Audit and fix NPC, door-staff and UI dialogue
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Skipped

- 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.
This commit is contained in:
Gig Line Dev committed 2026-11-02 14:44:01 +01:00
1 parent daf0926d91
commit 98c31fe44c
15 files changed
+232 -30

No files matched your search

+12
View File
@@ -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
+5 -4
View File
@@ -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
+8 -6
View File
@@ -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
+6 -6
View File
@@ -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",
+35
View File
@@ -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."]
}
+23
View File
@@ -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")
+35
View File
@@ -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."]
}
+2 -1
View File
@@ -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).
+19 -1
View File
@@ -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
+44
View File
@@ -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),
+4 -5
View File
@@ -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
+9 -5
View File
@@ -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
+11
View File
@@ -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()
+12
View File
@@ -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)
+7 -2
View File
@@ -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()