diff --git a/DONE.md b/DONE.md index 2f1b16c..be4194a 100644 --- a/DONE.md +++ b/DONE.md @@ -302,3 +302,91 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every - [x] Regenerate the AI asset set at role-specific aspect ratios and dimensions. - [x] Audit, process, and wire every generated asset into the game runtime. - [x] Add screenshot auditing and refresh the README screenshot gallery. + +## Access artifacts actually match the venue + +Completed in the working tree (uncommitted with the control pass). Headless **74/74**. + +- [x] Build artifacts from the event (`override_access`, carried items) and the venue policy, + not from `NpcType` alone. (`src/core/access_loadout.gd`; spawn signal is + `(profile, intent, npc_id, event)`.) +- [x] Joke Shop: valid ID+ticket, ID without ticket, expired/missing ID. Recognition does not + auto-grant on every `AUTHORIZED_GUEST`. +- [x] Wand venues roll `carried_items` per spawn (seeded); override can force a pocket. +- [x] Studio counterfeit / follow-through events carry `override_access`. +- [x] `AccessVerifier.verify(..., already_screened)` so a screened guest is not still denied. +- [x] Joke Shop / Studio `sidewalk` and `steps_end` waypoints so film/photo intents do not walk + to the entrance. +- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`. + +## Player control, focus ring, and radial action menu + +Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke +(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots. + +### Locomotion + +- [x] Wand off `W` (now `C` + wheel). `A`/`D` allow/deny only while inspect/wand is open + (they step the wheel while it is open). `Q` remains a closed-menu polite shortcut. +- [x] Accel/decel on walk; gravity only when airborne; `floor_snap_length` 0.3. +- [x] Mouse look only while captured and `not input_locked`. Freeze walk+look while radial / + inspect / wand / radio / review is up. Recapture on close. +- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse + capture and RMB does nothing. + +### Focus and head ring + +- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion + ray skips building hits. `Tab` still cycles 12 m. +- [x] 8-frame miss grace so walk-bob does not drop focus. +- [x] Billboarded torus on `Rig/Head`; gold idle, brighter while the radial is open; `Label3D` + `display_name`. HUD target line uses the display name, not the node name. +- [x] Screen-center crosshair. + +### Radial HUD + +- [x] `src/game/ui/action_radial.gd`: LMB or `Space` opens when someone is focused. Opening + click ignored for 120 ms. Slices: raise hand, point, head shake, polite, inspect, wand + (grey if no wand), warning, block, radio assist. Emergency stays on `R`. +- [x] Highlight by mouse angle, or `A`/`D` / arrows. Click slice or `Space` confirms. `Esc` / + RMB closes with no action (no recorder event). +- [x] Inspect / wand / radio slices close the wheel then open the existing panels. Freeze until + that panel closes. +- [x] Number keys `1`–`6` still work when the wheel is closed. + +### NPC display + +- [x] Procedural rig: neck, shoes, hair cap from seed, two eye spheres. Props, height + variation, walk bob, notice-head-turn kept. Collision capsule scaled to rig height. + Named `Head` is the ring attach point. Group `npc`. + +### Input, copy, tests + +- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel` + (Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`. +- [x] Windowed smoke: focus ring + name, wheel open/step/confirm/cancel + (`src/test/smoke_focus_radial.gd`, exits non-zero on failure). +- [x] README controls table, smoke checklist, test count, implemented/not-yet lists. + +## Runtime boot and spawn fixes + +Found while running the windowed smoke; all verified with a boot run and regenerated +screenshots. + +- [x] `GameManager` did not compile: `radial` was typed `CanvasLayer`, so + `radial.is_open` / `confirm_selected()` made `:=` inference fail. Typed as + `ActionRadial` (the parse error would have broken the whole main scene). +- [x] Camera sat at the guard's feet: `Head` had no height offset, and the new gravity pulled + the guard to the floor. `Head` is now at y=1.6, and the guard spawns on the landing. +- [x] Guard spawned inside the building collider and fell through the world. Building volume + moved back to z ∈ [-8, -4] so the door / awning / marquee sit on its front face; the + guard spawns in front of the door facing the street. +- [x] Steps rose away from the door; treads now rise toward the entrance. +- [x] Event scheduler was polled every physics frame (~60 Hz), spawning >1200 NPCs in + seconds. `ShiftCoordinator` now accumulates time and pulls once per simulated second; + regression test in `test_scenarios.gd`. +- [x] Live NPCs capped at `MAX_LIVE_NPCS` (16); routine traffic is dropped at the cap and a + scripted event evicts the oldest unfocused NPC. +- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in + `TODO.md`. +- [x] README screenshots regenerated with the fixed eye height and entrance staging. diff --git a/MEMORY.md b/MEMORY.md index 753d6e2..2404b78 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -13,11 +13,41 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default. - RNG: NPC reactions and event scheduling are seedable for deterministic tests. -## Current State +## Handoff (read this first) -- Next playable pass (planned, not coded): ADR-0007 locomotion/input layers, ADR-0008 cone focus + - head ring, ADR-0009 radial action menu, ADR-0010 procedural NPC presentation. Emergency stays on - `R`. Wand leaves `W`. Host sign-in follow-up is deferred. PLAN.md has the full pass notes. +- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable). + Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **77/77**. + Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0. +- `main` is **7 commits ahead of origin**, all committed locally, **not pushed**. The newest commit + is the control pass + boot/spawn fixes (access loadout, focus cone, action wheel, named input + actions, NPC rig, camera/building fixes, scheduler rate, NPC cap, screenshots, docs). +- Nothing is left uncommitted. Ask before pushing if you did not author the earlier commits. +- Next work, in order: (1) session shell in `TODO.md`; (2) world follow-ups listed at the top of + `TODO.md` (guard floats ~0.5 m, step colliders, skyline asset, marquee size); (3) push / release + when the user asks. +- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg. + +## Current State +- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy. + Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every + guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses + `controller.screened`. Joke Shop/Studio gained `sidewalk`/`steps_end` waypoints. Wand venues roll + `carried_items` per spawn (seeded). Tests in `src/test/tests/test_access_loadout.gd`. +- Wand shortcut is **`C`**, not `W` (`W` is walk). `radio_emergency` input map no longer includes W. + `hud.controls` / `hud.screen_first` updated. +- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`, + accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard. + `NpcAvatar` in group `npc`; neck/hair/eyes/shoes; torus head ring + `Label3D` name; `set_focus_visual`. + `src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB + cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed. +- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB), + `inspect` (E), `wand` (C). The old unused `interact` action was removed. +- Runtime fixes from the smoke: `Head` is +1.6 above the guard root (root sits on the floor); guard + spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door, + awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the + event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds); + `GameManager.MAX_LIVE_NPCS = 16` caps live avatars; skyline backdrop removed (opaque black asset). +- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX. - **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push. - NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines @@ -39,12 +69,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host (host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`, push. No local token needed. -- Core simulation, three scenarios, headless tests (**57 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, v0.2.1 installers/AppImage, first-person prototype (inspect/wand/radio, shift review, Tab targeting, V venue cycle) are on local `main`. Tagged v0.2.1 was 57 tests; this working tree is **74/74**. - 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). +- Wand screening (Joke Shop): `WandScreener` classifies carried items as prohibited vs false-alarm vs clear. `NpcProfile.carried_items` is filled at spawn on wand venues. `GameManager` binds **`C`** to `_toggle_wand_screening` and `A`/`D` to `decide_wand` while inspect/wand is open. `decide_access` still blocks admission until `controller.screened` is true, and `verify` now takes that flag. Door-staff lines log through GameManager. - Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**. `Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on `NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines` @@ -119,8 +149,20 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history). - macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled). - Joke Shop `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. -- `GameManager._artifacts_for` ignores event `override_access` and maps artifacts by `NpcType`. Joke Shop guests currently inspect as recognized staff. Tracked in TODO "Access artifacts actually match the venue". +- Access loadout is implemented in the working tree (see Current State); do not re-fix `override_access` as if it were still unread. - `Save.SETTINGS_FILE` is declared and unused; no mouse-sensitivity or pause menu yet. +- Headless suite is **77/77**; the windowed focus/radial smoke passes. Remaining visual follow-ups are at the top of `TODO.md`. +- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args). +- GDScript inference trap that broke the main scene: a `var` typed as a base class + (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a + parse error. Type it as the script type (`ActionRadial`) or annotate the local explicitly. + `--check-only --script` cannot see the `Save` autoload; use a real boot (`--quit-after 180`) to + verify a full-scene compile. +- Do not snapshot right after changing a transform in the same frame: `capture_screenshots.gd` + needs a state/frame gap or the capture uses the old camera orientation. +- `EventScheduler` probabilities are per simulated second, not per call. Never poll it per frame. +- Screenshot staging lives in `src/test/capture_screenshots.gd` (guard at z≈10.5 facing the door). + `docs/screenshots/` copies are manual: copy from `user://screenshots/`. ## Open Questions @@ -130,13 +172,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o ## Plan vs code (re-evaluated) -Shipped and tested: simulation core, three venues, 57 headless tests, v0.2.1 installers/AppImage, -procedural world, HUD inspect/wand/radio, shift review (summary only), Tab targeting, V venue cycle. +On local `main` (6 commits not on origin): v0.2.1 packages, ADRs 0007–0010, asset pipeline wired to +`assets/textures/ai/game_ready/`, screenshots refreshed. -Still missing (now in `TODO.md`, control pass first): ADR-0007–0010 input/focus/radial/NPC face; -venue-correct access artifacts (`override_access` unread); startup/pause/settings; eye contact, -recognize-as-verb, body-cam toggle, real blocking, request-reason; crowd rubberneck; emergency -aftermath; body-cam replay timeline; sit/photo/film poses; real crowd audio; a few Joke Shop / -Studio scripted cases; README test-count drift (says 52). +Uncommitted: `AccessLoadout`, control pass (cone, ring, radial, NPC face, named input actions), +and boot/spawn fixes. Headless 77/77; windowed smoke passes. Access-artifact and control-pass +sections moved to `DONE.md`. + +Still in `TODO.md`: world follow-ups (guard float, step colliders, skyline asset, marquee size); +session shell; leftover PLAN verbs; crowd rubberneck; emergency aftermath; body-cam replay; +sit/photo/film poses; real crowd audio; Joke Shop/Studio extra events; PLAN.md says GUT/GitHub. diff --git a/README.md b/README.md index 08a3d73..8b55f6d 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,9 @@ godot --headless --path . --script res://src/test/test_runner.gd # Save a screenshot to user:// (windowed) godot --path . --script res://src/test/take_screenshot.gd + +# Windowed control smoke: focus ring + action wheel, saves PNGs, exits 1 on failure +godot --path . --script res://src/test/smoke_focus_radial.gd ``` The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. @@ -210,15 +213,18 @@ The unit test runner is the project's own lightweight harness (no editor plugin | Input | Action | | --- | --- | | W/A/S/D + mouse | Move and look | -| Esc | Release / capture mouse | +| Space / left click | Action wheel for the focused person | +| A / D or ←/→ | Step through the action wheel (while it is open) | +| Space / left click | Confirm the highlighted action (while the wheel is open) | +| Esc / right click | Close the top panel; Esc with nothing open releases / captures the mouse | +| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) | | Q | Polite direction (Level 1) | -| 1 / 2 / 3 | Simple gestures: raise hand, point, head shake (Level 1) | | 4 | Formal warning (Level 2) | | 5 | Blocking and positioning (Level 3) | | 6 | Security assistance radio (Level 4) | | R | Emergency radio, hold on-screen button ~1.6s (Level 5) | | E | Inspect access artifacts / close inspection | -| W | Handheld wand screening (wand venues only) | +| C | Handheld wand screening (wand venues only) | | A / D | Allow / deny entry during inspection or wand check | | V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) | | Tab | Cycle target among nearby NPCs (aim-free alternative) | @@ -247,28 +253,31 @@ Implemented so far: - Godot 4.3 project scaffold, input map, procedural world builder - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings) +- Headless unit tests: **77 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone and action wheel) - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review +- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) -Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). +Not yet implemented: body-camera replay timeline, venue selection startup menu, pause/settings shell, eye-contact and request-reason verbs, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). ### Smoke Test Checklist 1. `godot --path .` starts the game without script errors. 2. The guard moves and looks around; the building, steps, and sidewalk are visible. 3. NPCs spawn and approach the entrance during the first minutes. -4. Aiming at an NPC shows its archetype and intent in the HUD. -5. Pressing `Q` produces a reaction log entry and reputation feedback. -6. `E` opens access inspection showing artifacts and policy result; `A`/`D` record a decision. -7. `6` opens the assistance form; submitting shows dispatch feedback. -8. `R` opens the emergency form; the button must be held ~1.6s or the call cancels. -9. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked). -10. `F` toggles the debug overlay with NPC states and shift stats. -11. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). -12. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history. +4. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line. +5. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry. +6. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand. +7. Pressing `Q` produces a reaction log entry and reputation feedback. +8. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision. +9. `6` opens the assistance form; submitting shows dispatch feedback. +10. `R` opens the emergency form; the button must be held ~1.6s or the call cancels. +11. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked). +12. `F` toggles the debug overlay with NPC states and shift stats. +13. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). +14. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history. ## Development Workflow diff --git a/TODO.md b/TODO.md index 42a50ac..30b9210 100644 --- a/TODO.md +++ b/TODO.md @@ -1,76 +1,25 @@ # TODO -Checked against `PLAN.md`, ADRs 0001–0010, `README.md`, `DONE.md`, and `src/` (Godot 4.3 -prototype, release **v0.2.1**, 57 headless tests). Do the control pass first; it unblocks -everything that uses focus or verbs. +Godot 4.3 prototype. Release **v0.2.1** is live on the host. The working tree (uncommitted) +has the access-loadout fix and the ADR-0007–0010 control pass; headless suite **77/77** and a +windowed focus/radial smoke pass. Host `REQUIRE_SIGNIN_VIEW` stays deferred. -## Player control, focus ring, and radial action menu +## World and presentation follow-ups (found during the control smoke) -Design: `PLAN.md` → "Player Control, Focus, and Action Menu" and ADRs 0007–0010. - -### Locomotion - -- [ ] Stop stealing WASD: wand off `W` (wheel + optional `C`); allow/deny `A`/`D` only while - inspect/wand is open; polite direction off `Q` as a required bind. -- [ ] Accel/decel on walk; gravity only when airborne; `floor_snap_length` for the steps. -- [ ] Mouse look only while captured and no modal UI is open. Freeze walk+look while the radial - (and inspect/wand opened from it) is up. Recapture on close. -- [ ] `Esc` closes the top modal first; with nothing open it still toggles mouse capture. - -### Focus and head ring - -- [ ] Replace the 8 m hairline ray as the primary picker with a focus cone (range ~10 m, head - inside a ~90 px / 10° circle around the crosshair; closest to center wins; occluded heads - skipped). Keep `Tab` as the 12 m cycle. -- [ ] Small grace so walk-bob does not drop focus. -- [ ] Billboarded ring on the focused avatar `Head`; gold idle, brighter while the radial is open; - one at a time. Optional `display_name` under the ring. No internal type name in the world. -- [ ] Add a small screen-center crosshair so the focus circle has a visible origin. - -### Radial HUD - -- [ ] New `src/game/ui/action_radial.gd` doughnut: open on LMB or `Space` when someone is focused. - Opening click does not pick a slice. Slices (top, clockwise, de-escalation first): raise - hand, point, head shake, polite direction, inspect, wand (grey if no wand), warning, block, - radio assist. Emergency stays on `R` only. -- [ ] Highlight by mouse angle, or `A`/`D` / arrows. Click slice or `Space` confirms. `Esc` / RMB - closes with no action and no recorder event. -- [ ] Inspect / wand / radio slices close the wheel then open the existing panels. Freeze until - that panel closes. -- [ ] Number keys `1`–`6` may remain as closed-menu shortcuts; they must not fire while the wheel - is open. - -### NPC display - -- [ ] Procedural rig: neck, shoes, hair cap from seed, two eye spheres so the head has a front. - Keep props, height variation, walk bob, notice-head-turn. Collision capsule matches height. - Named `Head` node stays the attach point. - -### Input, copy, tests - -- [ ] Update `project.godot` input map (`action_menu`, `confirm`, `cancel` includes RMB, wand on - `C`). Update `hud.controls` and the README table + smoke checklist. -- [ ] Headless tests `src/test/tests/test_focus_and_radial.gd`: cone picks the centered head; - highlight wrap; cancel writes no recorder event; `W` does not open wand. - -## Access artifacts actually match the venue - -`GameManager._artifacts_for` maps `NpcType` → a wristband or free `RECOGNITION`. Joke Shop -events set `override_access` `{has_ticket, has_id}` and nobody reads it, so club guests inspect -as "recognized staff" instead of ID/ticket. Studio counterfeit/follow events have the same hole. - -- [ ] Build artifacts from the event (`override_access`, carried items) and the venue policy, - not from `NpcType` alone. -- [ ] Joke Shop: valid ID+ticket, ID without ticket, expired/mismatched/missing ID, no ID when - required. Recognition must not auto-grant on every `AUTHORIZED_GUEST`. -- [ ] Give some guests `carried_items` (keys / belt / phone / prohibited) so wand alerts are not - always clear. -- [ ] Studio: counterfeit wristband/credential and "follow authorized guest" use the scripted - event data, not the generic type map. -- [ ] Headless tests: Joke Shop `guest_no_id` is not granted by recognition; `guest_valid` - grants only with ID+ticket; wand venues still require `controller.screened`. +- [ ] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an + opaque black background, so the planes rendered as black slabs with window dots. Re-add + the backdrop only with a transparent asset or a keyed shader. +- [ ] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule + offset and the spawn `y`, and verify eye height while walking the steps. +- [ ] Steps and the landing are visual-only (no collider). The guard walks through them; add + simple step colliders or restage the landing so it does not intersect the walk path. +- [ ] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up. +- [ ] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a + real playtest. +- [ ] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short + despawn/lifetime for ordinary guests so the entrance does not fill up with standers. ## Session shell @@ -85,11 +34,13 @@ The game boots into a live 900 s shift. `Save` declares `settings.json` and neve - [ ] Shift review "Run another shift" returns to the venue menu, not a silent restart of the same scenario. -## Remaining PLAN verbs (after the radial exists) +## Remaining PLAN verbs (radial exists) -- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. +- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a + radial slice if it ships. - [ ] "I recognize this person" is a player decision recorded as evidence, not a pre-attached - artifact on every staff/guest type. + artifact on every staff/guest type. Staff still auto-recognize when `override_access` is + empty (`AccessLoadout`). - [ ] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay is currently always on; toggling should write an incident event. - [ ] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only @@ -122,10 +73,11 @@ wants evacuation, a closed street, and an investigation when the call was unjust ## NPC theater -Intents `sit` / `photo` / `film` / `perform` already pick waypoints. Bodies still idle-stand. +Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair) +is in the working tree. Bodies still idle-stand at those spots. - [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer - stays put and plays the hat/sign. After the ADR-0010 rig pass, not before. + stays put and plays the hat/sign. ## Audio @@ -151,12 +103,11 @@ Intents `sit` / `photo` / `film` / `perform` already pick waypoints. Bodies stil ## Documentation drift -- [ ] README Development Status: test count is **57**, not 52. "Not yet implemented" should - match this file (control pass, artifact wiring, session shell, replay, crowd presentation). +- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists + refreshed for the control pass (77 tests). - [ ] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`. -- [ ] MEMORY: drop "door_staff_lines not surfaced" (they log through `GameManager`) and the - obsolete "prototype defaults to 300 s" decision. +- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed. ## Distribution follow-up (host config, deferred) @@ -164,15 +115,10 @@ Verified via API+host credentials (2026-10-31): - [x] Repo is **public** (`private:false`) and account has admin rights. - [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer + - AppImage) — the two installers were missing after CI publish (script uploaded only the - portables); `tools/publish_release.sh` fixed to include them and re-uploaded via API. + AppImage). - [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes - anonymous browsing/downloads redirect to login (API-confirmed repo is public; this is a - server `app.ini` setting, not an API or repo option). - Advised path for the host admin: set `REQUIRE_SIGNIN_VIEW = false` in `app.ini` under - `[service]`, restart Gitea, then verify `curl -sI https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.2.1` - returns 200 (not a login redirect) and an anonymous asset download succeeds. Alternative: - mirror downloads (Itch.io / GitHub Releases) for anonymous users. + anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false` + under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads. ## Open Polish @@ -182,4 +128,3 @@ Verified via API+host credentials (2026-10-31): - [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays. - [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions. - Not required for the control pass. diff --git a/content/strings.en.json b/content/strings.en.json index b38abb3..a388c0d 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -1,12 +1,21 @@ { - "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 · V venue · G flashlight · L lock door · F debug", + "hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · F debug", + "radial.raise_hand": "Stop", + "radial.point": "Point", + "radial.head_shake": "No", + "radial.polite": "Ask", + "radial.inspect": "Inspect", + "radial.wand": "Wand", + "radial.warning": "Warn", + "radial.block": "Block", + "radial.radio": "Radio", "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.", + "hud.screen_first": "Screen this guest with the wand (C) before admitting them.", "access.granted": "Entry granted: %s", "access.denied": "Entry denied: %s", "access.policy_satisfied": "policy satisfied", diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index e8c1964..1630979 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index 37a4143..bf89da4 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index a730542..24f152a 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index 331da68..99a0f03 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png index 61d0a23..7c47ded 100644 Binary files a/docs/screenshots/05-late-night-avenue.png and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png index 7b83767..db3715e 100644 Binary files a/docs/screenshots/06-night-flashlight.png and b/docs/screenshots/06-night-flashlight.png differ diff --git a/project.godot b/project.godot index a4c8a53..d01886b 100644 --- a/project.godot +++ b/project.godot @@ -47,18 +47,6 @@ move_right={ , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } -interact={ -"deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) -, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) -] -} -cancel={ -"deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null) -, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) -] -} gesture_raise_hand={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null) @@ -91,8 +79,35 @@ radio_assist={ } radio_emergency={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":87,"location":0,"echo":false,"script":null) -, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null) +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null) +] +} +action_menu={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +confirm={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +cancel={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +inspect={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) +] +} +wand={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null) ] } print_debug={ diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index ab75203..804197f 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -393,6 +393,8 @@ static func joke_shop() -> VenueScenario: "guard_post": Vector3(0, 0, -3), "entrance": Vector3(0, 0, 1.0), "door_area": Vector3(0, 0, 3), + "steps_end": Vector3(0, 0, 3), + "sidewalk": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9), } scenario.door_staff_lines = { @@ -433,7 +435,7 @@ static func joke_shop() -> VenueScenario: scenario.event_pool = [ _joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30), _joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18), - _joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12), + _joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}), _joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12), _joke_staff_entry(Vector2(0, 900), 0.25), _joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15), @@ -475,6 +477,8 @@ static func tv_studio() -> VenueScenario: "guard_post": Vector3(0, 0, -3), "entrance": Vector3(0, 0, 1.0), "stage_door": Vector3(6, 0, -2), + "steps_end": Vector3(0, 0, 3), + "sidewalk": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9), } @@ -492,9 +496,9 @@ static func tv_studio() -> VenueScenario: ]) scenario.event_pool = [ - _studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35), + _studio_guest("audience_ticket", {"has_ticket": true}, Vector2(0, 900), 0.35), _studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18), - _studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20), + _studio_guest("celebrity_arrival", {"has_guest_list": true}, Vector2(300, 700), 0.20, "arrive"), _studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30), _studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35), _studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25), @@ -509,6 +513,7 @@ static func tv_studio() -> VenueScenario: "time_range_s": Vector2(620, 780), "probability": 0.55, "base_weight": 1.0, + "override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT}, }, { "id": "studio_follow_through", @@ -516,6 +521,7 @@ static func tv_studio() -> VenueScenario: "kind": "scripted", "npc_type": Rules.NpcType.TRESPASSER, "intent": "follow_authorized", + "override_access": {"has_ticket": false, "has_wristband": false, "has_credential": false, "has_guest_list": false}, "time_range_s": Vector2(700, 860), "probability": 0.45, "base_weight": 1.0, @@ -549,12 +555,23 @@ static func _building_routine(id: String, npc_type: int, intent: String, time_ra "time_range_s": time_range, "probability": prob, "base_weight": 1.0, } -static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float) -> Dictionary: +static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float, extra: Dictionary = {}) -> Dictionary: + var override := {"has_ticket": has_ticket, "has_id": has_id} + for key in extra.keys(): + override[key] = extra[key] return { "id": id, "phase": _phase_for_range(time_range, 900.0), "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter", "time_range_s": time_range, "probability": prob, "base_weight": 1.0, - "override_access": {"has_ticket": has_ticket, "has_id": has_id}, + "override_access": override, + } + +static func _studio_guest(id: String, override_access: Dictionary, time_range: Vector2, prob: float, intent: String = "enter") -> Dictionary: + return { + "id": id, "phase": _phase_for_range(time_range, 900.0), + "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": intent, + "time_range_s": time_range, "probability": prob, "base_weight": 1.0, + "override_access": override_access, } static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary: diff --git a/src/core/access_loadout.gd b/src/core/access_loadout.gd new file mode 100644 index 0000000..6f1552a --- /dev/null +++ b/src/core/access_loadout.gd @@ -0,0 +1,134 @@ +extends RefCounted +## Builds the access artifacts a person actually presents. +## Event `override_access` wins; otherwise a venue-appropriate default from +## type + policy. Recognition is never attached to every guest. + +const Rules := preload("res://src/core/rules.gd") +const AccessArtifact := preload("res://src/core/access_artifact.gd") +const AccessPolicy := preload("res://src/core/access_policy.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") + +static func build(profile: NpcProfile, policy: AccessPolicy, override: Dictionary = {}) -> Array: + var npc_type: int = profile.npc_type if profile else Rules.NpcType.TOURIST + var out: Array = [] + if override.get("recognized", false) or (override.is_empty() and npc_type == Rules.NpcType.AUTHORIZED_STAFF): + out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true})) + if not override.is_empty(): + _append_override(out, profile, override) + return out + if policy != null and (policy.require_id or policy.requires(Rules.AccessKind.ID)): + _append_id_ticket_defaults(out, profile, npc_type, policy) + return out + if policy != null and policy.grant_if_any_required: + _append_or_defaults(out, npc_type) + return out + _append_wristband_defaults(out, npc_type) + return out + +static func roll_carried_items(seed_value: int, override: Dictionary = {}) -> Array: + if override.has("carried_items"): + return override["carried_items"] + var rng := RandomNumberGenerator.new() + rng.seed = seed_value if seed_value != 0 else 1 + var roll := rng.randf() + if roll < 0.08: + return ["keys", "knife"] + if roll < 0.40: + return ["keys"] + if roll < 0.62: + return ["phone"] + if roll < 0.80: + return ["belt"] + return [] + +static func _append_override(out: Array, profile: NpcProfile, override: Dictionary) -> void: + if override.has("has_id") or override.has("id_status"): + var has_id := bool(override.get("has_id", true)) + var status: int = int(override.get("id_status", Rules.ArtifactStatus.VALID if has_id else Rules.ArtifactStatus.MISSING)) + if not has_id: + status = Rules.ArtifactStatus.MISSING + var attrs := {} + if status != Rules.ArtifactStatus.MISSING: + attrs["age"] = int(override.get("age", 24)) + attrs["name"] = profile.display_name if profile else "Guest" + out.append(AccessArtifact.new(Rules.AccessKind.ID, status, attrs)) + if override.has("has_ticket") or override.has("ticket_status"): + var has_ticket := bool(override.get("has_ticket", true)) + var status: int = int(override.get("ticket_status", Rules.ArtifactStatus.VALID if has_ticket else Rules.ArtifactStatus.MISSING)) + if not has_ticket: + status = Rules.ArtifactStatus.MISSING + out.append(AccessArtifact.new(Rules.AccessKind.TICKET, status, {})) + if override.has("has_wristband") or override.has("wristband_status"): + var status := _present_status(override, "has_wristband", "wristband_status") + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, status, _band_attrs(status))) + if override.has("has_credential") or override.has("credential_status"): + var status := _present_status(override, "has_credential", "credential_status") + out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, status, {"role": "staff"})) + if override.has("has_guest_list") or override.has("guest_list_status"): + var status := _present_status(override, "has_guest_list", "guest_list_status") + out.append(AccessArtifact.new(Rules.AccessKind.GUEST_LIST, status, {})) + +static func _present_status(override: Dictionary, has_key: String, status_key: String) -> int: + if override.has(status_key): + return int(override[status_key]) + return Rules.ArtifactStatus.VALID if bool(override.get(has_key, false)) else Rules.ArtifactStatus.MISSING + +static func _append_id_ticket_defaults(out: Array, profile: NpcProfile, npc_type: int, policy: AccessPolicy) -> void: + if npc_type == Rules.NpcType.AUTHORIZED_STAFF: + return + if npc_type == Rules.NpcType.AUTHORIZED_GUEST: + out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, { + "age": 24, "name": profile.display_name if profile else "Guest", + })) + if policy.requires(Rules.AccessKind.TICKET): + out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {})) + return + out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.MISSING, {})) + if policy.requires(Rules.AccessKind.TICKET): + out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.MISSING, {})) + +static func _append_or_defaults(out: Array, npc_type: int) -> void: + match npc_type: + Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY: + if not _has_kind(out, Rules.AccessKind.RECOGNITION): + out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "staff"})) + Rules.NpcType.AUTHORIZED_GUEST: + out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {})) + Rules.NpcType.TRESPASSER: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT))) + Rules.NpcType.CONFUSED_VISITOR: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS))) + _: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {})) + +static func _append_wristband_defaults(out: Array, npc_type: int) -> void: + match npc_type: + Rules.NpcType.AUTHORIZED_STAFF: + pass + Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.DELIVERY: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, _band_attrs(Rules.ArtifactStatus.VALID))) + if npc_type == Rules.NpcType.DELIVERY: + out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"})) + Rules.NpcType.TRESPASSER: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT))) + Rules.NpcType.CONFUSED_VISITOR: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS))) + _: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {})) + +static func _band_attrs(status: int) -> Dictionary: + match status: + Rules.ArtifactStatus.VALID: + return {"color": "red", "date": "2026-02-13", "event": "gala"} + Rules.ArtifactStatus.COUNTERFEIT: + return {"color": "red", "date": "2024-01-01"} + Rules.ArtifactStatus.AMBIGUOUS: + return {"color": "gold"} + _: + return {} + +static func _has_kind(artifacts: Array, kind: int) -> bool: + for artifact in artifacts: + if artifact is AccessArtifact and artifact.kind == kind: + return true + return false diff --git a/src/core/access_verifier.gd b/src/core/access_verifier.gd index 0c2ef93..41287f9 100644 --- a/src/core/access_verifier.gd +++ b/src/core/access_verifier.gd @@ -15,7 +15,7 @@ func _init() -> void: pass ## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]). -func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary: +func verify(person_artifacts: Array, policy: AccessPolicy, already_screened: bool = false) -> Dictionary: var reasons: Array[String] = [] var issues: Array[String] = [] var checked: Array[String] = [] @@ -48,7 +48,7 @@ func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary: # Wand screening is processed by the interaction layer (false alarms), but a # policy can hard-require it; a refused screen blocks entry. - if policy.wand_screening_required and not person_screened(person_artifacts): + if policy.wand_screening_required and not already_screened and not person_screened(person_artifacts): issues.append("Screening required") return _result(false, reasons, issues, checked) diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd index 9f144ea..d710516 100644 --- a/src/core/npc_controller.gd +++ b/src/core/npc_controller.gd @@ -34,6 +34,9 @@ var guard_noticed := false ## Whether this NPC has passed the handheld wand screening (The Joke Shop). var screened := false +## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means +## venue/type defaults from AccessLoadout. +var access_override: Dictionary = {} func _init() -> void: fsm = FSM.new(self) diff --git a/src/core/shift_coordinator.gd b/src/core/shift_coordinator.gd index b11a2ee..48fc9fd 100644 --- a/src/core/shift_coordinator.gd +++ b/src/core/shift_coordinator.gd @@ -13,7 +13,7 @@ const EventScheduler := preload("res://src/core/event_scheduler.gd") ## Drives one shift: advances time, schedules events, owns the shared systems ## (recorder, reputation, radio, crowd), and produces the shift review. -signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String) +signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary) signal shift_started(payload: Dictionary) signal shift_ended(review: Dictionary) signal phase_changed(phase: int) @@ -34,6 +34,10 @@ var scheduler := EventScheduler.new() var spawned_count := 0 var spawn_seed := 0 var _shift_finished := false +## The scheduler rolls once per simulated second. `tick()` runs at physics rate, +## so accumulate time and only pull due events on whole-second boundaries. +const SPAWN_INTERVAL_S := 1.0 +var _spawn_accum_s := 0.0 func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void: scenario = p_scenario @@ -56,6 +60,7 @@ func start() -> void: running = true _shift_finished = false elapsed_s = 0.0 + _spawn_accum_s = 0.0 current_phase = _phase_for_time(0.0) event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase}) shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s}) @@ -77,7 +82,12 @@ func tick(delta: float) -> void: if elapsed_s >= duration_s: _finish_shift() return - _spawn_due(delta) + _spawn_accum_s += delta + if _spawn_accum_s >= SPAWN_INTERVAL_S: + _spawn_accum_s -= SPAWN_INTERVAL_S + if _spawn_accum_s > SPAWN_INTERVAL_S: + _spawn_accum_s = 0.0 + _spawn_due(SPAWN_INTERVAL_S) func _phase_for_time(t: float) -> int: if scenario == null or scenario.shift_phases.is_empty(): @@ -102,7 +112,7 @@ func _spawn_due(delta: float) -> void: var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST)) if profile == null: continue - npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id) + npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id, event) func _finish_shift() -> void: if _shift_finished: diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index c6d13a0..e3ee4d2 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -6,6 +6,7 @@ extends Node 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 AccessLoadout := preload("res://src/core/access_loadout.gd") const AccessVerifier := preload("res://src/core/access_verifier.gd") const DoorState := preload("res://src/core/door_state.gd") const WandScreener := preload("res://src/core/wand_screener.gd") @@ -19,6 +20,7 @@ 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 ActionRadial := preload("res://src/game/ui/action_radial.gd") const RadioPanel := preload("res://src/game/ui/radio_panel.gd") const ShiftReview := preload("res://src/game/ui/shift_review.gd") @@ -57,8 +59,10 @@ const PHASE_LIGHTING := { }, } var hud: Hud = null +var radial: ActionRadial = null var radio_panel: RadioPanel = null var shift_review_panel: ShiftReview = null +var _focused_avatar: Node3D = null var audio: AudioFeedback = null var verifier := AccessVerifier.new() var wand_screener := WandScreener.new() @@ -88,6 +92,9 @@ var _door_rng := RandomNumberGenerator.new() ## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility). const NEARBY_RADIUS := 12.0 +## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted +## events evict the oldest unfocused NPC so authored moments still spawn. +const MAX_LIVE_NPCS := 16 var _nearby_index := -1 func _ready() -> void: @@ -102,7 +109,10 @@ func _ready() -> void: guard = _build_guard() scene_root.add_child(guard) - guard.global_position = Vector3(0, 1.6, -2.5) + # Stand on the landing in front of the door, facing the street where guests + # arrive. The building volume now starts behind the door (world_builder). + guard.global_position = Vector3(0, 0.1, -2.0) + guard.rotation.y = PI guard.picked_target.connect(_on_guard_target) guard.cleared_target.connect(_on_guard_clear) @@ -126,6 +136,9 @@ func _build_guard() -> GuardController: g.name = "Guard" var head := Node3D.new() head.name = "Head" + # Eye height. The guard root sits on the floor, so this keeps the camera at + # ~1.7 m while gravity/floor snapping handles the steps (ADR-0007). + head.position = Vector3(0, 1.6, 0) g.add_child(head) var camera := Camera3D.new() camera.name = "Camera3D" @@ -185,20 +198,52 @@ var inspector_current_events: Array = [] func _build_ui() -> void: hud = Hud.new() add_child(hud) + _add_crosshair() audio = AudioFeedback.new() add_child(audio) + radial = ActionRadial.new() + add_child(radial) + radial.confirmed.connect(_on_radial_confirmed) + radial.cancelled.connect(_cancel_radial) radio_panel = RadioPanel.new() add_child(radio_panel) radio_panel.hide() radio_panel.submitted.connect(_on_radio_submitted) radio_panel.cancelled.connect(func(): radio_panel.hide() - hud.set_action_hint("")) + hud.set_action_hint("") + _refresh_freeze()) shift_review_panel = ShiftReview.new() add_child(shift_review_panel) shift_review_panel.hide() shift_review_panel.restart_requested.connect(restart_shift) +func _add_crosshair() -> void: + var layer := CanvasLayer.new() + layer.layer = 9 + layer.name = "Crosshair" + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + layer.add_child(center) + var box := Control.new() + box.custom_minimum_size = Vector2(16, 16) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + center.add_child(box) + var h := ColorRect.new() + h.color = Color(1, 1, 1, 0.75) + h.size = Vector2(12, 2) + h.position = Vector2(2, 7) + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_child(h) + var v := ColorRect.new() + v.color = Color(1, 1, 1, 0.75) + v.size = Vector2(2, 12) + v.position = Vector2(7, 2) + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_child(v) + add_child(layer) + func _ensure_lighting() -> void: _sun = DirectionalLight3D.new() _sun.name = "Sun" @@ -239,7 +284,12 @@ func _apply_phase_lighting() -> void: if world_builder: world_builder.set_night_mode(cfg.get("night", false)) -func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void: +func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void: + _purge_npcs() + if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine": + return + if npcs.size() >= MAX_LIVE_NPCS: + _evict_oldest_npc() npc_counter += 1 var avatar := NpcAvatar.new() avatar.name = "Npc_%d" % npc_counter @@ -250,10 +300,35 @@ func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void: # 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) + var spawn_profile: NpcProfile = profile.duplicate(true) if profile else profile + var override: Dictionary = event.get("override_access", {}) + var seed_value := npc_counter * 991 + 17 + if spawn_profile and selected_scenario.access_policy.wand_screening_required: + var items: Array[String] = [] + for item in AccessLoadout.roll_carried_items(seed_value, override): + items.append(str(item)) + spawn_profile.carried_items = items + avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override) scene_root.add_child(avatar) npcs.append(avatar) +## Drop references to freed avatars so the cap and Tab cycling stay accurate. +func _purge_npcs() -> void: + for i in range(npcs.size() - 1, -1, -1): + if not is_instance_valid(npcs[i]): + npcs.remove_at(i) + +## Make room for a scripted event by removing the oldest NPC the player is not +## currently focused on. +func _evict_oldest_npc() -> void: + for i in npcs.size(): + var npc: Node = npcs[i] + if npc == targeted_npc or npc == _focused_avatar: + continue + npcs.remove_at(i) + npc.queue_free() + return + ## 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. @@ -291,16 +366,27 @@ func _on_shift_ended(review: Dictionary) -> void: if Save: Save.save_shift_result(review) shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else []) + _refresh_freeze() func _on_guard_target(target: Node3D) -> void: targeted_npc = target + _set_focused_avatar(target) _begin_incident(target) func _on_guard_clear() -> void: targeted_npc = null + _set_focused_avatar(null) hud.set_target_label("") hud.set_action_hint("") +func _set_focused_avatar(avatar: Node3D) -> void: + if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"): + _focused_avatar.set_focus_visual(false, false) + _focused_avatar = avatar + var menu_open := radial != null and radial.is_open + if avatar and avatar.has_method("set_focus_visual"): + avatar.set_focus_visual(true, menu_open) + func _begin_incident(avatar: Node3D) -> void: var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null if controller == null: @@ -309,8 +395,7 @@ func _begin_incident(avatar: Node3D) -> void: current_incident_active = true current_npc_truth = _read_npc_truth(controller) var profile: Object = controller.profile - var label := "[%s] %s — %s" % [ - Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"), + var label := "%s — %s" % [ profile.display_name if profile else "?", controller.intent, ] @@ -327,20 +412,62 @@ func _begin_incident(avatar: Node3D) -> void: # ------------------------------------------------------------------ input --- func _unhandled_input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and not event.echo: - _handle_key(event.keycode) + if event is InputEventKey and event.echo: + return + if event.is_action_pressed("cancel"): + _handle_cancel(event is InputEventMouseButton) + return + if event is InputEventMouseButton and event.pressed: + _handle_mouse_button(event) + return + if event is InputEventKey and event.pressed: + _handle_key(event) -func _handle_key(keycode: Key) -> void: +func _handle_mouse_button(event: InputEventMouseButton) -> void: + if event.button_index != MOUSE_BUTTON_LEFT: + return + if radial and radial.is_open: + return + if not _modal_blocks_radial() and targeted_npc != null: + _open_radial() + +func _modal_blocks_radial() -> bool: + if inspection_open or wand_open: + return true + if radio_panel and radio_panel.visible: + return true + if shift_review_panel and shift_review_panel.visible: + return true + return false + +func _handle_key(event: InputEvent) -> void: + var keycode: Key = event.keycode + if radial and radial.is_open: + if event.is_action_pressed("confirm"): + _confirm_radial() + return + match keycode: + KEY_A, KEY_LEFT: + radial.step(-1) + KEY_D, KEY_RIGHT: + radial.step(1) + return + if event.is_action_pressed("action_menu"): + if targeted_npc != null and not _modal_blocks_radial(): + _open_radial() + return + if event.is_action_pressed("inspect"): + if targeted_npc != null and not inspection_open and not wand_open: + _toggle_inspection() + return + if event.is_action_pressed("wand"): + if targeted_npc != null and not inspection_open and not wand_open: + _toggle_wand_screening() + return match keycode: - KEY_E: - if targeted_npc != null and not inspection_open: - _toggle_inspection() KEY_Q: if targeted_npc != null and not inspection_open: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction") - KEY_W: - if targeted_npc != null and not inspection_open: - _toggle_wand_screening() KEY_TAB: _cycle_nearby_target() KEY_F: @@ -383,6 +510,113 @@ func _handle_action_key(keycode: Key) -> void: _: pass +## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse +## capture; RMB does nothing so it never grabs the mouse by accident. +func _handle_cancel(from_mouse: bool) -> void: + if _close_top_modal(): + return + if from_mouse: + return + if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + else: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + +func _close_top_modal() -> bool: + if radial and radial.is_open: + _cancel_radial() + return true + if inspection_open: + hud.hide_inspection() + inspection_open = false + _refresh_freeze() + return true + if wand_open: + hud.hide_wand() + wand_open = false + _refresh_freeze() + return true + if radio_panel and radio_panel.visible: + radio_panel.hide() + hud.set_action_hint("") + _refresh_freeze() + return true + return false + +func _open_radial() -> void: + if targeted_npc == null: + hud.log(Strings.lookup("hud.aim_first")) + return + var controller: Object = _targeted_controller() + var p_name := "Person" + if controller and controller.profile: + p_name = str(controller.profile.display_name) + var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand") + radial.open_menu(p_name, wand_ok) + if targeted_npc and targeted_npc.has_method("set_focus_visual"): + targeted_npc.set_focus_visual(true, true) + _refresh_freeze() + +func _confirm_radial() -> void: + if radial == null or not radial.is_open: + return + var slice := radial.confirm_selected() + radial.close_menu() + if targeted_npc and targeted_npc.has_method("set_focus_visual"): + targeted_npc.set_focus_visual(true, false) + _refresh_freeze() + if slice != "": + _on_radial_confirmed(slice) + +func _cancel_radial() -> void: + if radial == null or not radial.is_open: + return + radial.close_menu() + if targeted_npc and targeted_npc.has_method("set_focus_visual"): + targeted_npc.set_focus_visual(true, false) + _refresh_freeze() + +func _on_radial_confirmed(slice_id: String) -> void: + if radial and radial.is_open: + radial.close_menu() + if targeted_npc and targeted_npc.has_method("set_focus_visual"): + targeted_npc.set_focus_visual(true, false) + _refresh_freeze() + match slice_id: + "raise_hand": + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand") + "point": + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point") + "head_shake": + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake") + "polite": + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction") + "inspect": + _toggle_inspection() + "wand": + _toggle_wand_screening() + "warning": + _apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning") + "block": + _apply_interaction(Rules.Level.BLOCKING, "blocking") + "radio": + _open_radio(false) + +func _refresh_freeze() -> void: + var freeze := inspection_open or wand_open + if radial and radial.is_open: + freeze = true + if radio_panel and radio_panel.visible: + freeze = true + if shift_review_panel and shift_review_panel.visible: + freeze = true + if guard: + guard.input_locked = freeze + if freeze: + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + else: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + ## Toggle the guard's flashlight. In evening/late phases it widens NPC ## perception radius (PLAN.md: Level 3 — flashlight at night). func _toggle_flashlight() -> void: @@ -429,8 +663,13 @@ func _cycle_nearby_target() -> void: _nearby_index = (_nearby_index + 1) % nearby.size() var next_target: Node3D = nearby[_nearby_index] targeted_npc = next_target + _set_focused_avatar(next_target) _begin_incident(next_target) - hud.log(Strings.fmt("hud.targeting", [next_target.name])) + var controller: Object = _targeted_controller() + var who: String = next_target.name + if controller and controller.profile and controller.profile.display_name != "": + who = str(controller.profile.display_name) + hud.log(Strings.fmt("hud.targeting", [who])) func _apply_interaction(level: int, action_name: String) -> void: if targeted_npc == null: @@ -506,9 +745,11 @@ func _open_radio(emergency: bool) -> void: return radio_panel.open(controller, emergency, current_npc_truth) hud.set_action_hint("Fill in the radio report on screen.") + _refresh_freeze() func _on_radio_submitted(report: Dictionary, emergency: bool) -> void: hud.set_action_hint("") + _refresh_freeze() var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report) if not result.get("ok", false): hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")])) @@ -540,14 +781,16 @@ func _toggle_inspection() -> void: if inspection_open: hud.hide_inspection() inspection_open = false + _refresh_freeze() return var controller: Object = _targeted_controller() if controller == null: return inspection_open = true var artifacts := _artifacts_for(controller) - var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) + var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened) hud.show_inspection(controller, artifacts, result) + _refresh_freeze() # Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue). var line := _door_context_line() if line != "": @@ -565,6 +808,7 @@ func _toggle_wand_screening() -> void: hud.hide_wand() hud.set_wand_icon(false) wand_open = false + _refresh_freeze() return if not selected_scenario.equipment.has("wand"): hud.log(Strings.lookup("hud.no_wand")) @@ -579,6 +823,7 @@ func _toggle_wand_screening() -> void: var result: Dictionary = wand_screener.screen(items) wand_open = true hud.show_wand(controller, result) + _refresh_freeze() var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng) if wand_line != "": hud.log(wand_line) @@ -589,6 +834,7 @@ func decide_wand(allowed: bool) -> void: if controller == null: hud.hide_wand() wand_open = false + _refresh_freeze() return var profile: Object = controller.profile var items: Array = profile.carried_items if profile else [] @@ -624,6 +870,7 @@ func decide_wand(allowed: bool) -> void: controller.screened = true hud.hide_wand() wand_open = false + _refresh_freeze() # Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel). _wand_cooldown = WAND_COOLDOWN_S @@ -632,15 +879,17 @@ func decide_access(allowed: bool) -> void: if controller == null: hud.hide_inspection() inspection_open = false + _refresh_freeze() 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 + _refresh_freeze() return var artifacts := _artifacts_for(controller) - var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) + var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened) var granted: bool = result.get("granted", false) coordinator.recorder.add_access_evidence(controller.npc_id, { "decided": allowed, @@ -672,6 +921,7 @@ func decide_access(allowed: bool) -> void: audio.play_deny() hud.hide_inspection() inspection_open = false + _refresh_freeze() ## Venue-specific opening line for the access inspection (e.g. The Joke Shop's ## ID / ticket check lines). Returns "" when the venue has no matching lines. @@ -685,23 +935,10 @@ func _door_context_line() -> String: return "" func _artifacts_for(controller: Object) -> Array: - var profile: Object = controller.profile - var npc_type: int = profile.npc_type if profile else -1 - var out: Array = [] - match npc_type: - Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF: - out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true})) - Rules.NpcType.DELIVERY: - out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"})) - Rules.NpcType.TRESPASSER: - out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"})) - Rules.NpcType.TOURIST: - out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {})) - Rules.NpcType.CONFUSED_VISITOR: - out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"})) - _: - out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {})) - return out + var profile: NpcProfile = controller.profile as NpcProfile + var override: Dictionary = controller.access_override + var policy = selected_scenario.access_policy if selected_scenario else null + return AccessLoadout.build(profile, policy, override) func _read_npc_truth(controller: Object) -> Dictionary: var profile: Object = controller.profile diff --git a/src/game/guard_controller.gd b/src/game/guard_controller.gd index a689747..f365bab 100644 --- a/src/game/guard_controller.gd +++ b/src/game/guard_controller.gd @@ -1,35 +1,47 @@ class_name GuardController extends CharacterBody3D -## First-person guard controller: WASD movement, mouse look, interact raycast. +## First-person guard: WASD + mouse look. Focus is a cone on NPC heads (ADR-0007, ADR-0008). signal picked_target(target: Node) signal cleared_target() +const NpcFocus := preload("res://src/game/npc_focus.gd") + var move_speed: float = 3.2 var look_sensitivity: float = 0.0022 +var accel: float = 12.0 var is_ready := false var targeted_npc: Node3D = null +var input_locked := false +var _miss_frames := 0 +const MISS_GRACE := 8 @onready var camera: Camera3D = $Head/Camera3D @onready var head: Node3D = $Head func _ready() -> void: + floor_snap_length = 0.3 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) is_ready = true func _unhandled_input(event: InputEvent) -> void: + if input_locked: + return if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: rotate_y(-event.relative.x * look_sensitivity) head.rotate_x(-event.relative.y * look_sensitivity) head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) - elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: - if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: - Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) - else: - Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) func _physics_process(_delta: float) -> void: - # Movement relative to the head's yaw only. + if input_locked: + velocity.x = 0.0 + velocity.z = 0.0 + if not is_on_floor(): + velocity.y -= 9.8 * _delta + else: + velocity.y = 0.0 + move_and_slide() + return var forward := -global_transform.basis.z forward.y = 0.0 forward = forward.normalized() @@ -41,28 +53,49 @@ func _physics_process(_delta: float) -> void: Input.get_axis("move_left", "move_right"), Input.get_axis("move_forward", "move_back") ) - var direction := (forward * input_dir.y + right * input_dir.x).normalized() - velocity.x = direction.x * move_speed - velocity.z = direction.z * move_speed - velocity.y -= 9.8 * _delta + if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: + input_dir = Vector2.ZERO + var desired := (forward * input_dir.y + right * input_dir.x) + if desired.length() > 1.0: + desired = desired.normalized() + desired *= move_speed + velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed) + velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed) + if is_on_floor(): + velocity.y = 0.0 + else: + velocity.y -= 9.8 * _delta move_and_slide() _update_target() func _update_target() -> void: - var space := get_world_3d().direct_space_state - var from := camera.global_position - var to := from + -camera.global_transform.basis.z * 8.0 - var query := PhysicsRayQueryParameters3D.create(from, to) - query.collide_with_areas = true - query.collide_with_bodies = true - query.exclude = [get_rid()] - var hit := space.intersect_ray(query) + if camera == null: + return + var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else [] + var heads: Array = [] + var avatars: Array = [] + for node in npcs: + if not (node is Node3D) or not is_instance_valid(node): + continue + var avatar := node as Node3D + var head_node := avatar.get_node_or_null("Rig/Head") as Node3D + var pos: Vector3 = head_node.global_position if head_node else avatar.global_position + Vector3(0, 1.6, 0) + heads.append(pos) + avatars.append(avatar) + var cam_origin := camera.global_position + var cam_forward := -camera.global_transform.basis.z + var idx := NpcFocus.pick_index(cam_origin, cam_forward, heads) var new_target: Node3D = null - if hit: - var obj: Object = hit.get("collider") - var parent_avatar: Node3D = _find_avatar(obj) - if parent_avatar != null: - new_target = parent_avatar + if idx >= 0: + var candidate: Node3D = avatars[idx] + if _head_visible(cam_origin, heads[idx]): + new_target = candidate + if new_target == null and targeted_npc != null: + _miss_frames += 1 + if _miss_frames < MISS_GRACE: + return + else: + _miss_frames = 0 if new_target != targeted_npc: targeted_npc = new_target if new_target != null: @@ -70,14 +103,21 @@ func _update_target() -> void: else: cleared_target.emit() -func _find_avatar(obj: Object) -> Node3D: - # Avatars are CharacterBody3D with a collision shape; guard against self and scene junk. +func _head_visible(from: Vector3, to: Vector3) -> bool: + var space := get_world_3d().direct_space_state + if space == null: + return true + var query := PhysicsRayQueryParameters3D.create(from, to) + query.collide_with_areas = false + query.collide_with_bodies = true + query.exclude = [get_rid()] + var hit := space.intersect_ray(query) + if hit.is_empty(): + return true + var obj: Object = hit.get("collider") var n := obj as Node - if n == null: - return null - var current: Node = n - while current != null: - if current.has_method("get_npc_controller"): - return current as Node3D - current = current.get_parent() - return null + while n != null: + if n.is_in_group("npc"): + return true + n = n.get_parent() + return false diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index c59fc5e..4c6ead9 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -39,14 +39,17 @@ 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, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void: - npc.setup("npc_%d" % seed_value, profile, intent, seed_value) +func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void: + var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value + npc.setup(id, profile, intent, seed_value) + npc.access_override = p_override _visual_seed = 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) + add_to_group("npc") _build_body(profile.npc_type) _nav_usable = false _nav_try_frames = 0 @@ -62,9 +65,10 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector var collider := CollisionShape3D.new() var shape := CapsuleShape3D.new() shape.radius = 0.35 - shape.height = 1.6 + var height_scale: float = _rig.scale.y if _rig else 1.0 + shape.height = 1.6 * height_scale collider.shape = shape - collider.position = Vector3(0, 0.8, 0) + collider.position = Vector3(0, 0.8 * height_scale, 0) add_child(collider) var _visual_seed := 0 @@ -72,8 +76,11 @@ var _rig: Node3D = null var _head: Node3D = null var _bob_phase := 0.0 var _guard_ref: Node3D = null +var _ring: MeshInstance3D = null +var _name_tag: Label3D = null const SKIN_TONE := Color(0.88, 0.72, 0.60) const PANTS_COLOR := Color(0.22, 0.22, 0.26) +const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)] func _build_body(npc_type: int) -> void: _rig = Node3D.new() @@ -96,14 +103,60 @@ func _build_body(npc_type: int) -> void: # Arms _add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0) _add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0) - # Head + # Neck, head, face, hair, shoes so the body reads as a person (ADR-0010). + _add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE) _head = Node3D.new() _head.name = "Head" _head.position = Vector3(0, 1.62, 0) _add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE) + var hair_i := (_visual_seed * 7) % HAIR_COLORS.size() + _add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i]) + _add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1)) + _add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1)) _rig.add_child(_head) + _add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) + _add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) + _build_focus_ring() _add_props(npc_type) +func _build_focus_ring() -> void: + _ring = MeshInstance3D.new() + _ring.name = "FocusRing" + var torus := TorusMesh.new() + torus.inner_radius = 0.16 + torus.outer_radius = 0.20 + torus.rings = 12 + torus.ring_segments = 16 + _ring.mesh = torus + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _ring.material_override = mat + _ring.position = Vector3(0, 0.28, 0) + _ring.visible = false + _head.add_child(_ring) + _name_tag = Label3D.new() + _name_tag.name = "NameTag" + _name_tag.text = npc.profile.display_name if npc and npc.profile else "" + _name_tag.font_size = 28 + _name_tag.pixel_size = 0.004 + _name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED + _name_tag.position = Vector3(0, 0.42, 0) + _name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9) + _name_tag.visible = false + _head.add_child(_name_tag) + +func set_focus_visual(focused: bool, menu_open: bool = false) -> void: + if _ring: + _ring.visible = focused + var mat := _ring.material_override as StandardMaterial3D + if mat: + mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85) + _ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0) + if _name_tag: + _name_tag.visible = focused + func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void: var mi := MeshInstance3D.new() var mesh := CapsuleMesh.new() @@ -187,6 +240,10 @@ func _animate(delta: float) -> void: _bob_phase = 0.0 _rig.position.y = lerpf(_rig.position.y, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) + if _ring and _ring.visible: + var cam := get_viewport().get_camera_3d() if get_viewport() else null + if cam: + _ring.look_at(cam.global_position, Vector3.UP) # Attention cue: head turns toward the guard when awareness was raised. if _head != null and npc.guard_noticed: if _guard_ref == null or not is_instance_valid(_guard_ref): diff --git a/src/game/npc_focus.gd b/src/game/npc_focus.gd new file mode 100644 index 0000000..f4ac2aa --- /dev/null +++ b/src/game/npc_focus.gd @@ -0,0 +1,33 @@ +extends RefCounted +## World-space focus cone (ADR-0008). Picks the head closest to the camera +## forward vector inside range and angle. No viewport needed, so tests run headless. + +const MAX_RANGE := 10.0 +const MAX_ANGLE_RAD := 0.175 # ~10 degrees + +static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vector3, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> float: + var to := head_pos - cam_origin + var dist := to.length() + if dist < 0.05 or dist > max_range: + return -1.0 + var dir := to / dist + var dot := cam_forward.dot(dir) + if dot <= 0.0: + return -1.0 + var ang := acos(clampf(dot, -1.0, 1.0)) + if ang > max_angle: + return -1.0 + return ang + +static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int: + var best_i := -1 + var best_score := 999.0 + for i in head_positions.size(): + var pos: Vector3 = head_positions[i] + var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle) + if score < 0.0: + continue + if score < best_score: + best_score = score + best_i = i + return best_i diff --git a/src/game/ui/action_radial.gd b/src/game/ui/action_radial.gd new file mode 100644 index 0000000..d93f228 --- /dev/null +++ b/src/game/ui/action_radial.gd @@ -0,0 +1,176 @@ +extends CanvasLayer +## Doughnut action menu (ADR-0009). Open on LMB/Space, confirm click/Space, +## cancel Esc/RMB. Emergency is not a slice. + +signal confirmed(slice_id: String) +signal cancelled() + +const Rules := preload("res://src/core/rules.gd") +const Strings := preload("res://src/core/strings.gd") + +const SLICE_DEFS := [ + {"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"}, + {"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"}, + {"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"}, + {"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"}, + {"id": "inspect", "label_key": "radial.inspect", "level": -1, "action": "inspect"}, + {"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"}, + {"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"}, + {"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"}, + {"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"}, +] + +var selected := 0 +var wand_available := false +var person_name := "" +var is_open := false +var _opened_ms := 0 +var _wheel: Control = null +var _center_label: Label = null + +func _init() -> void: + name = "ActionRadial" + layer = 25 + var wheel := Control.new() + wheel.name = "Wheel" + wheel.set_anchors_preset(Control.PRESET_FULL_RECT) + wheel.mouse_filter = Control.MOUSE_FILTER_STOP + wheel.draw.connect(_on_wheel_draw) + wheel.gui_input.connect(_on_wheel_input) + add_child(wheel) + _wheel = wheel + _center_label = Label.new() + _center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _center_label.set_anchors_preset(Control.PRESET_CENTER) + _center_label.offset_left = -120 + _center_label.offset_right = 120 + _center_label.offset_top = -24 + _center_label.offset_bottom = 24 + _center_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_center_label) + hide() + +func slice_count() -> int: + return SLICE_DEFS.size() + +func slice_id(index: int) -> String: + if index < 0 or index >= SLICE_DEFS.size(): + return "" + return str(SLICE_DEFS[index]["id"]) + +func slice_def(index: int) -> Dictionary: + if index < 0 or index >= SLICE_DEFS.size(): + return {} + return SLICE_DEFS[index] + +func is_slice_enabled(index: int) -> bool: + if slice_id(index) == "wand": + return wand_available + return true + +func open_menu(p_name: String, p_wand: bool) -> void: + person_name = p_name + wand_available = p_wand + selected = 0 + is_open = true + _opened_ms = Time.get_ticks_msec() + _center_label.text = person_name + show() + _wheel.queue_redraw() + +func close_menu() -> void: + is_open = false + hide() + +func step(direction: int) -> void: + if not is_open or SLICE_DEFS.is_empty(): + return + var n := SLICE_DEFS.size() + var next := selected + for i in n: + next = (next + direction) % n + if next < 0: + next += n + if is_slice_enabled(next): + selected = next + _wheel.queue_redraw() + return + +func confirm_selected() -> String: + if not is_open: + return "" + if not is_slice_enabled(selected): + return "" + return slice_id(selected) + +func angle_to_index(angle: float) -> int: + # angle from atan2, 0 = +x, we want 0 = top (-PI/2). + var n := float(SLICE_DEFS.size()) + var shifted := angle + PI / 2.0 + while shifted < 0.0: + shifted += TAU + while shifted >= TAU: + shifted -= TAU + var idx := int(floor(shifted / TAU * n)) % SLICE_DEFS.size() + return idx + +func _on_wheel_input(event: InputEvent) -> void: + if not is_open: + return + if event is InputEventMouseMotion: + var idx := _index_at_mouse(event.position) + if idx >= 0 and is_slice_enabled(idx): + selected = idx + _wheel.queue_redraw() + elif event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_LEFT: + if Time.get_ticks_msec() - _opened_ms < 120: + return + var idx := _index_at_mouse(event.position) + if idx >= 0 and is_slice_enabled(idx): + selected = idx + confirmed.emit(slice_id(selected)) + elif event.button_index == MOUSE_BUTTON_RIGHT: + cancelled.emit() + +func _index_at_mouse(local_pos: Vector2) -> int: + var center := _wheel.size * 0.5 + var delta := local_pos - center + if delta.length() < 36.0: + return -1 + return angle_to_index(atan2(delta.y, delta.x)) + +func _on_wheel_draw() -> void: + if not is_open: + return + var center := _wheel.size * 0.5 + var n := SLICE_DEFS.size() + var outer := 150.0 + var inner := 58.0 + for i in n: + var a0 := -PI / 2.0 + TAU * float(i) / float(n) + var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n) + var col := Color(0.12, 0.14, 0.18, 0.82) + if i == selected: + col = Color(0.92, 0.78, 0.28, 0.9) + if not is_slice_enabled(i): + col = Color(0.2, 0.2, 0.22, 0.45) + _draw_slice(center, inner, outer, a0, a1, col) + var mid := (a0 + a1) * 0.5 + var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5) + var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"])) + _wheel.draw_string(ThemeDB.fallback_font, label_pos + Vector2(-28, 4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE) + +func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void: + var pts := PackedVector2Array() + var steps := 8 + for s in steps + 1: + var t := float(s) / float(steps) + var ang := lerpf(a0, a1, t) + pts.append(center + Vector2(cos(ang), sin(ang)) * outer) + for s in steps + 1: + var t := float(steps - s) / float(steps) + var ang := lerpf(a0, a1, t) + pts.append(center + Vector2(cos(ang), sin(ang)) * inner) + _wheel.draw_colored_polygon(pts, color) diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 6c05c8d..5e077c4 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -5,7 +5,6 @@ const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png") const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png") const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png") const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png") -const T_SKYLINE := preload("res://assets/textures/ai/game_ready/skyline.png") ## steps, building facade, restricted door, sidewalk, pavement, and street edge. ## No art assets required; all geometry is BoxMesh with flat materials. @@ -33,7 +32,6 @@ func build() -> void: _build_crosswalk() _build_street_furniture() _build_guard_spawn() - _build_skyline_backdrop() _build_navmesh() set_night_mode(false) @@ -68,13 +66,13 @@ func _build_building() -> void: var building := StaticBody3D.new() building.name = "Building" # Main facade (textured brick) - _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5)) + _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5)) # Entrance recess + door recess frame - _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09)) + _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09)) # Door frame and dark door - _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11)) + _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11)) # Door glass (semi-transparent) - var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.6, 0.75, 0.9, 0.55)) + var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55)) var glass_mat := StandardMaterial3D.new() glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45) glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA @@ -82,13 +80,13 @@ func _build_building() -> void: glass_mat.metallic = 0.1 glass.material_override = glass_mat # Door lock indicator (green = open, red = locked), placed beside the handle - door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3)) + door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) var col := BoxShape3D.new() col.size = Vector3(40, 18, 4) var so := building.create_shape_owner(building) building.shape_owner_add_shape(so, col) - building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2))) + building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6))) root.add_child(building) func _build_steps() -> void: @@ -102,7 +100,7 @@ func _build_steps() -> void: var height := 0.16 for i in 4: var y: float = i * height - var z: float = i * tread + var z: float = 4.0 * tread - i * tread _add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5)) # Landing at the top _add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45)) @@ -206,8 +204,10 @@ func _build_street_furniture() -> void: # Low-poly trees along the street front. var trunk_color := Color(0.35, 0.25, 0.18) var leaf_color := Color(0.16, 0.42, 0.2) + # Flank the walkway; keep the sight line to the door clear. for i in 6: - var x := -11.0 + i * 4.4 + var side := -1.0 if i % 2 == 0 else 1.0 + var x := side * (6.5 + float(int(i / 2)) * 3.0) var z := 4.0 + float((i * 7) % 5) var tree := Node3D.new() tree.name = "Tree_%d" % (i + 1) @@ -261,32 +261,10 @@ func _plain_material(color: Color) -> StandardMaterial3D: mat.albedo_color = color return mat -func _build_skyline_backdrop() -> void: - ## Distant procedural skyline on four unshaded planes around the block. - var mat := StandardMaterial3D.new() - mat.albedo_texture = T_SKYLINE - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - const DIST := 95.0 - const HEIGHT := 90.0 - for i in 4: - var plane := MeshInstance3D.new() - plane.name = "Skyline_%d" % i - var pm := PlaneMesh.new() - pm.size = Vector2(300, HEIGHT) - plane.mesh = pm - plane.material_override = mat - var angle := i * 90.0 - plane.position = Vector3(sin(deg_to_rad(angle)) * DIST, HEIGHT / 2.0, cos(deg_to_rad(angle)) * DIST) - plane.rotation_degrees = Vector3(0, -angle + 90.0, 0) - plane.cast_shadow = (0 as GeometryInstance3D.ShadowCastingSetting) - root.add_child(plane) - func _build_guard_spawn() -> void: var marker := Marker3D.new() marker.name = "GuardSpawn" - marker.position = Vector3(0, 1.6, -2.5) + marker.position = Vector3(0, 0.1, -2.0) root.add_child(marker) func _lock_indicator_material(color: Color) -> StandardMaterial3D: diff --git a/src/test/capture_screenshots.gd b/src/test/capture_screenshots.gd index 2d9148e..2b08566 100644 --- a/src/test/capture_screenshots.gd +++ b/src/test/capture_screenshots.gd @@ -31,8 +31,9 @@ func _process(_delta: float) -> bool: quit(1) return true if gm != null and not camera_staged: - gm.guard.global_position = Vector3(0, 1.6, 5.0) - gm.guard.head.rotation.x = deg_to_rad(-8.0) + gm.guard.global_position = Vector3(0, 0.1, 10.5) + gm.guard.rotation.y = 0.0 + gm.guard.head.rotation.x = deg_to_rad(2.0) camera_staged = true if frames > MAX_FRAMES: print("timeout reached") @@ -54,6 +55,7 @@ func _advance() -> void: # Wait for a nearby NPC so the interaction HUD has a target. if gm != null and gm.npcs.size() > 0: gm.call("_cycle_nearby_target") + _face_target() targeted = true state = 2 state_frames = 0 @@ -61,6 +63,7 @@ func _advance() -> void: if state_frames >= 30: _shoot("02-targeting-npc.png") if targeted and gm != null: + _face_target() gm.call("_toggle_inspection") state = 3 state_frames = 0 @@ -80,6 +83,8 @@ func _advance() -> void: # Close the radio form, then jump the shift to the LATE phase. if gm != null: gm.radio_panel.hide() + gm.guard.rotation.y = 0.0 + gm.guard.head.rotation.x = deg_to_rad(2.0) gm.coordinator.elapsed_s = 750.0 state = 5 state_frames = 0 @@ -87,6 +92,8 @@ func _advance() -> void: # Let the phase transition (and street lamps / marquee) settle, then # take the night avenue shot. if state_frames >= 90: + gm.guard.rotation.y = 0.0 + gm.guard.head.rotation.x = deg_to_rad(2.0) _shoot("05-late-night-avenue.png") if gm != null: gm.call("_toggle_flashlight") @@ -98,6 +105,17 @@ func _advance() -> void: state = 7 quit(0) +func _face_target() -> void: + var target: Node3D = gm.targeted_npc + if target == null: + return + var to: Vector3 = target.global_position - gm.guard.global_position + to.y = 0.0 + if to.length() < 0.01: + return + gm.guard.rotation.y = atan2(-to.x, -to.z) + gm.guard.head.rotation.x = deg_to_rad(-4.0) + func _shoot(file_name: String) -> void: var dir := DirAccess.open("user://") if dir == null or not dir.dir_exists("screenshots"): diff --git a/src/test/smoke_focus_radial.gd b/src/test/smoke_focus_radial.gd new file mode 100644 index 0000000..c9afd33 --- /dev/null +++ b/src/test/smoke_focus_radial.gd @@ -0,0 +1,123 @@ +extends SceneTree +## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action +## wheel). Boots the real main scene, stages one NPC in front of the guard, opens +## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or +## radial state did not come up, so it can be used as a smoke check. +## Usage: godot --path . --script res://src/test/smoke_focus_radial.gd + +const OUT_DIR := "user://screenshots" +const MAX_FRAMES := 1800 +const STAGE_DISTANCE := 2.4 + +var frames := 0 +var gm: Node = null +var state := 0 +var state_frames := 0 +var stage_npc: Node3D = null +var failures: Array[String] = [] + +func _initialize() -> void: + root.size = Vector2i(1280, 720) + var packed: PackedScene = load("res://scenes/main.tscn") + root.add_child(packed.instantiate()) + +func _process(_delta: float) -> bool: + frames += 1 + if gm == null: + gm = root.find_child("GameManager", true, false) + if gm == null: + if frames > 600: + _fail("GameManager not found") + return false + if frames > MAX_FRAMES: + _fail("timeout before the radial state") + return true + _advance() + return false + +func _advance() -> void: + state_frames += 1 + match state: + 0: + # Wait for the guard to settle on the ground, then grab one NPC. + if state_frames >= 40 and gm.npcs.size() > 0: + print("smoke: spawn guard_y=%.2f z=%.2f" % [ + (gm.guard.global_position as Vector3).y, + (gm.guard.global_position as Vector3).z, + ]) + _shoot("smoke-00-spawn.png") + stage_npc = gm.npcs[0] + _hide_other_npcs(stage_npc) + state = 1 + state_frames = 0 + 1: + # Keep the staged NPC in front of the guard so the ring is visible. + _stage_target() + if state_frames >= 20: + gm.call("_cycle_nearby_target") + if gm.targeted_npc == null: + _fail("no NPC focused") + elif gm.radial == null: + _fail("radial layer missing") + else: + print("smoke: guard_y=%.2f npc_y=%.2f" % [ + (gm.guard.global_position as Vector3).y, + (gm.targeted_npc.global_position as Vector3).y, + ]) + _shoot("smoke-01-focus-ring.png") + gm.call("_open_radial") + state = 2 + state_frames = 0 + 2: + if state_frames >= 20: + if not gm.radial.is_open: + _fail("action wheel did not open") + else: + _shoot("smoke-02-action-wheel.png") + gm.call("_cancel_radial") + state = 3 + state_frames = 0 + 3: + if state_frames >= 5: + if gm.radial.is_open: + _fail("action wheel did not close on cancel") + _finish() + +func _stage_target() -> void: + if stage_npc == null or not is_instance_valid(stage_npc): + stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null + if stage_npc == null: + return + _hide_other_npcs(stage_npc) + var guard_pos: Vector3 = gm.guard.global_position + var forward: Vector3 = -gm.guard.global_transform.basis.z + forward.y = 0.0 + forward = forward.normalized() + stage_npc.global_position = guard_pos + forward * STAGE_DISTANCE + Vector3(0, 0.4, 0) + stage_npc.rotation.y = gm.guard.rotation.y + PI + +func _hide_other_npcs(keep: Node3D) -> void: + for npc in gm.npcs: + if is_instance_valid(npc) and npc is Node3D and npc != keep: + (npc as Node3D).visible = false + +func _shoot(file_name: String) -> void: + var dir := DirAccess.open("user://") + if dir == null or not dir.dir_exists("screenshots"): + dir.make_dir_recursive("screenshots") + var img: Image = root.get_texture().get_image() + var err: int = img.save_png(OUT_DIR.path_join(file_name)) + print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()]) + +func _fail(message: String) -> void: + failures.append(message) + _finish() + +func _finish() -> void: + if failures.is_empty(): + print("SMOKE OK: focus ring + action wheel opened and cancelled") + quit(0) + else: + for message in failures: + printerr("SMOKE FAIL: " + message) + quit(1) diff --git a/src/test/tests/test_access_loadout.gd b/src/test/tests/test_access_loadout.gd new file mode 100644 index 0000000..685e93a --- /dev/null +++ b/src/test/tests/test_access_loadout.gd @@ -0,0 +1,134 @@ +extends RefCounted +## Venue-correct access artifacts (override_access, no free recognition on guests). + +const Rules := preload("res://src/core/rules.gd") +const AccessLoadout := preload("res://src/core/access_loadout.gd") +const AccessVerifier := preload("res://src/core/access_verifier.gd") +const ScenarioFactory := preload("res://src/content/scenario_factory.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") + +var verifier := AccessVerifier.new() + +func _guest() -> NpcProfile: + var p := NpcProfile.new() + p.id = "authorized_guest" + p.display_name = "Authorized Guest" + p.npc_type = Rules.NpcType.AUTHORIZED_GUEST + return p + +func _staff() -> NpcProfile: + var p := NpcProfile.new() + p.id = "authorized_staff" + p.display_name = "Staff Member" + p.npc_type = Rules.NpcType.AUTHORIZED_STAFF + return p + +func _kinds(artifacts: Array) -> Array: + var out: Array = [] + for a in artifacts: + out.append(a.kind) + return out + +func test_joke_guest_valid_needs_screen_then_grants() -> bool: + var policy = ScenarioFactory.joke_shop().access_policy + var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": true, "has_ticket": true}) + var has_rec := false + for art in arts: + if art.kind == Rules.AccessKind.RECOGNITION: + has_rec = true + var a := TestUtils.check(not has_rec, "no free recognition") + var before := verifier.verify(arts, policy, false) + var after := verifier.verify(arts, policy, true) + var b := TestUtils.check_eq(before["granted"], false, "unscreened valid guest denied") + var c := TestUtils.check_eq(after["granted"], true, "screened valid guest granted") + return a and b and c + +func test_joke_guest_no_id_denied_even_when_screened() -> bool: + var policy = ScenarioFactory.joke_shop().access_policy + var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": false, "has_ticket": true}) + var result := verifier.verify(arts, policy, true) + return TestUtils.check_eq(result["granted"], false, "no-id guest denied") + +func test_joke_guest_expired_id_denied() -> bool: + var policy = ScenarioFactory.joke_shop().access_policy + var arts: Array = AccessLoadout.build(_guest(), policy, { + "has_id": true, "has_ticket": true, "id_status": Rules.ArtifactStatus.EXPIRED, + }) + var result := verifier.verify(arts, policy, true) + return TestUtils.check_eq(result["granted"], false, "expired id denied") + +func test_joke_event_payload_matches_factory() -> bool: + var shop = ScenarioFactory.joke_shop() + var found_expired := false + var found_no_id := false + for event in shop.event_pool: + var ov: Dictionary = event.get("override_access", {}) + if event.get("id", "") == "guest_expired_id": + found_expired = int(ov.get("id_status", -1)) == Rules.ArtifactStatus.EXPIRED + if event.get("id", "") == "guest_no_id": + found_no_id = ov.get("has_id", true) == false + var a := TestUtils.check(found_expired, "guest_expired_id carries expired status") + var b := TestUtils.check(found_no_id, "guest_no_id has has_id false") + return a and b + +func test_joke_staff_recognized_without_override() -> bool: + var policy = ScenarioFactory.joke_shop().access_policy + var arts: Array = AccessLoadout.build(_staff(), policy, {}) + var has_rec := false + for a in arts: + if a.kind == Rules.AccessKind.RECOGNITION and a.status == Rules.ArtifactStatus.VALID: + has_rec = true + var screened := verifier.verify(arts, policy, true) + return TestUtils.check(has_rec, "staff are recognized") and TestUtils.check_eq(screened["granted"], true, "recognized staff enter after screen") + +func test_building_guest_uses_wristband_not_recognition() -> bool: + var policy = ScenarioFactory.hollywood_building().access_policy + var arts: Array = AccessLoadout.build(_guest(), policy, {}) + var has_rec := false + var has_band := false + for a in arts: + if a.kind == Rules.AccessKind.RECOGNITION: + has_rec = true + if a.kind == Rules.AccessKind.WRISTBAND and a.status == Rules.ArtifactStatus.VALID: + has_band = true + var result := verifier.verify(arts, policy) + var a := TestUtils.check(not has_rec, "building guest is not auto-recognized") + var b := TestUtils.check(has_band, "building guest presents a wristband") + var c := TestUtils.check_eq(result["granted"], true, "valid wristband grants") + return a and b and c + +func test_studio_ticket_grants_without_recognition() -> bool: + var policy = ScenarioFactory.tv_studio().access_policy + var arts: Array = AccessLoadout.build(_guest(), policy, {"has_ticket": true}) + var has_rec := false + for a in arts: + if a.kind == Rules.AccessKind.RECOGNITION: + has_rec = true + var result := verifier.verify(arts, policy) + return TestUtils.check(not has_rec, "audience ticket is not recognition") and TestUtils.check_eq(result["granted"], true, "ticket grants at studio") + +func test_studio_counterfeit_denied() -> bool: + var policy = ScenarioFactory.tv_studio().access_policy + var arts: Array = AccessLoadout.build(_guest(), policy, { + "wristband_status": Rules.ArtifactStatus.COUNTERFEIT, + "credential_status": Rules.ArtifactStatus.COUNTERFEIT, + }) + var result := verifier.verify(arts, policy) + return TestUtils.check_eq(result["granted"], false, "counterfeit studio artifacts deny") + +func test_carried_items_override_and_roll() -> bool: + var forced: Array = AccessLoadout.roll_carried_items(1, {"carried_items": ["weapon"]}) + var a := TestUtils.check_eq(forced, ["weapon"], "override items win") + var rolled: Array = AccessLoadout.roll_carried_items(42, {}) + var b := TestUtils.check(rolled is Array, "roll returns an array") + var again: Array = AccessLoadout.roll_carried_items(42, {}) + var c := TestUtils.check_eq(rolled, again, "same seed same pocket") + return a and b and c + +func test_joke_shop_sidewalk_exists() -> bool: + var shop = ScenarioFactory.joke_shop() + var studio = ScenarioFactory.tv_studio() + var a := TestUtils.check(shop.locations.has("sidewalk"), "joke shop sidewalk") + var b := TestUtils.check(studio.locations.has("sidewalk"), "studio sidewalk") + var c := TestUtils.check_eq(shop.waypoint_for_intent("film").z, shop.locations["sidewalk"].z, "film walks to sidewalk") + return a and b and c diff --git a/src/test/tests/test_focus_and_radial.gd b/src/test/tests/test_focus_and_radial.gd new file mode 100644 index 0000000..49bdcb0 --- /dev/null +++ b/src/test/tests/test_focus_and_radial.gd @@ -0,0 +1,100 @@ +extends RefCounted +## Focus cone math and radial menu wrap/cancel (ADR-0008, ADR-0009). + +const NpcFocus := preload("res://src/game/npc_focus.gd") +const ActionRadial := preload("res://src/game/ui/action_radial.gd") + +func test_cone_picks_centered_head() -> bool: + var origin := Vector3(0, 1.6, 0) + var forward := Vector3(0, 0, -1) + var heads: Array = [ + Vector3(3, 1.6, -4), + Vector3(0.05, 1.6, -4), + Vector3(-2, 1.6, -4), + ] + var idx := NpcFocus.pick_index(origin, forward, heads) + return TestUtils.check_eq(idx, 1, "head nearest the look axis wins") + +func test_cone_rejects_behind_and_far() -> bool: + var origin := Vector3(0, 1.6, 0) + var forward := Vector3(0, 0, -1) + var behind := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, 4)) + var far := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, -40)) + var a := TestUtils.check_eq(behind, -1.0, "behind the camera is ignored") + var b := TestUtils.check_eq(far, -1.0, "out of range is ignored") + return a and b + +func test_radial_wrap_and_skip_wand() -> bool: + var menu: Node = ActionRadial.new() + menu.open_menu("Guest", false) + var start: int = menu.selected + menu.step(-1) + var a := TestUtils.check(menu.selected != start, "step wrap moves selection") + var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice") + # Walk forward until we would hit wand; it must skip. + menu.selected = 4 + menu.step(1) + var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none") + menu.free() + return a and b and c + +func test_radial_cancel_does_not_confirm() -> bool: + var menu: Node = ActionRadial.new() + menu.open_menu("Guest", true) + var got := {"id": ""} + menu.confirmed.connect(func(id): got["id"] = id) + menu.cancelled.connect(func(): got["id"] = "cancelled") + menu.close_menu() + var a := TestUtils.check_eq(got["id"], "", "closing without confirm emits nothing") + var slice: String = menu.confirm_selected() + var b := TestUtils.check_eq(slice, "", "confirm while closed is empty") + menu.free() + return a and b + +func test_radial_confirm_returns_id() -> bool: + var menu: Node = ActionRadial.new() + menu.open_menu("Guest", true) + menu.selected = 0 + var slice: String = menu.confirm_selected() + menu.free() + return TestUtils.check_eq(slice, "raise_hand", "space-confirm uses highlighted slice") + +func test_angle_top_is_first_slice() -> bool: + var menu: Node = ActionRadial.new() + var idx: int = menu.angle_to_index(-PI / 2.0) + menu.free() + return TestUtils.check_eq(idx, 0, "top of the doughnut is slice 0") + +func test_wand_not_on_w() -> bool: + var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd") + var a := TestUtils.check(not src.contains("KEY_W:"), "W is not a game-manager action key") + var b := TestUtils.check(src.contains("is_action_pressed(\"wand\")"), "wand goes through the named action") + var c := TestUtils.check(not _action_has_key("wand", KEY_W), "wand action is not bound to W") + var d := TestUtils.check(_action_has_key("wand", KEY_C), "wand action is bound to C") + return a and b and c and d + +func test_named_input_actions_exist() -> bool: + var menu_ok := _action_has_key("action_menu", KEY_SPACE) + var confirm_ok := _action_has_key("confirm", KEY_SPACE) and _action_has_key("confirm", KEY_ENTER) + var cancel_ok := _action_has_key("cancel", KEY_ESCAPE) + var inspect_ok := _action_has_key("inspect", KEY_E) + return TestUtils.check(menu_ok, "action_menu has Space") \ + and TestUtils.check(confirm_ok, "confirm has Space + Enter") \ + and TestUtils.check(cancel_ok, "cancel has Esc") \ + and TestUtils.check(inspect_ok, "inspect has E") + +func test_cancel_has_right_mouse() -> bool: + var events: Array = InputMap.action_get_events("cancel") + var found := false + for event in events: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: + found = true + return TestUtils.check(found, "cancel action includes right mouse button") + +func _action_has_key(action: String, keycode: Key) -> bool: + if not InputMap.has_action(action): + return false + for event in InputMap.action_get_events(action): + if event is InputEventKey and event.physical_keycode == keycode: + return true + return false diff --git a/src/test/tests/test_scenarios.gd b/src/test/tests/test_scenarios.gd index 0d149ff..1dc357c 100644 --- a/src/test/tests/test_scenarios.gd +++ b/src/test/tests/test_scenarios.gd @@ -38,7 +38,7 @@ func test_tv_studio_has_all_npc_types() -> bool: var _spawn_count := 0 -func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void: +func _on_spawn(_profile: NpcProfile, _intent: String, _id: String, _event: Dictionary = {}) -> void: _spawn_count += 1 func test_late_phase_reaches_violent_event() -> bool: @@ -137,3 +137,17 @@ func test_waypoints_map_intent_to_location() -> bool: 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 + +func test_spawn_rate_is_time_based() -> bool: + # Regression: tick() runs at physics rate. Rolling the event scheduler every + # frame spawned >1000 NPCs in seconds. Ten simulated seconds at 60 Hz must + # stay in the normal single-digit-per-second range. + var scenario: VenueScenario = ScenarioFactory.hollywood_building() + var coordinator := ShiftCoordinator.new() + coordinator.setup(scenario, 60.0, 99) + _spawn_count = 0 + coordinator.npc_spawn_requested.connect(_on_spawn) + coordinator.start() + for i in 600: + coordinator.tick(1.0 / 60.0) + return TestUtils.check(_spawn_count <= 60, "spawns stay time-based at physics rate (was %d)" % _spawn_count)