From 98c31fe44c9db895aca8a61cfcaea6f5021beb5f Mon Sep 17 00:00:00 2001 From: Gig Line Dev Date: Mon, 2 Nov 2026 14:44:01 +0100 Subject: [PATCH] Audit and fix NPC, door-staff and UI dialogue - Route the Reason verb through authored reason contexts and give every shared archetype an in-character answer. - Add Hollywood wristband and TV Studio ticket/credential door-staff lines. - Clarify radio cancellation, Ask terminology, start hints and campaign-reset copy. - Record the dialogue audit and length findings in docs/ui-copy.md; add regression tests. --- CHANGELOG.md | 12 +++++++ MEMORY.md | 9 +++--- TODO.md | 14 ++++---- content/strings.en.json | 12 +++---- content/venues/hollywood_building.tres | 35 ++++++++++++++++++++ content/venues/joke_shop.tres | 23 ++++++++++++++ content/venues/tv_studio.tres | 35 ++++++++++++++++++++ docs/development-status.md | 3 +- docs/ui-copy.md | 20 +++++++++++- src/content/scenario_factory.gd | 44 ++++++++++++++++++++++++++ src/core/npc_controller.gd | 9 +++--- src/game/game_manager.gd | 14 +++++--- src/test/tests/test_dialogue.gd | 11 +++++++ src/test/tests/test_plan_verbs.gd | 12 +++++++ src/test/tests/test_scenarios.gd | 9 ++++-- 15 files changed, 232 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b67a1..1240865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable user-facing changes to Gig Line. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use Semantic Versioning. +## [Unreleased] + +### Changed + +- NPC reason answers now use role-specific dialogue contexts, so staff, guests, + delivery workers, performers and other archetypes answer instead of silently + deflecting the Reason verb. +- Hollywood and the TV Studio now have door-staff lines for their wristband, + ticket, credential and guest-list checks. +- Clarified the emergency radio cancellation message, the Ask verb in Help/HUD + hints, the start-menu key hint and the campaign-reset confirmation. + ## [0.9.2] — 2026-11-02 ### Added diff --git a/MEMORY.md b/MEMORY.md index 3957f89..2812caa 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -453,7 +453,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o it. The body camera is a real flag (`GameManager.body_camera_on`, `Hud.set_body_camera`), off by default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event. Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward - the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick. + the entrance. `_request_reason()` uses the authored `reason` context (falling back to seeded `dialogue_lines`) and stores the pick for the timeline. - **NPC navigation was dead (fixed 2026-10-31):** avatars never moved in the live game, for three stacked reasons. (1) `agent.target_position` was re-assigned every physics frame, which invalidates the path so `get_next_path_position()` keeps returning the current point — only set @@ -633,7 +633,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o removed so hosts scanning both paths do not double-run. - Venue cycling: `V` switches Hollywood Blvd → The Joke Shop → TV Studio and restarts the shift. - Venue door-staff dialogue: `VenueScenario.door_staff_line(context, rng)` and GameManager logging - (id/ticket/wand-check, refusal, prohibited-item) — Joke Shop flavor. + (wristband, ID/ticket, wand-check, refusal, prohibited-item). Hollywood uses a wristband line, + the Joke Shop uses ID/ticket/wand lines, and the Studio uses ticket/credential/guest-list lines. - CI cache evaluation: Gitea Actions cache was tested for the Godot binary and trimmed 4.7.2 templates. Restores were inconsistent and saves timed out in `CreateCacheEntry` or were rejected as reserved by another job. The cache steps @@ -698,10 +699,10 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb. - 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. +- Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. - Access loadout is implemented; do not re-fix `override_access` as if it were still unread. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`. -- Headless suite is **253/253**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`. +- Headless suite is **258/258**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `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 diff --git a/TODO.md b/TODO.md index fc8cc90..9651d34 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these Baseline: **v0.9.2** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume, difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete -escalation/access/radio loop, shift review, the Blender asset library, 256 headless + 97 Python +escalation/access/radio loop, shift review, the Blender asset library, 258 headless + 97 Python tests, a clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong release-candidate foundation, not yet a 0.10.0 release candidate. @@ -45,12 +45,12 @@ release-candidate foundation, not yet a 0.10.0 release candidate. | Future 0.x | Stable 0.x feature set | Live replay, more venues, VO/music, image-to-mesh props and seasonal content | Scope creep before release | 0.11.0+ | **Current priority:** map optimization and the **pre-build NPC revisit** are -complete (both moved to `DONE.md` on 2026-11-02). The postponed 0.9.3 UI, +complete (both moved to `DONE.md` on 2026-11-02). **0.9.3 UI polish** is active; 0.9.4 NPC-detail and 0.9.5 texture patches, then 0.9.6 integration and the 0.10.0 -release candidate, are next. No release build or tag has been produced for the +release candidate, are queued. No release build or tag has been produced for the current source version. -## Postponed — Patch 0.9.3 — Dialog, button and UI polish +## Patch 0.9.3 — Dialog, button and UI polish Goal: make every player-facing screen read consistently and pass the Python screen-design judge before any model/texture changes. No gameplay rules change. @@ -69,8 +69,10 @@ screen-design judge before any model/texture changes. No gameplay rules change. - [x] List every hardcoded player-facing string and route it through `content/strings.en.json`; remaining UI literals are numeric/debug-only or dynamic data. Placeholder validation covers the new keys. -- [ ] Review NPC/door-staff/radio/tutorial/help/review/campaign dialogue for - repetition, ambiguity, tone and localization length. +- [x] Review NPC/door-staff/radio/tutorial/help/review/campaign dialogue for + repetition, ambiguity, tone and localization length. (Reason contexts, + door-staff lines, radio/tutorial/help copy and length findings recorded in + `docs/ui-copy.md`; longest dialogue line is 56 characters.) ### Button and panel behavior diff --git a/content/strings.en.json b/content/strings.en.json index f04d6e2..878d5b1 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -1,5 +1,5 @@ { - "hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab/wheel cycle · Shift slow · G light · L lock · F debug", + "hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q ask · B camera · Tab/wheel cycle · Shift slow · G light · L lock · F debug", "radial.raise_hand": "Stop", "radial.eye_contact": "Look", "radial.point": "Point", @@ -105,7 +105,7 @@ "ui.radio_send_assist": "Send request", "ui.radio_send_emergency": "Send emergency", "ui.radio_hold_hint": "Hold the button for %.1fs", - "ui.radio_cancelled": "Call cancelled — hold the button.", + "ui.radio_cancelled": "Call not sent — hold the button to confirm.", "ui.radio_holding": "Holding… %.0f%%", "ui.review_return_menu": "Return to menu", "ui.quit_game": "Quit game", @@ -121,7 +121,7 @@ "ui.settings_reset_campaign": "Reset campaign", "ui.settings_controls_help": "Controls (click a binding, then press a key or pad button)", "ui.settings_cancel": "Cancel", - "ui.settings_reset_confirm": "Clear campaign progress and start again from the Hollywood shift?", + "ui.settings_reset_confirm": "Clear campaign progress and restart from the Hollywood Boulevard shift?", "ui.settings_press": "Press…", "ui.pause_title": "Paused", "ui.pause_resume": "Resume", @@ -135,7 +135,7 @@ "help.control_move": "Movement: W/A/S/D · mouse look · Shift precision walk", "help.control_focus": "Focus: crosshair cone · Tab / mouse wheel cycles nearby people", "help.control_wheel": "Action wheel: Space or left click · A/D or arrows step · Space confirms", - "help.control_access": "Inspect access: E · Wand: C · Polite direction: Q", + "help.control_access": "Inspect access: E · Wand: C · Ask: Q", "help.control_escalate": "Warning: 4 · Blocking: 5 · Security radio: 6", "help.control_emergency": "Emergency radio: R, hold the on-screen button to confirm", "help.control_tools": "Body camera: B · Flashlight: G · Door lock: L", @@ -144,14 +144,14 @@ "ui.start_subtitle": "Pick a venue to start your shift.", "ui.start_help": "Help", "ui.start_settings": "Settings", - "ui.start_hint": "Up/Down to choose, Enter to start, Esc to quit", + "ui.start_hint": "Up/Down to choose · Enter to start · Esc to quit", "ui.campaign_complete": "Campaign complete — pick any venue for a free shift.", "ui.campaign_status": "Campaign shift %d · next: %s", "ui.continue_shift": "Continue shift — %s", "ui.campaign_complete_short": "Campaign complete", "ui.radio_location_placeholder": "Location (e.g. front steps)", "ui.radio_location_default": "front entrance", - "ui.radio_description_placeholder": "Describe the person and behavior", + "ui.radio_description_placeholder": "Describe the person and their behavior", "ui.radio_weapon": "Weapon seen", "ui.radio_danger": "Danger level: ", "ui.radio_cancel": "Cancel", diff --git a/content/venues/hollywood_building.tres b/content/venues/hollywood_building.tres index a1758fe..1a1e8d1 100644 --- a/content/venues/hollywood_building.tres +++ b/content/venues/hollywood_building.tres @@ -20,6 +20,7 @@ base_compliance = 0.9 dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."]) context_lines = { "deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."], +"reason": ["Just getting a photo of the entrance.", "I'm meeting friends here."], "recognition": ["You remember me from the tour, right?"], "refusal": ["I just want a photo, I'm not staying.", "I didn't know this entrance was restricted."] } @@ -34,6 +35,7 @@ base_compliance = 0.85 dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."]) context_lines = { "deescalation": ["Oh, I understand. I'll check with the box office."], +"reason": ["I thought this was the box office.", "I'm looking for the event entrance."], "refusal": ["I'm sure the event is here. Let me check my phone."] } @@ -43,6 +45,9 @@ id = "delivery" display_name = "Delivery Worker" npc_type = 10 dialogue_lines = Array[String](["Where should I leave this?"]) +context_lines = { +"reason": ["Drop-off for the venue. Where should I leave it?"] +} [sub_resource type="Resource" id="Resource_dsavb"] script = ExtResource("2_7qk8j") @@ -58,6 +63,9 @@ reactions_by_level = { 3: 30 } } +context_lines = { +"reason": ["Just doing my act; I'll keep the sidewalk clear."] +} [sub_resource type="Resource" id="Resource_sexiy"] script = ExtResource("2_7qk8j") @@ -88,6 +96,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."]) +context_lines = { +"reason": ["Filming a quick clip for my channel."] +} [sub_resource type="Resource" id="Resource_ghqd8"] script = ExtResource("2_7qk8j") @@ -110,6 +121,9 @@ reactions_by_level = { } } reaction_delay = 1.8 +context_lines = { +"reason": ["Looking for my friends. I think they went inside."] +} [sub_resource type="Resource" id="Resource_g8peq"] script = ExtResource("2_7qk8j") @@ -148,6 +162,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."]) +context_lines = { +"reason": ["Meeting a friend inside. I'll only be a minute.", "Just cutting through."] +} [sub_resource type="Resource" id="Resource_4xl5p"] script = ExtResource("2_7qk8j") @@ -187,6 +204,9 @@ reactions_by_level = { } } reaction_delay = 2.4 +context_lines = { +"reason": ["I just need a quiet minute.", "I'm waiting for someone."] +} [sub_resource type="Resource" id="Resource_y6282"] script = ExtResource("2_7qk8j") @@ -222,6 +242,9 @@ reactions_by_level = { 7: 30 } } +context_lines = { +"reason": ["Back off. This doesn't concern you."] +} [sub_resource type="Resource" id="Resource_75a0r"] script = ExtResource("2_7qk8j") @@ -259,6 +282,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."]) +context_lines = { +"reason": ["We're demonstrating on the public sidewalk.", "This is a peaceful protest."] +} [sub_resource type="Resource" id="Resource_ix35t"] script = ExtResource("2_7qk8j") @@ -272,6 +298,9 @@ default_reactions = { 0: 100 } } +context_lines = { +"reason": ["I'm on staff. I'm checking the entrance.", "Crew check-in; I'm working tonight."] +} [sub_resource type="Resource" id="Resource_0mcg3"] script = ExtResource("2_7qk8j") @@ -290,6 +319,9 @@ default_reactions = { 2: 12 } } +context_lines = { +"reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."] +} [resource] script = ExtResource("3_lvgx2") @@ -495,3 +527,6 @@ shift_phases = { "start_s": 700.0 } } +door_staff_lines = { +"wristband_check": ["Wristband, please.", "I need to see your event wristband."] +} diff --git a/content/venues/joke_shop.tres b/content/venues/joke_shop.tres index 80f9982..1f6548c 100644 --- a/content/venues/joke_shop.tres +++ b/content/venues/joke_shop.tres @@ -24,6 +24,7 @@ base_compliance = 0.9 dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."]) context_lines = { "deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."], +"reason": ["Just getting a photo of the entrance.", "I'm meeting friends here."], "recognition": ["You remember me from the tour, right?"], "refusal": ["I just want a photo, I'm not staying.", "I didn't know this entrance was restricted."] } @@ -38,6 +39,7 @@ base_compliance = 0.85 dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."]) context_lines = { "deescalation": ["Oh, I understand. I'll check with the box office."], +"reason": ["I thought this was the box office.", "I'm looking for the event entrance."], "refusal": ["I'm sure the event is here. Let me check my phone."] } @@ -55,6 +57,9 @@ reactions_by_level = { 3: 30 } } +context_lines = { +"reason": ["Just doing my act; I'll keep the sidewalk clear."] +} [sub_resource type="Resource" id="Resource_50i1h"] script = ExtResource("2_l60ad") @@ -85,6 +90,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."]) +context_lines = { +"reason": ["Filming a quick clip for my channel."] +} [sub_resource type="Resource" id="Resource_nsw77"] script = ExtResource("2_l60ad") @@ -107,6 +115,9 @@ reactions_by_level = { } } reaction_delay = 1.8 +context_lines = { +"reason": ["Looking for my friends. I think they went inside."] +} [sub_resource type="Resource" id="Resource_20knl"] script = ExtResource("2_l60ad") @@ -145,6 +156,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."]) +context_lines = { +"reason": ["Meeting a friend inside. I'll only be a minute.", "Just cutting through."] +} [sub_resource type="Resource" id="Resource_rivis"] script = ExtResource("2_l60ad") @@ -180,6 +194,9 @@ reactions_by_level = { 7: 30 } } +context_lines = { +"reason": ["Back off. This doesn't concern you."] +} [sub_resource type="Resource" id="Resource_1dvkp"] script = ExtResource("2_l60ad") @@ -193,6 +210,9 @@ default_reactions = { 0: 100 } } +context_lines = { +"reason": ["I'm on staff. I'm checking the entrance.", "Crew check-in; I'm working tonight."] +} [sub_resource type="Resource" id="Resource_7wsm0"] script = ExtResource("2_l60ad") @@ -211,6 +231,9 @@ default_reactions = { 2: 12 } } +context_lines = { +"reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."] +} [resource] script = ExtResource("3_voh88") diff --git a/content/venues/tv_studio.tres b/content/venues/tv_studio.tres index d8fd24f..afb7464 100644 --- a/content/venues/tv_studio.tres +++ b/content/venues/tv_studio.tres @@ -24,6 +24,7 @@ base_compliance = 0.9 dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."]) context_lines = { "deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."], +"reason": ["Just getting a photo of the entrance.", "I'm meeting friends here."], "recognition": ["You remember me from the tour, right?"], "refusal": ["I just want a photo, I'm not staying.", "I didn't know this entrance was restricted."] } @@ -38,6 +39,7 @@ base_compliance = 0.85 dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."]) context_lines = { "deescalation": ["Oh, I understand. I'll check with the box office."], +"reason": ["I thought this was the box office.", "I'm looking for the event entrance."], "refusal": ["I'm sure the event is here. Let me check my phone."] } @@ -47,6 +49,9 @@ id = "delivery" display_name = "Delivery Worker" npc_type = 10 dialogue_lines = Array[String](["Where should I leave this?"]) +context_lines = { +"reason": ["Drop-off for the venue. Where should I leave it?"] +} [sub_resource type="Resource" id="Resource_t47d3"] script = ExtResource("2_g7rkt") @@ -62,6 +67,9 @@ reactions_by_level = { 3: 30 } } +context_lines = { +"reason": ["Just doing my act; I'll keep the sidewalk clear."] +} [sub_resource type="Resource" id="Resource_j2mp5"] script = ExtResource("2_g7rkt") @@ -92,6 +100,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."]) +context_lines = { +"reason": ["Filming a quick clip for my channel."] +} [sub_resource type="Resource" id="Resource_uscqg"] script = ExtResource("2_g7rkt") @@ -114,6 +125,9 @@ reactions_by_level = { } } reaction_delay = 1.8 +context_lines = { +"reason": ["Looking for my friends. I think they went inside."] +} [sub_resource type="Resource" id="Resource_8aovb"] script = ExtResource("2_g7rkt") @@ -152,6 +166,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."]) +context_lines = { +"reason": ["Meeting a friend inside. I'll only be a minute.", "Just cutting through."] +} [sub_resource type="Resource" id="Resource_owl7s"] script = ExtResource("2_g7rkt") @@ -191,6 +208,9 @@ reactions_by_level = { } } reaction_delay = 2.4 +context_lines = { +"reason": ["I just need a quiet minute.", "I'm waiting for someone."] +} [sub_resource type="Resource" id="Resource_rnay4"] script = ExtResource("2_g7rkt") @@ -226,6 +246,9 @@ reactions_by_level = { 7: 30 } } +context_lines = { +"reason": ["Back off. This doesn't concern you."] +} [sub_resource type="Resource" id="Resource_lhp4m"] script = ExtResource("2_g7rkt") @@ -263,6 +286,9 @@ reactions_by_level = { } } dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."]) +context_lines = { +"reason": ["We're demonstrating on the public sidewalk.", "This is a peaceful protest."] +} [sub_resource type="Resource" id="Resource_s8tjd"] script = ExtResource("2_g7rkt") @@ -276,6 +302,9 @@ default_reactions = { 0: 100 } } +context_lines = { +"reason": ["I'm on staff. I'm checking the entrance.", "Crew check-in; I'm working tonight."] +} [sub_resource type="Resource" id="Resource_ii8kq"] script = ExtResource("2_g7rkt") @@ -294,6 +323,9 @@ default_reactions = { 2: 12 } } +context_lines = { +"reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."] +} [resource] script = ExtResource("3_jd45y") @@ -485,3 +517,6 @@ shift_phases = { "start_s": 700.0 } } +door_staff_lines = { +"ticket_check": ["Ticket, credential, or guest-list confirmation, please.", "I need your ticket or guest-list confirmation."] +} diff --git a/docs/development-status.md b/docs/development-status.md index 15922e2..2eca631 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -5,7 +5,7 @@ Implemented so far: - Godot 4.7.2 project scaffold, input map, procedural world builder (CI/local/release unified) - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (El Capitan Entertainment Centre / Hollywood Boulevard, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **256 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) +- Headless unit tests: **258 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) - 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 blue circle in front of the face 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`) @@ -51,6 +51,7 @@ Implemented so far: - Joke Shop / Sunset Strip map (map optimization): the second venue is now a stylized reconstruction of The Comedy Store block at 8433 Sunset Blvd, with a 27 m black hall, signature-covered sloping canopy, circular **THE JOKE SHOP** blade sign, red velvet curtain bays, a west-end entrance, cream and white low-rise neighbours, a dry hillside with stucco houses and palms behind the roofline, and a low retail/billboard frontage across Sunset. The same geometry serves day and late lighting through the phase system. Real-site references and limits are in [`joke-shop-map.md`](joke-shop-map.md); the map measures **22,680 triangles across 454 meshes** in the budget test. - Venue geometry (v0.7.0, Joke Shop frontage superseded by the map-optimization pass): the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes. - Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report. +- Dialogue pass (0.9.3 work): NPC reason answers now use the authored `reason` context, with an in-character answer for every shared archetype; Hollywood and Studio gained door-staff lines; radio cancellation, tutorial Ask terminology, help, start hints and campaign reset copy were clarified. Audit recorded in `docs/ui-copy.md`. - Pre-build NPC review (2026-11-02): all eight library characters were staged at portrait, conversation and crowd distance on Hollywood and the Joke Shop, in day and night lighting, with the normal crowd hidden (`src/test/capture_npc_review.gd`). No P0 issues were found. Two P1 gear-fit issues were fixed with runtime attach offsets: the authorized-guest lanyard badge now clears the jacket and the firefighter helmet sits above the eyes. Backpack straps, caps, hands and the intentionally low-poly faces were accepted for the stylized target; close-range flashlight wash remains an optional-lighting follow-up. The 24-NPC budget stayed at 6.93 ms, the gallery passed 13 good / 0 bad and the screen-design judge stayed 13/13, and a clean portrait scored a 0.732 vision gap against the photoreal concept. Current gaps for the 0.x release line: deeper authored content/dialogue, animation and audio production, localization/accessibility expansion, 24-NPC/platform profiling, external playtesting, and signed/notarized release packaging. The full gap analysis and milestone plan live in [`TODO.md`](../TODO.md); the current architecture and documentation ownership map are in [`docs/architecture.md`](architecture.md). diff --git a/docs/ui-copy.md b/docs/ui-copy.md index a203aea..bbcc4d9 100644 --- a/docs/ui-copy.md +++ b/docs/ui-copy.md @@ -1,6 +1,6 @@ # UI copy and button inventory -Status: audit in progress for the 0.9.3 UI polish patch. +Status: dialogue pass complete; button/focus/font-scale audit in progress for 0.9.3. ## Screen inventory @@ -72,6 +72,24 @@ Status: audit in progress for the 0.9.3 UI polish patch. - “Quit” was ambiguous between menu and application exit; both application-exit buttons now read **Quit game**. - Settings “Apply” was made verb+object as **Apply settings**. +## Dialogue audit + +The NPC reason verb now uses the authored `reason` context instead of always +falling back to the general dialogue lines. Every shared archetype has at least +one role-specific reason answer, so asking a staff member, guest, delivery +worker or performer no longer produces an unexplained silence. Deliberately +silent profiles can still return an empty context line. + +| Surface | Finding | Result | +| --- | --- | --- | +| Shared NPC reasons | `request_reason()` ignored `context_lines["reason"]`; staff, guests, delivery, performers, intoxicated people, crisis and threat profiles had no answer. | Fixed; all 12 shared archetypes now answer in character. | +| Door staff | Only The Joke Shop authored door lines. | Hollywood has wristband lines; the Studio has ticket/credential/guest-list lines; the inspection log selects the venue context. | +| Radio | “Call cancelled” could read as if a call had been sent; the description placeholder was ungrammatical. | “Call not sent — hold the button to confirm.” and “Describe the person and their behavior.” | +| Tutorial / Help | The wheel verb is **Ask**, but help and the HUD key strip called it “polite direction”. | Both now say **Ask**; the tutorial tells the player to choose Ask after closing the panel. | +| Review / campaign | Continue/return/quit labels and campaign-next copy checked. | No ambiguity found; no change. | +| Repetition | Shared tourist/confused/delivery lines recur across venues by design; role-specific reason lines prevent every archetype sounding alike. | Accepted for the shared-archetype model. | +| Localization length | Longest authored NPC/door line is 56 characters; longest UI string is `hud.controls` at 172 characters. | Dialogue fits the HUD log; the 1.5–2× string/font-scale layout test remains a separate 0.9.3 task. | + ## Remaining audit work - Player-facing labels are now routed through `content/strings.en.json`; numeric diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index 5fa1bae..6a39fb8 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -22,6 +22,7 @@ static func shared_profiles() -> Array[NpcProfile]: tourist.temperament = 0.1 tourist.base_compliance = 0.9 tourist.context_lines = { + "reason": ["Just getting a photo of the entrance.", "I'm meeting friends here."], "refusal": ["I just want a photo, I'm not staying.", "I didn't know this entrance was restricted."], "deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."], "recognition": ["You remember me from the tour, right?"], @@ -40,6 +41,7 @@ static func shared_profiles() -> Array[NpcProfile]: confused.temperament = 0.2 confused.base_compliance = 0.85 confused.context_lines = { + "reason": ["I thought this was the box office.", "I'm looking for the event entrance."], "refusal": ["I'm sure the event is here. Let me check my phone."], "deescalation": ["Oh, I understand. I'll check with the box office."], } @@ -55,6 +57,9 @@ static func shared_profiles() -> Array[NpcProfile]: delivery.npc_type = Rules.NpcType.DELIVERY delivery.temperament = 0.25 delivery.base_compliance = 0.8 + delivery.context_lines = { + "reason": ["Drop-off for the venue. Where should I leave it?"], + } delivery.dialogue_lines = ["Where should I leave this?"] profiles.append(delivery) @@ -64,6 +69,9 @@ static func shared_profiles() -> Array[NpcProfile]: performer.npc_type = Rules.NpcType.PERFORMER performer.temperament = 0.45 performer.base_compliance = 0.55 + performer.context_lines = { + "reason": ["Just doing my act; I'll keep the sidewalk clear."], + } performer.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 55, @@ -100,6 +108,9 @@ static func shared_profiles() -> Array[NpcProfile]: Rules.Reaction.COMPLY: 10, }, } + influencer.context_lines = { + "reason": ["Filming a quick clip for my channel."], + } influencer.dialogue_lines = [ "This is public sidewalk, you can't stop me from filming.", ] @@ -113,6 +124,9 @@ static func shared_profiles() -> Array[NpcProfile]: intoxicated.temperament = 0.6 intoxicated.base_compliance = 0.4 intoxicated.reaction_delay = 1.8 + intoxicated.context_lines = { + "reason": ["Looking for my friends. I think they went inside."], + } intoxicated.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 30, @@ -135,6 +149,9 @@ static func shared_profiles() -> Array[NpcProfile]: trespasser.temperament = 0.55 trespasser.base_compliance = 0.35 trespasser.return_probability = 0.6 + trespasser.context_lines = { + "reason": ["Meeting a friend inside. I'll only be a minute.", "Just cutting through."], + } trespasser.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.IGNORE: 45, @@ -177,6 +194,9 @@ static func shared_profiles() -> Array[NpcProfile]: mental_crisis.base_compliance = 0.3 mental_crisis.return_probability = 0.4 mental_crisis.reaction_delay = 2.4 + mental_crisis.context_lines = { + "reason": ["I just need a quiet minute.", "I'm waiting for someone."], + } mental_crisis.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.IGNORE: 55, @@ -214,6 +234,9 @@ static func shared_profiles() -> Array[NpcProfile]: violent_threat.npc_type = Rules.NpcType.VIOLENT_THREAT violent_threat.temperament = 0.95 violent_threat.base_compliance = 0.05 + violent_threat.context_lines = { + "reason": ["Back off. This doesn't concern you."], + } violent_threat.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.THREATEN: 30, @@ -250,6 +273,9 @@ static func shared_profiles() -> Array[NpcProfile]: protester.temperament = 0.5 protester.base_compliance = 0.4 protester.return_probability = 0.25 + protester.context_lines = { + "reason": ["We're demonstrating on the public sidewalk.", "This is a peaceful protest."], + } protester.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.ARGUE: 35, @@ -290,6 +316,9 @@ static func shared_profiles() -> Array[NpcProfile]: staff.npc_type = Rules.NpcType.AUTHORIZED_STAFF staff.temperament = 0.15 staff.base_compliance = 1.0 + staff.context_lines = { + "reason": ["I'm on staff. I'm checking the entrance.", "Crew check-in; I'm working tonight."], + } staff.default_reactions = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 100 } } profiles.append(staff) @@ -299,6 +328,9 @@ static func shared_profiles() -> Array[NpcProfile]: guest.npc_type = Rules.NpcType.AUTHORIZED_GUEST guest.temperament = 0.2 guest.base_compliance = 0.95 + guest.context_lines = { + "reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."], + } guest.default_reactions = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 92, Rules.Reaction.ARGUE: 8 }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.COMPLY: 88, Rules.Reaction.ARGUE: 12 }, @@ -322,6 +354,12 @@ static func hollywood_building() -> VenueScenario: scenario.access_policy = policy scenario.equipment = ["wristband_policy", "flashlight", "radio"] + scenario.door_staff_lines = { + "wristband_check": [ + "Wristband, please.", + "I need to see your event wristband.", + ], + } scenario.locations = { "guard_post": Vector3(0, 0, -3), "entrance": Vector3(0, 0, 1.0), @@ -589,6 +627,12 @@ static func tv_studio() -> VenueScenario: scenario.access_policy = policy scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"] + scenario.door_staff_lines = { + "ticket_check": [ + "Ticket, credential, or guest-list confirmation, please.", + "I need your ticket or guest-list confirmation.", + ], + } scenario.locations = { "guard_post": Vector3(0, 0, -3), "entrance": Vector3(0, 0, 1.0), diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd index 93ea10b..99878ee 100644 --- a/src/core/npc_controller.gd +++ b/src/core/npc_controller.gd @@ -166,11 +166,10 @@ func decide_leave() -> bool: ## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and ## headless-testable; returns the line this NPC answers with ("" if silent). func request_reason() -> String: - var lines: Array[String] = profile.dialogue_lines if profile else [] - if lines.is_empty(): - last_reason_reply = "" - return "" - last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)] + # Prefer the authored "reason" context; profiles without one fall back to + # their general dialogue lines. Silence remains valid for a deliberately + # silent character. + last_reason_reply = profile.line_for("reason", rng) if profile else "" return last_reason_reply ## Perception pass: given the guard's distance (meters), return a perception diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index fdf7228..5c5432a 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -1940,11 +1940,15 @@ func decide_access(allowed: bool) -> void: func _door_context_line() -> String: if selected_scenario == null: return "" - if selected_scenario.access_policy.require_id: - return selected_scenario.door_staff_line("id_check", _door_rng) - if selected_scenario.access_policy.requires(Rules.AccessKind.TICKET): - return selected_scenario.door_staff_line("ticket_check", _door_rng) - return "" + match str(selected_scenario.id): + "joke_shop": + if selected_scenario.access_policy.require_id: + return selected_scenario.door_staff_line("id_check", _door_rng) + return selected_scenario.door_staff_line("ticket_check", _door_rng) + "tv_studio": + return selected_scenario.door_staff_line("ticket_check", _door_rng) + _: + return selected_scenario.door_staff_line("wristband_check", _door_rng) func _artifacts_for(controller: Object) -> Array: var profile: NpcProfile = controller.profile as NpcProfile diff --git a/src/test/tests/test_dialogue.gd b/src/test/tests/test_dialogue.gd index c9b249f..1e24cf4 100644 --- a/src/test/tests/test_dialogue.gd +++ b/src/test/tests/test_dialogue.gd @@ -18,6 +18,17 @@ func test_context_lines_are_deterministic_and_fallback() -> bool: "unknown context with no fallback is empty") return d and e and f +func test_every_shared_profile_answers_reason() -> bool: + var rng := RandomNumberGenerator.new() + rng.seed = 11 + var problems: Array[String] = [] + for profile in ScenarioFactory.shared_profiles(): + var reply := profile.line_for("reason", rng) + if reply.strip_edges() == "": + problems.append(str(profile.id)) + return TestUtils.check(problems.is_empty(), + "every shared profile has an in-character reason answer (%s)" % str(problems)) + func test_request_reason_uses_reason_context() -> bool: var profile := ScenarioFactory.hollywood_building().profile_for(0) var controller := NpcController.new() diff --git a/src/test/tests/test_plan_verbs.gd b/src/test/tests/test_plan_verbs.gd index 555c8ee..629f711 100644 --- a/src/test/tests/test_plan_verbs.gd +++ b/src/test/tests/test_plan_verbs.gd @@ -58,6 +58,18 @@ func test_request_reason_uses_dialogue_lines() -> bool: npc = null return a and b and c +func test_request_reason_prefers_the_reason_context() -> bool: + var p := _profile("Visitor", ["I have a ticket for tonight."]) + p.context_lines = {"reason": ["I'm here for the show."]} + var npc := NPCController.new() + npc.setup("npc_reason", p, "enter", 4242) + var a := TestUtils.check_eq(npc.request_reason(), "I'm here for the show.", + "reason context overrides the generic dialogue lines") + var b := TestUtils.check_eq(npc.last_reason_reply, "I'm here for the show.", + "the selected reason is stored for the timeline") + npc = null + return a and b + func test_request_reason_silent_without_lines() -> bool: var npc := NPCController.new() npc.setup("npc_2", _profile("Silent"), "enter", 7) diff --git a/src/test/tests/test_scenarios.gd b/src/test/tests/test_scenarios.gd index 1dc357c..ba0273c 100644 --- a/src/test/tests/test_scenarios.gd +++ b/src/test/tests/test_scenarios.gd @@ -75,7 +75,12 @@ func test_door_staff_lines_available() -> bool: var a := TestUtils.check(ok, "all joke-shop contexts have a door-staff line") var b := TestUtils.check_eq(scenario.door_staff_line("unknown_context", rng), "", "unknown context returns empty") var b_country := ScenarioFactory.hollywood_building() - var c := TestUtils.check_eq(b_country.door_staff_line("refusal", rng), "", "venue without lines returns empty") + var c := TestUtils.check_eq(b_country.door_staff_line("refusal", rng), "", "venue without that context returns empty") + var e := TestUtils.check(b_country.door_staff_line("wristband_check", rng) != "", + "Hollywood has a wristband door line") + var studio := ScenarioFactory.tv_studio() + var f := TestUtils.check(studio.door_staff_line("ticket_check", rng) != "", + "Studio has a ticket/credential door line") # Same seed picks the same line. var r0 := RandomNumberGenerator.new() r0.seed = 11 @@ -85,7 +90,7 @@ func test_door_staff_lines_available() -> bool: scenario.door_staff_line("wand_check", r0), scenario.door_staff_line("wand_check", r1), "seeded door-staff pick is deterministic") - return a and b and c and d + return a and b and c and d and e and f func test_protester_archetype_present() -> bool: var profiles := ScenarioFactory.shared_profiles()