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.
This commit is contained in:
1 parent
daf0926d91
commit
98c31fe44c
15 files changed
+232
-30
No files matched your search
@@ -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
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
||||||
Semantic Versioning.
|
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
|
## [0.9.2] — 2026-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
- **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
|
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
|
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.
|
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 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
|
- 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
|
- 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
|
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
|
`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.
|
- 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).
|
- 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).
|
- 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.
|
- 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()`.
|
- `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).
|
- 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
|
- 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
|
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
|
||||||
|
|||||||
@@ -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,
|
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
|
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
|
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.
|
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+ |
|
| 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
|
**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
|
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.
|
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
|
Goal: make every player-facing screen read consistently and pass the Python
|
||||||
screen-design judge before any model/texture changes. No gameplay rules change.
|
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
|
- [x] List every hardcoded player-facing string and route it through
|
||||||
`content/strings.en.json`; remaining UI literals are numeric/debug-only or
|
`content/strings.en.json`; remaining UI literals are numeric/debug-only or
|
||||||
dynamic data. Placeholder validation covers the new keys.
|
dynamic data. Placeholder validation covers the new keys.
|
||||||
- [ ] Review NPC/door-staff/radio/tutorial/help/review/campaign dialogue for
|
- [x] Review NPC/door-staff/radio/tutorial/help/review/campaign dialogue for
|
||||||
repetition, ambiguity, tone and localization length.
|
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
|
### Button and panel behavior
|
||||||
|
|
||||||
|
|||||||
@@ -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.raise_hand": "Stop",
|
||||||
"radial.eye_contact": "Look",
|
"radial.eye_contact": "Look",
|
||||||
"radial.point": "Point",
|
"radial.point": "Point",
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
"ui.radio_send_assist": "Send request",
|
"ui.radio_send_assist": "Send request",
|
||||||
"ui.radio_send_emergency": "Send emergency",
|
"ui.radio_send_emergency": "Send emergency",
|
||||||
"ui.radio_hold_hint": "Hold the button for %.1fs",
|
"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.radio_holding": "Holding… %.0f%%",
|
||||||
"ui.review_return_menu": "Return to menu",
|
"ui.review_return_menu": "Return to menu",
|
||||||
"ui.quit_game": "Quit game",
|
"ui.quit_game": "Quit game",
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"ui.settings_reset_campaign": "Reset campaign",
|
"ui.settings_reset_campaign": "Reset campaign",
|
||||||
"ui.settings_controls_help": "Controls (click a binding, then press a key or pad button)",
|
"ui.settings_controls_help": "Controls (click a binding, then press a key or pad button)",
|
||||||
"ui.settings_cancel": "Cancel",
|
"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.settings_press": "Press…",
|
||||||
"ui.pause_title": "Paused",
|
"ui.pause_title": "Paused",
|
||||||
"ui.pause_resume": "Resume",
|
"ui.pause_resume": "Resume",
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
"help.control_move": "Movement: W/A/S/D · mouse look · Shift precision walk",
|
"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_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_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_escalate": "Warning: 4 · Blocking: 5 · Security radio: 6",
|
||||||
"help.control_emergency": "Emergency radio: R, hold the on-screen button to confirm",
|
"help.control_emergency": "Emergency radio: R, hold the on-screen button to confirm",
|
||||||
"help.control_tools": "Body camera: B · Flashlight: G · Door lock: L",
|
"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_subtitle": "Pick a venue to start your shift.",
|
||||||
"ui.start_help": "Help",
|
"ui.start_help": "Help",
|
||||||
"ui.start_settings": "Settings",
|
"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_complete": "Campaign complete — pick any venue for a free shift.",
|
||||||
"ui.campaign_status": "Campaign shift %d · next: %s",
|
"ui.campaign_status": "Campaign shift %d · next: %s",
|
||||||
"ui.continue_shift": "Continue shift — %s",
|
"ui.continue_shift": "Continue shift — %s",
|
||||||
"ui.campaign_complete_short": "Campaign complete",
|
"ui.campaign_complete_short": "Campaign complete",
|
||||||
"ui.radio_location_placeholder": "Location (e.g. front steps)",
|
"ui.radio_location_placeholder": "Location (e.g. front steps)",
|
||||||
"ui.radio_location_default": "front entrance",
|
"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_weapon": "Weapon seen",
|
||||||
"ui.radio_danger": "Danger level: ",
|
"ui.radio_danger": "Danger level: ",
|
||||||
"ui.radio_cancel": "Cancel",
|
"ui.radio_cancel": "Cancel",
|
||||||
|
|||||||
@@ -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."])
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
context_lines = {
|
context_lines = {
|
||||||
"deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."],
|
"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?"],
|
"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."]
|
"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."])
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
context_lines = {
|
context_lines = {
|
||||||
"deescalation": ["Oh, I understand. I'll check with the box office."],
|
"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."]
|
"refusal": ["I'm sure the event is here. Let me check my phone."]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,9 @@ id = "delivery"
|
|||||||
display_name = "Delivery Worker"
|
display_name = "Delivery Worker"
|
||||||
npc_type = 10
|
npc_type = 10
|
||||||
dialogue_lines = Array[String](["Where should I leave this?"])
|
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"]
|
[sub_resource type="Resource" id="Resource_dsavb"]
|
||||||
script = ExtResource("2_7qk8j")
|
script = ExtResource("2_7qk8j")
|
||||||
@@ -58,6 +63,9 @@ reactions_by_level = {
|
|||||||
3: 30
|
3: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Just doing my act; I'll keep the sidewalk clear."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_sexiy"]
|
[sub_resource type="Resource" id="Resource_sexiy"]
|
||||||
script = ExtResource("2_7qk8j")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_ghqd8"]
|
||||||
script = ExtResource("2_7qk8j")
|
script = ExtResource("2_7qk8j")
|
||||||
@@ -110,6 +121,9 @@ reactions_by_level = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 1.8
|
reaction_delay = 1.8
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Looking for my friends. I think they went inside."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_g8peq"]
|
[sub_resource type="Resource" id="Resource_g8peq"]
|
||||||
script = ExtResource("2_7qk8j")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_4xl5p"]
|
||||||
script = ExtResource("2_7qk8j")
|
script = ExtResource("2_7qk8j")
|
||||||
@@ -187,6 +204,9 @@ reactions_by_level = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 2.4
|
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"]
|
[sub_resource type="Resource" id="Resource_y6282"]
|
||||||
script = ExtResource("2_7qk8j")
|
script = ExtResource("2_7qk8j")
|
||||||
@@ -222,6 +242,9 @@ reactions_by_level = {
|
|||||||
7: 30
|
7: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Back off. This doesn't concern you."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_75a0r"]
|
[sub_resource type="Resource" id="Resource_75a0r"]
|
||||||
script = ExtResource("2_7qk8j")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_ix35t"]
|
||||||
script = ExtResource("2_7qk8j")
|
script = ExtResource("2_7qk8j")
|
||||||
@@ -272,6 +298,9 @@ default_reactions = {
|
|||||||
0: 100
|
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"]
|
[sub_resource type="Resource" id="Resource_0mcg3"]
|
||||||
script = ExtResource("2_7qk8j")
|
script = ExtResource("2_7qk8j")
|
||||||
@@ -290,6 +319,9 @@ default_reactions = {
|
|||||||
2: 12
|
2: 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."]
|
||||||
|
}
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("3_lvgx2")
|
script = ExtResource("3_lvgx2")
|
||||||
@@ -495,3 +527,6 @@ shift_phases = {
|
|||||||
"start_s": 700.0
|
"start_s": 700.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
door_staff_lines = {
|
||||||
|
"wristband_check": ["Wristband, please.", "I need to see your event wristband."]
|
||||||
|
}
|
||||||
@@ -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."])
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
context_lines = {
|
context_lines = {
|
||||||
"deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."],
|
"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?"],
|
"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."]
|
"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."])
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
context_lines = {
|
context_lines = {
|
||||||
"deescalation": ["Oh, I understand. I'll check with the box office."],
|
"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."]
|
"refusal": ["I'm sure the event is here. Let me check my phone."]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,9 @@ reactions_by_level = {
|
|||||||
3: 30
|
3: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Just doing my act; I'll keep the sidewalk clear."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_50i1h"]
|
[sub_resource type="Resource" id="Resource_50i1h"]
|
||||||
script = ExtResource("2_l60ad")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_nsw77"]
|
||||||
script = ExtResource("2_l60ad")
|
script = ExtResource("2_l60ad")
|
||||||
@@ -107,6 +115,9 @@ reactions_by_level = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 1.8
|
reaction_delay = 1.8
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Looking for my friends. I think they went inside."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_20knl"]
|
[sub_resource type="Resource" id="Resource_20knl"]
|
||||||
script = ExtResource("2_l60ad")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_rivis"]
|
||||||
script = ExtResource("2_l60ad")
|
script = ExtResource("2_l60ad")
|
||||||
@@ -180,6 +194,9 @@ reactions_by_level = {
|
|||||||
7: 30
|
7: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Back off. This doesn't concern you."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_1dvkp"]
|
[sub_resource type="Resource" id="Resource_1dvkp"]
|
||||||
script = ExtResource("2_l60ad")
|
script = ExtResource("2_l60ad")
|
||||||
@@ -193,6 +210,9 @@ default_reactions = {
|
|||||||
0: 100
|
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"]
|
[sub_resource type="Resource" id="Resource_7wsm0"]
|
||||||
script = ExtResource("2_l60ad")
|
script = ExtResource("2_l60ad")
|
||||||
@@ -211,6 +231,9 @@ default_reactions = {
|
|||||||
2: 12
|
2: 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."]
|
||||||
|
}
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("3_voh88")
|
script = ExtResource("3_voh88")
|
||||||
|
|||||||
@@ -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."])
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
context_lines = {
|
context_lines = {
|
||||||
"deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."],
|
"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?"],
|
"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."]
|
"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."])
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
context_lines = {
|
context_lines = {
|
||||||
"deescalation": ["Oh, I understand. I'll check with the box office."],
|
"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."]
|
"refusal": ["I'm sure the event is here. Let me check my phone."]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ id = "delivery"
|
|||||||
display_name = "Delivery Worker"
|
display_name = "Delivery Worker"
|
||||||
npc_type = 10
|
npc_type = 10
|
||||||
dialogue_lines = Array[String](["Where should I leave this?"])
|
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"]
|
[sub_resource type="Resource" id="Resource_t47d3"]
|
||||||
script = ExtResource("2_g7rkt")
|
script = ExtResource("2_g7rkt")
|
||||||
@@ -62,6 +67,9 @@ reactions_by_level = {
|
|||||||
3: 30
|
3: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Just doing my act; I'll keep the sidewalk clear."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_j2mp5"]
|
[sub_resource type="Resource" id="Resource_j2mp5"]
|
||||||
script = ExtResource("2_g7rkt")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_uscqg"]
|
||||||
script = ExtResource("2_g7rkt")
|
script = ExtResource("2_g7rkt")
|
||||||
@@ -114,6 +125,9 @@ reactions_by_level = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 1.8
|
reaction_delay = 1.8
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Looking for my friends. I think they went inside."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_8aovb"]
|
[sub_resource type="Resource" id="Resource_8aovb"]
|
||||||
script = ExtResource("2_g7rkt")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_owl7s"]
|
||||||
script = ExtResource("2_g7rkt")
|
script = ExtResource("2_g7rkt")
|
||||||
@@ -191,6 +208,9 @@ reactions_by_level = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 2.4
|
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"]
|
[sub_resource type="Resource" id="Resource_rnay4"]
|
||||||
script = ExtResource("2_g7rkt")
|
script = ExtResource("2_g7rkt")
|
||||||
@@ -226,6 +246,9 @@ reactions_by_level = {
|
|||||||
7: 30
|
7: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["Back off. This doesn't concern you."]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_lhp4m"]
|
[sub_resource type="Resource" id="Resource_lhp4m"]
|
||||||
script = ExtResource("2_g7rkt")
|
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."])
|
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"]
|
[sub_resource type="Resource" id="Resource_s8tjd"]
|
||||||
script = ExtResource("2_g7rkt")
|
script = ExtResource("2_g7rkt")
|
||||||
@@ -276,6 +302,9 @@ default_reactions = {
|
|||||||
0: 100
|
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"]
|
[sub_resource type="Resource" id="Resource_ii8kq"]
|
||||||
script = ExtResource("2_g7rkt")
|
script = ExtResource("2_g7rkt")
|
||||||
@@ -294,6 +323,9 @@ default_reactions = {
|
|||||||
2: 12
|
2: 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context_lines = {
|
||||||
|
"reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."]
|
||||||
|
}
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("3_jd45y")
|
script = ExtResource("3_jd45y")
|
||||||
@@ -485,3 +517,6 @@ shift_phases = {
|
|||||||
"start_s": 700.0
|
"start_s": 700.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
door_staff_lines = {
|
||||||
|
"ticket_check": ["Ticket, credential, or guest-list confirmation, please.", "I need your ticket or guest-list confirmation."]
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ Implemented so far:
|
|||||||
- Godot 4.7.2 project scaffold, input map, procedural world builder (CI/local/release unified)
|
- 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`
|
- 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
|
- 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
|
- 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
|
- 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`)
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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).
|
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
@@ -1,6 +1,6 @@
|
|||||||
# UI copy and button inventory
|
# 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
|
## 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**.
|
- “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**.
|
- 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
|
## Remaining audit work
|
||||||
|
|
||||||
- Player-facing labels are now routed through `content/strings.en.json`; numeric
|
- Player-facing labels are now routed through `content/strings.en.json`; numeric
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
tourist.temperament = 0.1
|
tourist.temperament = 0.1
|
||||||
tourist.base_compliance = 0.9
|
tourist.base_compliance = 0.9
|
||||||
tourist.context_lines = {
|
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."],
|
"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."],
|
"deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."],
|
||||||
"recognition": ["You remember me from the tour, right?"],
|
"recognition": ["You remember me from the tour, right?"],
|
||||||
@@ -40,6 +41,7 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
confused.temperament = 0.2
|
confused.temperament = 0.2
|
||||||
confused.base_compliance = 0.85
|
confused.base_compliance = 0.85
|
||||||
confused.context_lines = {
|
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."],
|
"refusal": ["I'm sure the event is here. Let me check my phone."],
|
||||||
"deescalation": ["Oh, I understand. I'll check with the box office."],
|
"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.npc_type = Rules.NpcType.DELIVERY
|
||||||
delivery.temperament = 0.25
|
delivery.temperament = 0.25
|
||||||
delivery.base_compliance = 0.8
|
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?"]
|
delivery.dialogue_lines = ["Where should I leave this?"]
|
||||||
profiles.append(delivery)
|
profiles.append(delivery)
|
||||||
|
|
||||||
@@ -64,6 +69,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
performer.npc_type = Rules.NpcType.PERFORMER
|
performer.npc_type = Rules.NpcType.PERFORMER
|
||||||
performer.temperament = 0.45
|
performer.temperament = 0.45
|
||||||
performer.base_compliance = 0.55
|
performer.base_compliance = 0.55
|
||||||
|
performer.context_lines = {
|
||||||
|
"reason": ["Just doing my act; I'll keep the sidewalk clear."],
|
||||||
|
}
|
||||||
performer.reactions_by_level = {
|
performer.reactions_by_level = {
|
||||||
Rules.Level.SIMPLE_DETERRENCE: {
|
Rules.Level.SIMPLE_DETERRENCE: {
|
||||||
Rules.Reaction.COMPLY: 55,
|
Rules.Reaction.COMPLY: 55,
|
||||||
@@ -100,6 +108,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
Rules.Reaction.COMPLY: 10,
|
Rules.Reaction.COMPLY: 10,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
influencer.context_lines = {
|
||||||
|
"reason": ["Filming a quick clip for my channel."],
|
||||||
|
}
|
||||||
influencer.dialogue_lines = [
|
influencer.dialogue_lines = [
|
||||||
"This is public sidewalk, you can't stop me from filming.",
|
"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.temperament = 0.6
|
||||||
intoxicated.base_compliance = 0.4
|
intoxicated.base_compliance = 0.4
|
||||||
intoxicated.reaction_delay = 1.8
|
intoxicated.reaction_delay = 1.8
|
||||||
|
intoxicated.context_lines = {
|
||||||
|
"reason": ["Looking for my friends. I think they went inside."],
|
||||||
|
}
|
||||||
intoxicated.reactions_by_level = {
|
intoxicated.reactions_by_level = {
|
||||||
Rules.Level.SIMPLE_DETERRENCE: {
|
Rules.Level.SIMPLE_DETERRENCE: {
|
||||||
Rules.Reaction.COMPLY: 30,
|
Rules.Reaction.COMPLY: 30,
|
||||||
@@ -135,6 +149,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
trespasser.temperament = 0.55
|
trespasser.temperament = 0.55
|
||||||
trespasser.base_compliance = 0.35
|
trespasser.base_compliance = 0.35
|
||||||
trespasser.return_probability = 0.6
|
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 = {
|
trespasser.reactions_by_level = {
|
||||||
Rules.Level.SIMPLE_DETERRENCE: {
|
Rules.Level.SIMPLE_DETERRENCE: {
|
||||||
Rules.Reaction.IGNORE: 45,
|
Rules.Reaction.IGNORE: 45,
|
||||||
@@ -177,6 +194,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
mental_crisis.base_compliance = 0.3
|
mental_crisis.base_compliance = 0.3
|
||||||
mental_crisis.return_probability = 0.4
|
mental_crisis.return_probability = 0.4
|
||||||
mental_crisis.reaction_delay = 2.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 = {
|
mental_crisis.reactions_by_level = {
|
||||||
Rules.Level.SIMPLE_DETERRENCE: {
|
Rules.Level.SIMPLE_DETERRENCE: {
|
||||||
Rules.Reaction.IGNORE: 55,
|
Rules.Reaction.IGNORE: 55,
|
||||||
@@ -214,6 +234,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
violent_threat.npc_type = Rules.NpcType.VIOLENT_THREAT
|
violent_threat.npc_type = Rules.NpcType.VIOLENT_THREAT
|
||||||
violent_threat.temperament = 0.95
|
violent_threat.temperament = 0.95
|
||||||
violent_threat.base_compliance = 0.05
|
violent_threat.base_compliance = 0.05
|
||||||
|
violent_threat.context_lines = {
|
||||||
|
"reason": ["Back off. This doesn't concern you."],
|
||||||
|
}
|
||||||
violent_threat.reactions_by_level = {
|
violent_threat.reactions_by_level = {
|
||||||
Rules.Level.SIMPLE_DETERRENCE: {
|
Rules.Level.SIMPLE_DETERRENCE: {
|
||||||
Rules.Reaction.THREATEN: 30,
|
Rules.Reaction.THREATEN: 30,
|
||||||
@@ -250,6 +273,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
protester.temperament = 0.5
|
protester.temperament = 0.5
|
||||||
protester.base_compliance = 0.4
|
protester.base_compliance = 0.4
|
||||||
protester.return_probability = 0.25
|
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 = {
|
protester.reactions_by_level = {
|
||||||
Rules.Level.SIMPLE_DETERRENCE: {
|
Rules.Level.SIMPLE_DETERRENCE: {
|
||||||
Rules.Reaction.ARGUE: 35,
|
Rules.Reaction.ARGUE: 35,
|
||||||
@@ -290,6 +316,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
staff.npc_type = Rules.NpcType.AUTHORIZED_STAFF
|
staff.npc_type = Rules.NpcType.AUTHORIZED_STAFF
|
||||||
staff.temperament = 0.15
|
staff.temperament = 0.15
|
||||||
staff.base_compliance = 1.0
|
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 } }
|
staff.default_reactions = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 100 } }
|
||||||
profiles.append(staff)
|
profiles.append(staff)
|
||||||
|
|
||||||
@@ -299,6 +328,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
guest.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
guest.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||||
guest.temperament = 0.2
|
guest.temperament = 0.2
|
||||||
guest.base_compliance = 0.95
|
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 = {
|
guest.default_reactions = {
|
||||||
Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 92, Rules.Reaction.ARGUE: 8 },
|
Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 92, Rules.Reaction.ARGUE: 8 },
|
||||||
Rules.Level.FORMAL_WARNING: { Rules.Reaction.COMPLY: 88, Rules.Reaction.ARGUE: 12 },
|
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.access_policy = policy
|
||||||
|
|
||||||
scenario.equipment = ["wristband_policy", "flashlight", "radio"]
|
scenario.equipment = ["wristband_policy", "flashlight", "radio"]
|
||||||
|
scenario.door_staff_lines = {
|
||||||
|
"wristband_check": [
|
||||||
|
"Wristband, please.",
|
||||||
|
"I need to see your event wristband.",
|
||||||
|
],
|
||||||
|
}
|
||||||
scenario.locations = {
|
scenario.locations = {
|
||||||
"guard_post": Vector3(0, 0, -3),
|
"guard_post": Vector3(0, 0, -3),
|
||||||
"entrance": Vector3(0, 0, 1.0),
|
"entrance": Vector3(0, 0, 1.0),
|
||||||
@@ -589,6 +627,12 @@ static func tv_studio() -> VenueScenario:
|
|||||||
scenario.access_policy = policy
|
scenario.access_policy = policy
|
||||||
|
|
||||||
scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"]
|
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 = {
|
scenario.locations = {
|
||||||
"guard_post": Vector3(0, 0, -3),
|
"guard_post": Vector3(0, 0, -3),
|
||||||
"entrance": Vector3(0, 0, 1.0),
|
"entrance": Vector3(0, 0, 1.0),
|
||||||
|
|||||||
@@ -166,11 +166,10 @@ func decide_leave() -> bool:
|
|||||||
## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and
|
## 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).
|
## headless-testable; returns the line this NPC answers with ("" if silent).
|
||||||
func request_reason() -> String:
|
func request_reason() -> String:
|
||||||
var lines: Array[String] = profile.dialogue_lines if profile else []
|
# Prefer the authored "reason" context; profiles without one fall back to
|
||||||
if lines.is_empty():
|
# their general dialogue lines. Silence remains valid for a deliberately
|
||||||
last_reason_reply = ""
|
# silent character.
|
||||||
return ""
|
last_reason_reply = profile.line_for("reason", rng) if profile else ""
|
||||||
last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)]
|
|
||||||
return last_reason_reply
|
return last_reason_reply
|
||||||
|
|
||||||
## Perception pass: given the guard's distance (meters), return a perception
|
## Perception pass: given the guard's distance (meters), return a perception
|
||||||
|
|||||||
@@ -1940,11 +1940,15 @@ func decide_access(allowed: bool) -> void:
|
|||||||
func _door_context_line() -> String:
|
func _door_context_line() -> String:
|
||||||
if selected_scenario == null:
|
if selected_scenario == null:
|
||||||
return ""
|
return ""
|
||||||
if selected_scenario.access_policy.require_id:
|
match str(selected_scenario.id):
|
||||||
return selected_scenario.door_staff_line("id_check", _door_rng)
|
"joke_shop":
|
||||||
if selected_scenario.access_policy.requires(Rules.AccessKind.TICKET):
|
if selected_scenario.access_policy.require_id:
|
||||||
return selected_scenario.door_staff_line("ticket_check", _door_rng)
|
return selected_scenario.door_staff_line("id_check", _door_rng)
|
||||||
return ""
|
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:
|
func _artifacts_for(controller: Object) -> Array:
|
||||||
var profile: NpcProfile = controller.profile as NpcProfile
|
var profile: NpcProfile = controller.profile as NpcProfile
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ func test_context_lines_are_deterministic_and_fallback() -> bool:
|
|||||||
"unknown context with no fallback is empty")
|
"unknown context with no fallback is empty")
|
||||||
return d and e and f
|
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:
|
func test_request_reason_uses_reason_context() -> bool:
|
||||||
var profile := ScenarioFactory.hollywood_building().profile_for(0)
|
var profile := ScenarioFactory.hollywood_building().profile_for(0)
|
||||||
var controller := NpcController.new()
|
var controller := NpcController.new()
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ func test_request_reason_uses_dialogue_lines() -> bool:
|
|||||||
npc = null
|
npc = null
|
||||||
return a and b and c
|
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:
|
func test_request_reason_silent_without_lines() -> bool:
|
||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("npc_2", _profile("Silent"), "enter", 7)
|
npc.setup("npc_2", _profile("Silent"), "enter", 7)
|
||||||
|
|||||||
@@ -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 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 := TestUtils.check_eq(scenario.door_staff_line("unknown_context", rng), "", "unknown context returns empty")
|
||||||
var b_country := ScenarioFactory.hollywood_building()
|
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.
|
# Same seed picks the same line.
|
||||||
var r0 := RandomNumberGenerator.new()
|
var r0 := RandomNumberGenerator.new()
|
||||||
r0.seed = 11
|
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", r0),
|
||||||
scenario.door_staff_line("wand_check", r1),
|
scenario.door_staff_line("wand_check", r1),
|
||||||
"seeded door-staff pick is deterministic")
|
"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:
|
func test_protester_archetype_present() -> bool:
|
||||||
var profiles := ScenarioFactory.shared_profiles()
|
var profiles := ScenarioFactory.shared_profiles()
|
||||||
|
|||||||
Reference in new issue
Block a user