Compare commits

..
96 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 82e58559ba chore(meta): release v0.1.14
Sprint 14 "Live" complete — 22/22 tickets done. NPCs breathe
independently: mood, trust, routines, overheard conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:40:46 +01:00
jpmschweitzerandClaude Opus 4.6 e2985142f5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:56:13 +01:00
jpmschweitzerandClaude Opus 4.6 a0001c059a docs(scratchpad): note on contextual NPC conversation topics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:55:50 +01:00
jpmschweitzerandClaude Opus 4.6 9a91c18739 data(schema): raise monologue line maxLength from 160 to 256 chars
Hard cap at 256 with soft guidance to aim for ≤160 to avoid line wrap.
Allows longer lines for urgent monologue and confrontation beats that
need more space without schema validation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:55:46 +01:00
jpmschweitzerandClaude Opus 4.6 536c7d953d docs(decisions): update D-061 with unified conversation log architecture
Sprint 14 #535 redesigned the dialogue box into a single chronological
log for both player-NPC and overheard NPC-NPC conversations. Updates
D-061 in perception.md to document the unified log pattern, entry
lifecycle, passive line treatment, and cross-references to D-076/D-078.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:55:40 +01:00
jpmschweitzerandClaude Opus 4.6 952f994d59 fix(ui): address PR #52 review — 10 items across Hoshe, Tyre, Araminta (#535)
Critical: bump PROTOCOL_VERSION 8→9 for conversation_events/ended fields.

Hoshe review:
- Dirty flag (_log_dirty) prevents per-frame O(n) BBCode rebuild
- BBCode injection: _escape_bbcode() replaces [ with [lb] on server text
- D-064 regression: dialogue_active cleared in fade callback, not before
- YAML quoting: remove unnecessary quotes from numeric values

Tyre review:
- Carry-forward for dialogue_response, conversation_events, conversation_ended
  in receive_bytes() — arrays merge, scalar falls through
- pause_requested/unpause_requested signals route through main.gd input
  recording (_pending_record_inputs) for #507 replay determinism
- Fix version comments: dialogue_response is v8 (#305), not v9
- Remove dead _active_overheard dictionary

Araminta review:
- Passive lines: ┃ glyph prefix + _desaturate() for name colours
- Active conversation entries pinned (no timeout), unpinned with timestamp
  reset on conversation end
- _enforce_contrast(): minimum luminance floor for name colour readability
- Simplified 1-on-1 attribution: "Speaker:" instead of "Speaker → You:"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:51:40 +01:00
jpmschweitzerandClaude Opus 4.6 2edc7c3098 feat(ui): unified dialogue log with overheard NPC conversations (#535)
Refactors dialogue box into a scrolling conversation log. All dialogue
(player-NPC and overheard NPC-NPC) flows chronologically, oldest at top.
Player response options at the bottom during active conversations.

- Entries expire after configurable timeout (equal for all message types)
- Walk-away clears options but preserves log entries (fair information)
- Per-character name colors from dialogue-theme.yaml (hash-indexed palette)
- Overheard lines render at 90% opacity (D-078)
- Protocol decode for conversation_events + conversation_ended
- GameState fields for conversation_events, conversation_ended, dialogue_response
- Mock Mira/Soren NPC-NPC conversation in test snapshot
- Also wires #511 debug overlay into main.gd and main.tscn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:51:40 +01:00
jpmschweitzerandClaude Opus 4.6 ef3f4a70fa feat(ui): F3 debug overlay — real-time game state display (#511)
Toggle with F3. Shows tick, fps, position, facing, stance, zone,
entity/tile counts, interaction/recognition counts, dialogue status,
stationary ticks, insert state, time/tick_rate, mode, gauntlet.
Two-column layout, click-through, hidden by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:51:40 +01:00
jpmschweitzer 665f67626b Merge pull request 'feat(copy): Sprint 14 — monologue architecture, line pools, voice guide, schema, overheard dialogue' (#51) from copy into main 2026-02-20 19:31:28 +01:00
jpmschweitzerandClaude Opus 4.6 386d80df26 fix: resolve merge conflicts with main (Situation + Mood variants)
Keep all new variants from both branches: Greeting (copy), FirstMeeting
and RepeatedVisit (server). Combine Mood doc comments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:31:20 +01:00
jpmschweitzer f1dcdc2ee9 Merge pull request 'feat(simulation): Sprint 14 — NPC mood, trust, routines, conversations, invariants' (#50) from server into main 2026-02-20 19:28:53 +01:00
jpmschweitzer c10b896562 Merge pull request 'feat(client): #122 monologue display — multi-line, priority queue, 34 tests' (#49) from client into main 2026-02-20 19:28:49 +01:00
jpmschweitzer b2559d5467 Merge pull request 'docs(visual): Sprint 14 visual spec suite — 6 design documents' (#48) from visual into main 2026-02-20 19:28:39 +01:00
jpmschweitzerandClaude Opus 4.6 f0e0673c99 fix(simulation): address PR #50 review — determinism, overflow, invariants
- Sort eligible NPCs by StableId for deterministic conversation pairing (D-010)
- Replace single_mut() with per-observer iteration (D-010 principle 3, D-027)
- Widen current_stress * 100 to i32 in mood derivation to prevent i16 overflow
- Promote line_interval to named constant LINE_INTERVAL_TICKS
- Add 4 Sprint 14 component invariants (MoodState, InteractionMemory,
  ActivityState+PathRequest exclusion, double-tagged tier detection)
- Attach MoodState to content spawn pipeline and gauntlet fixup
- VecDeque for InteractionMemory.notable_events (O(1) pop_front)
- Rename who_knows() to who_knows_full_scan() to communicate O(N) cost
- Add O(N^2) growth limit comment on conversation pair scan
- Document Suspicious/Focused as externally-set moods on enum variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:18:34 +01:00
jpmschweitzerandClaude Sonnet 4.6 677ca9b59d fix(ui): address PR #49 review — 6 warnings + 2 suggestions (#122)
Warnings fixed:
- BBCode injection (line 153): escape [ → [lb] in server text before interpolation
- sort_custom on silent-drop (line 125): sort now only runs on actual insertion/replacement
- clip_contents: add clip_contents=true to MonologueDisplay Control (overflow guard)
- confrontation tick guard (main.gd): _last_confrontation_tick deduplicates same-tick signals
- GameState decoupling: show_monologue() reads lattice_profile once and passes it through
  _show_line() → _build_line_node(); renderer no longer reaches into autoload (D-020)
- Equal-priority eviction: >= tiebreak (was >); FIFO for equal-priority queue overflow

Suggestions fixed:
- Minimum duration clamp: maxf(duration, FADE_IN_SEC + 0.1) — line survives own fade-in
- _label_text bounds check: guard against empty _visible before indexing [0]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:06:26 +01:00
jpmschweitzerandClaude Opus 4.6 3c4304f745 fix(visual): address PR #48 review comments
- sound-indicator-visual: D-069 → D-033/D-048 color reference, reconcile
  medium range S1 (3-8) vs S7 (3-20) contradiction, split dual values in
  quick-reference table
- entity-color-system: D-046 → D-015 for peripheral rendering, tighten
  cognitive delay wording, add accessibility ticket tracking note
- visual-grammar-v01: dialogue max-width corrected from ~70% to 640px
  per D-076
- the-friend-visual-treatment: D-063 citation title corrected
- tell-visual-expression: soften D-024 attribution (axis, not categories)
- environmental-text-standards: specify tile unit for terminal visibility
- monologue-display-spec: header updated to reflect Sprint 14 inclusion
- CHANGELOG: add entries for all 6 Sprint 14 design docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:04:00 +01:00
jpmschweitzerandClaude Opus 4.6 036e9dc5c6 fix(tests): update NPC profile count assertions (20→23) for Sprint 14 content
Three NPC profiles added by copy team content expansion. Updates
content_loading test expectations to match actual content directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:00:14 +01:00
jpmschweitzerandClaude Opus 4.6 91eb11da16 refactor(content): rename mood vocabulary to match voice guide (D-035 Sprint 14 amendment)
Schema and Rust Mood enum renamed for author-friendly vocabulary:
fond→warm, comfortable→content, worried→anxious, concerned→frustrated.
Dropped: analytical (merged into focused), conflicted (modeled as
suspicious+warm collision). Added: hostile. Final 8 moods: anxious,
frustrated, content, suspicious, warm, hostile, relieved, focused.
Neutral = untagged. Resolves Gestalt's blocking issue on #121.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:00:07 +01:00
jpmschweitzerandClaude Sonnet 4.6 019a7b2221 test(simulation): additional conversation system QA coverage (Hoshe, S14)
Additional integration tests for conversation cooldown application, distance
termination with both NPCs receiving cooldown, and edge cases in the NPC-to-NPC
conversation pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:40 +01:00
jpmschweitzerandClaude Sonnet 4.6 8bd65a5662 feat(simulation): NPC-NPC relationship dynamics with passive decay (#103, D-024)
update_relationship_dynamics runs once per game-minute (10 ticks). Edges
inactive for 1+ game-hours (600 ticks) decay trust 1 point toward 0 per
minute. Creates social texture: NPCs who haven't interacted drift to neutral
without active maintenance. Blocks #249 (social propagation, Sprint 15).

Constants: DECAY_INTERVAL_TICKS=10, DECAY_INACTIVITY_THRESHOLD_TICKS=600,
DECAY_DELTA=1. Integer arithmetic only (D-010 determinism).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:36 +01:00
jpmschweitzerandClaude Sonnet 4.6 bd60b356f8 feat(simulation): routine execution system with ActivityState (#101, D-031)
ActivityState component tracks the activity an NPC is currently performing
at their routine destination (activity name, phase, started_tick). The
enter_activity system runs after movement validation and sets ActivityState
when an NPC has arrived with no active path. Cleared on phase transitions.

Feeds TellTrigger::DuringActivity and D-028 Layer 2 situation matching.
Unit tests verified via shift_change room integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 dcd15a330f feat(simulation): interaction tracking + trust progression (#325, #324)
InteractionMemory component tracks interaction_count, last_interaction_tick,
and notable_events per NPC. Drives D-028 Layer 2 situation activation:
first_meeting (count==0) and repeated_visit (count>=3). warm_active in mood
system now derives from InteractionMemory within a 300-tick window.

Trust progression wired into dialogue systems: talk completion (+1),
walk-away (-1), confrontation (-2) emit TrustEvents consumed by update_trust.
InteractionEvent (WalkAway, Confrontation) recorded in notable_events for
fast per-pair access.

Adds FirstMeeting and RepeatedVisit Situation variants. 18 unit tests in
interaction.rs. All arithmetic integer-only (D-010 determinism). No HashMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:23 +01:00
jpmschweitzerandClaude Sonnet 4.6 df4d7b4666 docs(design): add Gestalt KG validation to #253 prerequisite map and #121 mood guide
#253 monologue-content-architecture.md:
- All prerequisite fact_ids validated against knowledge vocabulary — APPROVED
- Flag: behavioral.sera_avoidance should be behavioral.sera_avoidance_pattern
- Flag: investigation.drin_inspection_pattern duplicated in investigation.yaml
- Confirm: cooldown is content-layer (schema field) — OQ item 3 resolved
- Pending: witness_interaction trigger payload (OQ item 4) — Dudley to confirm
  before named-pair witness_interaction lines can be authored

#121 monologue-voice-guide.md:
- BLOCKING: mood vocabulary mismatch — voice guide uses neutral/anxious/frustrated/
  content/warm/hostile but schema enum uses fond/comfortable/worried/analytical/
  conflicted/concerned/relieved; hostile not in schema at all
- Provide interim mapping table so Mellanie can use correct schema names
- Flag schema gaps: analytical and relieved have no voice guide coverage
- Recommend schema rename to voice guide names (Lead decision required)
- Mood-as-selection-weight model is mechanically coherent — APPROVED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:43:55 +01:00
jpmschweitzerandClaude Opus 4.6 c71ba8ebb3 test(ui): GameState integration + D-049 compliance tests for monologue display (#122)
7 tests: snapshot round-trip, null/stale clearing, non-dict rejection,
duration/id preservation, canvas layer constant. before_test/after_test
lifecycle hooks prevent state bleed between test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:43:41 +01:00
jpmschweitzerandClaude Sonnet 4.6 a14c980aad test(simulation): map-agnostic invariant tests, 36 invariants (#508)
Adds test_world::invariants with 29 world-query invariants (structural,
perception, population, simulation) and 7 system-execution tests. All
invariants are gated behind the gauntlet feature and run against the
fully-initialized gauntlet world. Documents StableId ranges through
Sprint 14 rooms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:56 +01:00
jpmschweitzerandClaude Sonnet 4.6 9eba259fd5 feat(simulation): NPC mood state machine (#323, D-024, D-035)
8-state NPC mood FSM driven by stress, time of day, and interaction events.
Adds MoodState component, derive_mood() pure function, and update_mood() Bevy
system wired into NpcPlugin. Adds Focused as 9th Mood content tag (D-035
Sprint 8 amendment). Syncs CurrentMood for Layer 4 dialogue selection.

31 unit tests covering priority ordering, boundary conditions, and system
integration. All random values route through SimRng (D-010 determinism).
No floats. No HashMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:51 +01:00
jpmschweitzerandClaude Sonnet 4.6 aa0d8ced37 feat(simulation): NPC-to-NPC conversation system (#247, D-078)
Implements server-authoritative per-word occlusion for NPC conversations.
ConversationEventBuffer drains into ObserverSnapshot each tick so the client
receives only words audible from the player's position. Updates test fixtures
to include the new conversation_events and conversation_ended fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:44 +01:00
jpmschweitzerandClaude Sonnet 4.6 b49d78ef6a fix(ui): guard empty text in show_monologue — no ghost slots or queue entries (#122)
show_monologue() now returns early when text.is_empty(), preventing:
- ghost visible slots with blank labels
- stagger timer advancing on empty calls
- empty strings queuing when slots are full

Tests: add test_show_monologue_with_empty_text_does_not_set_displaying and
two companion cases (stagger timer unchanged, no enqueue when full) anticipating
Hoshe's QA additions to the test suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:22 +01:00
jpmschweitzerandClaude Sonnet 4.6 1765e2922f refactor(ui): monologue display — multi-line architecture per Tyre review (#122)
Rewrites the monologue display system per Tyre architecture review (Sprint 14):

Rendering:
- Up to 3 simultaneous visible lines (VBoxContainer, dynamic node creation)
- Lines created programmatically as MarginContainer > RichTextLabel per slot
- Percentage-based anchors: 5% left, 75–98% vertical (25% area from bottom), 50% max width
- Z-layer 7 in UILayer (CanvasLayer 20, D-049)

Queue:
- 5-entry priority queue; highest priority drains first
- On overflow: incoming line replaces lowest-priority queued entry if it outranks it
- Lower/equal priority incoming lines silently dropped when queue full

API:
  show_monologue(text, duration, priority=2, is_urgent=false)
- Replaces old (text, duration, character_type) signature
- main.gd passes priority and is_urgent from MonologueEvent fields
- Confrontation monologue: priority=3, is_urgent=true (D-063)

Colour:
- Reads GameState.lattice_profile at render time (D-032)
- lattice_augmented (detective): standard #d0d4e0 / urgent #e0e8f8
- lattice_baseline (smuggler): standard #d8d0c4 / urgent #f0e4d4
- Fallback for unknown profiles; no crash

Stagger: 0.15s between consecutive fade-ins (spec §5.4)
Opacity: standard 0.85, urgent 1.0; bloom deferred

GameState: adds lattice_profile field, parsed from snapshot

Tests: 27 gdUnit4 test cases — queue order, priority drop, overflow, stagger,
no-overwrite (P0 #477), BBCode output, palette selection, slot lifecycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:39:49 +01:00
jpmschweitzerandClaude Sonnet 4.6 0b5952a107 docs(visual): Sprint 14 visual spec suite — 6 design documents
Completes all Sprint 14 visual tickets (#304, #316, #317, #318, #334, #251).

- entity-color-system.md (#304): Full D-033 relationship→color spec. Hex values,
  runtime derivation from VisibleEntity.relationship, 0.5s transition behavior,
  fog edge cases, static object definition, color blindness assessment (deuteranopia
  and protanopia gaps flagged for v0.1.2+). Implementation notes for Stig.

- text-display-hierarchy.md (#316): Four content pipelines — dialogue, monologue,
  overheard/observation, environmental text. Screen positions, z-layers, font sizes,
  hex values, passive panel mode for D-078 overheard conversation.

- sound-indicator-visual.md (#317): Fog-edge pulse spec. Three color codes, direction
  encoding, three range-distance alpha levels, 2.5s pulse animation target, suppression
  rules, current vs target implementation delta for Stig.

- the-friend-visual-treatment.md (#318): Phase 1/2/3 visual states for Kael Davan
  and Sera Venn. Earned visual detail principle, 0.5s green→amber transition, staging
  requirement (first relationship color change in session), animation tier transition
  point. Cross-references entity-color-system.md.

- environmental-text-standards.md (#334): Signage, terminals, news tickers. Character
  limits, z-layers, two-state terminal model, bilingual Concordat Standard/Krenn
  vernacular treatment (D-036), scroll behavior, copy team and Stig quick-reference tables.

- tell-visual-expression.md (#251): 5 tell categories mapped to 6 Tier 2 behavioral
  patterns. Tile-level appearance descriptions, intensity scaling, server implementation
  notes for Dudley, monologue layer spec for Mellanie. THE FRIEND tell trajectory
  for Kael and Sera included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:38:45 +01:00
jpmschweitzerandClaude Sonnet 4.6 8c362c8535 feat(schema): implement D-035 converged tag taxonomy as canonical JSON Schema (#168)
- Add content/_schema/dialogue-line.schema.json — canonical standalone line schema
  with all 6 structural + 3 selection + 2 authoring-only tags and $defs for
  monologue extension (character, trigger, prerequisite)
- Update dialogue-pool.schema.json: add dual_lens and notes authoring-only tags,
  add additionalProperties: false to knowledge_grant
- Update monologue-pool.schema.json: add all 6 structural tags as required fields
  for schema compliance (role=player_character, access=[public], trust=surface are
  constrained constants); add topic, mood, dual_lens, notes
- Enumerate all 14 v0.1 situations and 9 v0.1 topics as allowed enum values per D-035
- Fix Rust sync gap: add Situation::Greeting and Mood::Focused to line_pool.rs
  (D-035 Sprint 8 amendments — were in schema but missing from Rust enums, causing
  content with these values to be silently dropped at runtime)
- Update line_preview.rs situation_str, mood_str match arms and help text for
  new variants; all 25 line_pool tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:38:37 +01:00
jpmschweitzerandClaude Sonnet 4.6 4365871846 feat(ui): monologue display — queue, character colours, italic BBCode (#122)
- Queue management: enqueue on mid-display arrival, drain in order, MAX_QUEUE_DEPTH=8 cap, no-overwrite contract (P0 #477)
- Character colours: detective=#c8e0ff (cool blue), smuggler=#f0c870 (warm amber), fallback to neutral insert text
- Typography: italic via BBCode [i] tags; font size 13px (smaller than dialogue)
- Positioning: bottom-left of viewport, 420×120px, 80px bottom margin above verb list
- main.gd: _get_active_character_type() reads player entity kind.data.character_type; passed to show_monologue() and _on_confrontation_monologue()
- gdUnit4 tests: queue order, depth cap, no-overwrite, BBCode output, colour mapping, fade timer integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:31:13 +01:00
jpmschweitzer 14cd16f33a chore: resolve CHANGELOG.md merge conflict from main 2026-02-20 18:24:52 +01:00
jpmschweitzerandClaude Opus 4.6 4e53a2e979 chore(meta): release v0.1.13
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:07:35 +01:00
jpmschweitzerandClaude Opus 4.6 1b08734838 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:53:02 +01:00
jpmschweitzerandClaude Opus 4.6 16bc87c590 docs(sprints): add Sprint 14 "Live" briefings — 22 tickets across 4 teams
NPC mood, trust, routine execution, NPC-to-NPC conversations with D-078
occlusion, monologue display, content line pools, and visual specs.
Teams: server (7), client (3), copy (6), visual (6).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:52:50 +01:00
jpmschweitzerandClaude Opus 4.6 f6eb92cb25 feat(docs): add D-078 — overheard NPC conversation via passive dialogue panel
Server-authoritative stochastic per-word occlusion filter for NPC-to-NPC
conversations. Dialogue panel in passive mode displays pre-occluded text;
monologue remains a clean internal response system. Indexes updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:52:44 +01:00
jpmschweitzerandClaude Opus 4.6 e11d54ff5c fix(assets): add missing .import file for sfx_npc_murmur.ogg
Godot requires .import files for stable uid:// references across
editor sessions. All other audio assets have one — this was missed
in the audio branch PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:48:42 +01:00
jpmschweitzer cf3349b909 Merge remote-tracking branch 'origin/audio' 2026-02-19 18:46:53 +01:00
jpmschweitzer bc1cfabb68 Merge remote-tracking branch 'origin/client' 2026-02-19 18:45:54 +01:00
jpmschweitzerandClaude Opus 4.6 8985340bdd fix(client): address PR #47 review — murmur deferral, zone extraction, 6 tests
Remove premature Voice/VoiceConversation from SOUND_EVENT_ASSETS (D-072
defers murmur to end-to-end sprint). Extract current_zone_id in
GameState.apply_snapshot() as first-class field, eliminating O(N) tile
scan in main.gd (D-020 server-authoritative). Add dir.list_dir_end()
after registry scan. Add rapid zone-crossing + _load_prefs() roundtrip
tests. Enhance comments on hub/workplace same-asset pattern, station
base hum, and confrontation dip replacement semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:37:23 +01:00
jpmschweitzer d2d538f4a8 Merge remote-tracking branch 'origin/server' 2026-02-19 18:36:48 +01:00
jpmschweitzerandClaude Opus 4.6 f57560ebe7 docs(meta): add tea comment hanging workaround to CLAUDE.md
tea comment hangs with inline heredocs and multi-line strings.
Document the two-step pattern: write to .tmp/ first, then pass
via $(cat).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:34:39 +01:00
jpmschweitzerandClaude Opus 4.6 dd1dd0feec fix(simulation): address PR #46 review — zone tests, doc accuracy, TBD comment
Add 3 zone enrichment tests to observer pipeline (tile inside zone,
tile outside zone, absent ZoneMap resource). Fix misleading Decay
Observatory doc comment that claimed Direct start when test starts at
KnowsDetails. Add production population TBD note on ZoneMap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:22:48 +01:00
jpmschweitzerandClaude Opus 4.6 513682522f test(audio): Sprint 13 test suite + lambda capture fix (#528-#533)
57 new tests in test_audio_sprint13.gd covering bus setup, dip profiles,
zone crossfade, recognition chime, and murmur wiring. Fix lambda capture
bug in 3 signal tests (String → Array wrapper for reliable closure
capture in Godot 4). Add bus volume reset to before_test() for test
isolation against prefs-loaded state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:19:32 +01:00
jpmschweitzerandClaude Opus 4.6 0e6cc4eef8 feat(audio): Sprint 13 — AudioManager, zone crossfade, dip profiles, murmur wiring (#528, #529, #530, #533)
AudioManager: volume slider persistence (ConfigFile), settings UI with
5 teal-bordered sliders, default_bus_layout.tres for editor. Zone
crossfade: defensive zone_id read from snapshot tiles, 1.5-2s ambient
tween, auto-activates when server ships OQ-09. Dip profiles: dialogue
dip in show/hide_dialogue, ListeningFocus 30-tick gate via
stationary_ticks in game_state.gd. NPC murmur: client plumbing for
event-driven World SFX playback, no-ops until audio asset arrives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:19:25 +01:00
jpmschweitzerandClaude Opus 4.6 96275c5b90 fix(skills): add agent rules for git and db script usage in sprint-start
Spawned agents are told not to run git commands (team lead handles those)
and to call db/connectors scripts with bare commands only — no python3
prefix or command chaining — so they match the permission whitelist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:10:07 +01:00
jpmschweitzerandClaude Opus 4.6 51abc3e3c1 feat(simulation): #523 add zone_id to ObserverSnapshot (D-077, protocol v11)
Server-tracked zone_id on VisibleTile for D-073 zone crossfade and
D-059 deep fog temperature tint. ZoneMap resource backed by BTreeMap,
observer enrichment in snapshot assembly. Backwards-compatible: v10
payloads deserialize with zone_id: None.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:10:06 +01:00
jpmschweitzerandClaude Sonnet 4.6 c2de509c01 feat(simulation): add Sound Lab, Decay Observatory, Shift Change gauntlet rooms (#505)
Three new gauntlet test rooms for Sprint 13, each targeting a specific
system under test. StableIds are additive-only (66–74).

Sound Lab (66-68): Three NPCs at calibrated tile distances (2, 6, 12
tiles) verify D-018 three-range sound model. Tests confirm Close ≤3,
Medium ≤8, Long ≤20 tile thresholds via SoundEvent::audible_at().

Decay Observatory (69): Single NPC observed in LOS then broken. Tests
confirm D-041 confidence decay: KnowsDetails → KnowsOf → Suspects
(floor), and Stale state when stale_after threshold is exceeded.

Shift Change (70-71): Two NPCs with DailyRoutine components. Test
confirms D-031 phase-boundary check_phase_transition issues PathRequest
only to NPCs not already at their target location.

Sprint 13 reset plates (72-74) wired per existing pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 18:05:46 +01:00
jpmschweitzerandClaude Sonnet 4.6 a394ff6a5f feat(audio): NPC conversation murmur asset (SFX-003)
Produces sfx_npc_murmur.ogg via Stable Audio Open for D-072 universal
event-driven NPC proximity murmur. Organic sonic family (D-074): warm
mid-freq 200-800Hz vocal texture, human voices in adjacent room with
direction and cadence distinguishable, words not. World SFX bus.

Generated 3 candidates (5s/CFG8, 6s/CFG7, 4s/CFG9), selected candidate
1 for richest texture and clearest spatial framing. Unblocks client #533.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:10:17 +01:00
jpmschweitzerandClaude Opus 4.6 1fa04c3e89 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:10:08 +01:00
jpmschweitzerandClaude Opus 4.6 2eb887c75f docs(visual): monologue display system visual spec (#315)
Typography, positioning, stacking rules (max 3 lines), priority
system (observation > atmosphere > tutorial), fade animation,
character-differentiated color (detective cool, smuggler warm),
and 80-character line length constraint for copy team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:10:00 +01:00
jpmschweitzerandClaude Opus 4.6 61c452aca4 chore(config): whitelist remaining skill invocations
Add sprint-plan, pr-push, pr-review, ticket, docs-search, and
workshop-start to shared permission settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:59:58 +01:00
jpmschweitzerandClaude Opus 4.6 4eeaf72456 fix(skills): check sprint progress before closing in sprint-start
Case A on main previously closed the active sprint immediately without
checking ticket completion or open PR status. Now checks both and asks
the user before proceeding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:59:56 +01:00
jpmschweitzer 45a644c701 Merge branch 'main' of ssh://git.schweitz.net:2222/jpmschweitzer/settled-reach 2026-02-19 16:54:35 +01:00
jpmschweitzerandClaude Opus 4.6 c13d97b7d9 chore(meta): release v0.1.12
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:54:22 +01:00
jpmschweitzerandClaude Opus 4.6 749d53f198 chore(meta): release v0.1.12
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:52:07 +01:00
jpmschweitzerandClaude Opus 4.6 bf2ce4ca36 chore(db): backup database after planning merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:51:31 +01:00
jpmschweitzer 72741562e8 Merge remote-tracking branch 'origin/planning'
# Conflicts:
#	CHANGELOG.md
2026-02-19 16:51:20 +01:00
jpmschweitzerandClaude Opus 4.6 6995405950 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:49:57 +01:00
jpmschweitzerandClaude Opus 4.6 03f70ada97 docs(sprints): add Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual
Full audio architecture sprint: AudioManager completion, zone crossfade,
dip profiles, recognition chime, murmur pipeline. Server gauntlet expansion
and OQ-09 resolution. Visual monologue display spec unblocks copy pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:49:39 +01:00
jpmschweitzerandClaude Opus 4.6 5ef845e71d chore(db): backup database after copy merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:46:14 +01:00
jpmschweitzer ce5f735949 Merge remote-tracking branch 'origin/copy' 2026-02-19 16:46:06 +01:00
jpmschweitzerandClaude Opus 4.6 500f15960a chore(meta): update changelog
Sprint 12 copy team output: 26 tickets covering foundation docs,
character builds, design documents, content packs, and ambient
expansion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:44:37 +01:00
jpmschweitzerandClaude Sonnet 4.6 d9facc7958 feat(copy): Sprint 12 #194 — generation pass, ambient variant expansion
80 new ambient/routine lines across all 9 content pack dialogue files.
Expands greeting pools and routine floor/bar talk to reduce cycling in play.

Critical lines untouched: knowledge_grants, threshold lines (real trust),
ring-ops insider dialogue, detective confrontation lines.

Per-file additions:
- shift-supervisor: 7 lines (procedural authority variants)
- dock-worker: 12 lines (floor talk, colleague references, shift texture)
- scheduler: 8 lines (scheduling procedure, audit prep)
- new-hire: 7 lines (orientation questions, colleague curiosity)
- courier: 6 lines (arrival/departure variants)
- bar-owner: 8 lines (greeting variants, house-rule phrases)
- bartender: 9 lines (service register, social observations)
- bar-regular: 10 lines (arrival greetings, community texture)
- ring-operative: 7 lines (surface cover only — route-deflection, maintenance cover)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:42:19 +01:00
jpmschweitzerandClaude Sonnet 4.6 2b62aa275f feat(copy): Sprint 12 Wave 3 — workplace, bar, and ring content packs
#190 Workplace content pack — The Terminal:
- shift-supervisor.yaml: Voss (23 lines, ring-complicit via scheduling cover)
- dock-worker.yaml: generic floor worker (21 lines, dual-lens discrepancy awareness)
- scheduler.yaml: Maret (20 lines, WITNESS/SKEPTIC arc, threshold line at real trust)
- new-hire.yaml: fresh hire (14 lines, inadvertent witness via naive observation)
- courier.yaml: Renn Harsk (16 lines, ring-adjacent documentation handler)

#191 Bar content pack — The Last Shift:
- bar-owner.yaml: Lera (23 lines, Torek tab arrangement, Naia threshold line)
- bartender.yaml: Sess (17 lines, social intelligence, behavioral change tracking)
- bar-regular.yaml: generic regular (20 lines, transition window frustration, Tamm notice)

#192 Smuggling ring content pack — Maintenance Corridors:
- ring-operative.yaml: generic operatives (20 lines, dual-register coded language)
  Coded vocabulary: "cans are ready", "manifest is clean", "Drin was asking",
  "dead air", "the gap". Surface and operational registers sound identical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:36:52 +01:00
jpmschweitzerandClaude Sonnet 4.6 266510c3d8 feat(content): populate knowledge fact catalogs for v0.1 transit district
Add machine-readable fact catalog YAML files for all knowledge categories.
Makes the check-fact-ids pre-commit hook validation functional.

New files: awareness.yaml, behavioral.yaml, knowledge.yaml, social.yaml
Populated: investigation.yaml, contraband.yaml, location.yaml,
           relationship.yaml, world.yaml, progress.yaml

50+ canonical fact IDs covering: smuggling ring operations, investigation
findings, behavioral patterns, situational awareness, location facts,
inter-NPC relationships, and social positioning. Character starting
confidence annotated per fact. Derived from knowledge-vocabulary-v01.md (#368).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:22:12 +01:00
jpmschweitzerandClaude Sonnet 4.6 cfbd00f876 docs(decisions): record Gestalt endorsement of D-075 archetype dimension review
D-075 dissent was pending Gestalt and Nigel input on the no-archetype-tag
decision. Gestalt endorses: access tier tags in content handle archetype
distinction emergently; starting knowledge differentials produce per-character
gate timing without hardcoded archetype filters; maintenance cost of archetype
tags would undermine the "two keyholes on the same world" experience (D-027).
Nigel's input still pending.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:19:31 +01:00
jpmschweitzerandClaude Sonnet 4.6 e40ce61e6f docs(copy): Sprint 12 Wave 2 — Meridian Coverage Map for Sova Transit (#335)
Ten coverage zones from Gate Terminal Approach (comprehensive) through
Corridor 7B (dead air, Severance-enhanced). Maps detective investigative
progression, smuggler operational geography, NPC behavioral modulation by
zone, and coverage-transition monologue triggers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:19:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 6b1c5e4d29 docs(copy): Sprint 12 Wave 1 — Paula's ten design documents
Character schema and builds:
- #179: Character definition schema (6-section formal spec)
- #180: Smuggler character build (full schema instantiation)
- #181: Detective character build (full schema instantiation)
- #182: Divergent starting knowledge (master comparison table)
- #183: Divergent relationships (NPC web per character)

Institutional design:
- #322: Detective chain of command (Veth, Pres, Tsev backstory)

NPC pattern documents:
- #332: Contradiction arc — reusable FRIEND pattern (5 phases, checklist)
- #329: Mirror moment design — 10 paired dual-lens observations

Experience design:
- #259: First 5 minutes (narrative texture additions to Gestalt's systems doc)
- #307: Flat NPC trait pass (Pael, Ren, Tev — traits, routines, dual-lens)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:18:23 +01:00
jpmschweitzerandClaude Sonnet 4.6 6719cf0fe3 docs(copy): Sprint 12 Wave 1 — Miri's five foundation docs
Cultural generation guide (#189), Sova Texture Appendix (#302),
Contraband specification (#321), Sova Station Profile (#320), and
Span Gate Transit Schedule (#336). These are the blocking foundation
docs for all three content packs (#190/#191/#192) and the Meridian
Coverage Map (#335).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:16:04 +01:00
jpmschweitzerandClaude Sonnet 4.6 7916ebac5f feat(copy): Sprint 12 Wave 1 — flat NPCs, environmental text, insert flavor
Completes three unblocked Sprint 12 copy tickets:

#307 — Flat NPC memorable trait pass (3 NPCs)
- npcs/pael.yaml: maintenance tech, NOBODY/CIVILIAN tier 3
- npcs/ren.yaml: drifter, NOBODY/CIVILIAN tier 3
- npcs/tev.yaml: transit worker (looks like lookout), NOBODY/CIVILIAN tier 3
- dialogue/the-terminal/maintenance-tech.yaml: Pael's 6 lines incl. "seems
  important but isn't" B-7 seal complaint (the-terminal_d_040-045)
- dialogue/the-last-shift/day-worker.yaml: Ren's 6 lines incl. night-rotation
  job-seeking line (the-last-shift_d_001-006)
- dialogue/maintenance-corridors/transit-worker.yaml: Tev's 5 lines incl.
  corridor-watching-but-waiting-for-partner line (maintenance-corridors_d_001-005)

#262 — Environmental text content
- environmental/the-terminal.yaml: 7 items — shift board, manifest terminal
  (4471 discrepancy), maintenance clipboard (Pael's B-7 log), compliance
  notice, Drin/Maret work order note, safety placard, bay status board
- environmental/the-last-shift.yaml: 7 items — drink menu, Lera's rules
  (Torek named), notice board (Vessels Tamm missing), graffiti (bathroom),
  specials board, lattice-free zone notice, cargo workers' association flyer
- environmental/maintenance-corridors.yaml: 6 items — access restriction,
  Meridian scan schedule (14:20-14:45 window documented), maintenance log
  (Pael's anomaly report), routing diagram (pencil annotation), air quality
  notice, C-4 temporary storage log (container 4471 thread)

#331 — Diegetic insert flavor text
- insert/smuggler.yaml: POI labels (first-name, state-dependent), operational
  notifications (Meridian window, drop point, ring check-in, Commission patrol),
  system status strings, examination labels
- insert/detective.yaml: POI labels (surname-first, first-name shift for
  allies is the relationship marker), Commission/case-file notifications,
  field logging strings, examination labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:10:57 +01:00
jpmschweitzerandClaude Opus 4.6 7c0112bd76 chore(db): backup database after client merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:57:06 +01:00
jpmschweitzer 7874493d2a Merge remote-tracking branch 'origin/client' 2026-02-19 15:56:57 +01:00
jpmschweitzerandClaude Opus 4.6 f42c3209d8 chore(db): backup database after worktree merge
Regenerate msgpack fixtures for combined sound_events + rng_seed struct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:37:28 +01:00
jpmschweitzerandClaude Opus 4.6 dfcafee3ee fix(client): address PR #43 review — walk key mismatch + 5 suggestions
Fix SOUND_EVENT_ASSETS walk-speed keys to match actual filename
(sfx_footstep_metal_walk), add play_loop null guard, source indicator
colors from Constants, extract CAMERA_DEFAULT_ZOOM, document
consume-once semantics on close_sound_events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:37:20 +01:00
jpmschweitzer 8c92ae2285 Merge remote-tracking branch 'origin/visual' 2026-02-19 15:36:59 +01:00
jpmschweitzer 4bdcc8c3fa Merge remote-tracking branch 'origin/ci'
# Conflicts:
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/Cargo.toml
#	server/src/bridge/text_renderer.rs
#	server/src/bridge/types.rs
#	server/src/perception/observer/mod.rs
#	server/src/simulation/path_follow.rs
#	server/tests/bridge_ipc.rs
#	server/tests/bridge_tcp.rs
#	server/tests/gen_fixtures.rs
#	server/tests/serialization.rs
2026-02-19 15:35:56 +01:00
jpmschweitzerandClaude Opus 4.6 87a90b27bd feat(simulation): wire footstep sound events into movement system
validate_movement now inserts SoundEventEmitter with Footstep events on
every successful move. Intensity scales by stance: Sprint 0.8, Walk 0.5,
Careful 0.3, Crouch 0.15. Range is Close (3 tiles) for all stances.

This completes the sound event pipeline end-to-end: movement produces
events → collect_sound_events drains to queue → observer snapshot
includes audible events → client bridge receives them.

Addresses Tyre critical review item #1 on PR #42.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:14:52 +01:00
jpmschweitzerandClaude Opus 4.6 f533235f58 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:08:51 +01:00
jpmschweitzerandClaude Opus 4.6 03c44bebaf fix(simulation): address PR #42 review — 8 items from Hoshe and Tyre
1. Sound producers: document empty v0.1 pipeline explicitly (critical)
2. Routine tests: add ActiveSim to 3 tests that passed trivially
3. Rename _observer_pos → observer_pos (used at line 191)
4. Add FactionOnly positive test case (matching faction_id)
5. Fix stale doc comment "Current: 9" → 10 in ObserverSnapshot
6. Remove orphaned SimulationTier/LastInteraction/ScopeTag types
7. Add tracing::warn on FactionOnly non-numeric parse failure
8. Document Medium-range occlusion gap as TODO in audible_at
9. Insert SoundEventQueue in observer test setup_world

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:08:32 +01:00
jpmschweitzerandClaude Sonnet 4.6 3c4bf742ea docs(visual): Sprint 12 — spatial layouts for three v0.1 locations (#311, #312, #313)
Tile-level floor plans with sightline analysis for all three Sova Transit
District locations. Unblocks copy team content packs #190, #191, #192.

- spatial-layout-terminal-v01.md: The Terminal (44×28m), scanner bays,
  main corridor chokepoint, manifest processing, cargo floor, supervisor
  office with window, restricted storage. Sightline table, access tiers,
  NPC traffic density, Triangle 1 + 2 service notes.
- spatial-layout-bar-v01.md: The Last Shift (28×22m + eastern extension).
  Corner booth sightline analysis (sees entrance, bar, card table, back
  room door simultaneously). Bathroom corridor as private exchange zone.
  Triangle 3 + 4 service notes.
- spatial-layout-smuggling-corridors-v01.md: Ring infrastructure overlay
  — restricted storage staging, maintenance corridor network, three
  dead-drop locations with discovery vectors, transition corridor (40m)
  with camera constraint and traffic density. Full operation flow diagram.

Also carries forward minor visual-grammar and placeholder-spec corrections
from earlier in the sprint (wall_top tile spec, cursor D-056 attribution,
transition strip procedural generation clarification).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 15:07:55 +01:00
jpmschweitzerandClaude Opus 4.6 e047218ad2 fix(ci): remove duplicate doc comment, add rng_seed serialization tests (#344, #527)
Review fixes: deduplicate dump_schedule_graph doc comment,
add rng_seed round-trip test and v9→v10 backward compat test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:05:56 +01:00
jpmschweitzerandClaude Opus 4.6 e1a1a9cfc2 fix(client): address PR review warnings — field guard + public API rename
- main.gd: add has("entity_id") guard to _play_recognition_chimes()
  (matches defensive pattern in _play_close_sound_events and update_sound_events)
- sound_indicator_renderer.gd: rename _color_for_type → color_for_type
  (public testable API, not an internal-only method)
- test_rendering.gd: update test calls to match rename

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:03:10 +01:00
jpmschweitzerandClaude Opus 4.6 f337bb3ca8 fix(client): correct 5 bugs found in pre-PR review
- sound_indicator_renderer: draw_colored_polygon → draw_polygon (runtime crash)
- sound_indicator_renderer: append+dedup instead of clear — indicators now
  survive 3.5s instead of dying after one server tick
- dialogue_box: wire Constants.DIALOGUE_MAX_WIDTH (640px) instead of
  hardcoded 832px MAX_WIDTH_PX
- dialogue_box.tscn: update default offsets to ±320 (was ±416)
- decisions/perception.md: fix footer typo (1920px → 640px)
- tests updated for new append/dedup indicator behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:59:50 +01:00
jpmschweitzerandClaude Opus 4.6 3d306b8a2b feat(ci): add tracing::instrument to heavy per-tick systems, fix stale protocol comment (#344, #527)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:55:00 +01:00
jpmschweitzerandClaude Sonnet 4.6 a71218f6bc feat(ci): tracing JSON format in CI, tick duration logging, schedule dump (#344, #346)
Tracing (#344):
- Add 'json' feature to tracing-subscriber dependency
- Emit JSON log format when CI=true or RUST_LOG_FORMAT=json is set
  (structured log ingestion in CI pipelines)
- Add tracing::debug! with tick_ms/budget_ms/over_budget fields on each
  tick for performance profiling and tier system debugging prerequisite

Schedule dump (#346):
- Add --dump-schedule CLI flag that prints bevy_ecs schedule graph and exits
  without requiring TCP bridge or world setup
- Add make debug-schedule target for CI artifact generation and diff-based
  regression detection of unintended system reordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:15:46 +01:00
jpmschweitzerandClaude Sonnet 4.6 6a7dc915de feat(bridge): add rng_seed to ObserverSnapshot for deterministic replay (#527)
Adds rng_seed: Option<u64> to ObserverSnapshot. The WRONG button (#507) captures
inputs.jsonl and seed.txt for replay, but seed.txt was writing "unavailable"
because the server did not include the RNG seed in ObserverSnapshot.

Changes:
- bridge/types.rs: PROTOCOL_VERSION 9→10, rng_seed field with serde(default,
  skip_serializing_if = "Option::is_none") for backward compatibility
- perception/observer/mod.rs: inject Res<SimRng> into compute_observer_snapshot,
  populate rng_seed: Some(rng.seed()) each tick
- All test files: add rng_seed: None to ObserverSnapshot constructors
- tests/serialization.rs: bump protocol_version_constant assertion 9→10
- Regenerate msgpack fixtures and golden file for protocol v10

Completes the WRONG button capture loop: replays can now fully reproduce
observed bugs with the exact RNG seed from the capture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:15:34 +01:00
jpmschweitzerandClaude Sonnet 4.6 3248603838 feat(simulation): ban HashMap via clippy disallowed_types, fix violations (#343)
Adds server/.clippy.toml with disallowed-types for std::collections::HashMap
and std::collections::HashSet. HashMap iteration order is non-deterministic
and breaks deterministic simulation replay (D-030).

Changes:
- server/.clippy.toml: disallow HashMap and HashSet crate-wide
- simulation/movement.rs: WalkabilityMap.chunks and occupied map → BTreeMap;
  add PartialOrd+Ord to ChunkCoord, TilePosition, TilePresence
- simulation/monologue.rs: MonologueState.shown_ids → BTreeSet (simulation state)
- perception/shadowcast.rs: #![allow] — per-frame FOV scratch, iteration irrelevant
- perception/interpretation.rs: #![allow] — per-frame lookup table, key-only access
- perception/query.rs: #![allow] — sector_lookup is a per-frame read-only cache

Also applies cargo fmt to pre-existing format drift in contraband.rs,
dialogue.rs, test_world/mod.rs, and several integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:14:07 +01:00
jpmschweitzerandClaude Sonnet 4.6 8e00db2b2d feat(simulation): #94 #99 active tier filtering and transition system
#94 — Active tier simulation (complete):
- Add ActiveSim marker to all 9 test world room NPC spawns
- Fix test entities in routine.rs and path_follow.rs to include ActiveSim
  so With<ActiveSim> queries match correctly in unit tests

#99 — Tier transition logic (complete):
- Implement update_tier_markers system in tier.rs
- Promotes/demotes tier markers by manhattan distance from PlayerCharacter:
  ≤40 tiles → ActiveSim, ≤120 → BackgroundSim, beyond → StateSaved
- Handles cross-z-level as u32::MAX (effectively unreachable)
- No-op when no PlayerCharacter entity present (headless tests safe)
- 11 new unit tests covering all distance bands and boundary cases
- TierPlugin now registers the system after movement::validate_movement

Also picks up extended test coverage added by hoshe:
- observer/tests.rs — 230 lines of perception observer tests
- sound.rs — additional sound event integration tests

All 548 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:54:37 +01:00
jpmschweitzerandClaude Sonnet 4.6 719721cd4c fix(client): apply arch review corrections — 640px dialogue width, D-067 chime
DIALOGUE_MAX_WIDTH: correct to 640px (20 × TILE_SIZE) per Tyre architecture
  review. D-076 updated in decisions/perception.md with amendment note.
  Initial 1920px was D-061 "max-width" but readability wins at 640px.

D-067 recognition chime: wire sfx_monologue_chime to fog entity recognition
  onset. AudioManager.CHIME_RECOGNITION constant added. main.gd tracks seen
  entity IDs in _known_recognition_ids; fires chime on first appearance in
  pending_recognitions, expires when entity leaves the queue. UISounds bus
  (not WorldSFX) per D-038 "monologue chime is a UI sound." Tests added
  to test_audio_bus_routing.gd (Layer 2b).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:35:27 +01:00
jpmschweitzerandClaude Opus 4.6 9ea9fd2a74 feat(engine): add rng_seed to ObserverSnapshot for deterministic replay (#527)
Add rng_seed: Option<u64> to ObserverSnapshot (protocol v10). Populated
from SimRng state each tick. Completes the WRONG button capture loop —
seed.txt now writes a valid u64 instead of "unavailable", enabling
deterministic replay from bug reports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:34:14 +01:00
jpmschweitzerandClaude Opus 4.6 0f5a990f82 feat(ci): ban HashMap in simulation crate via clippy (#343)
Add clippy::disallowed_types for std::collections::HashMap scoped to
the simulation crate. Replace HashMap with BTreeMap in movement.rs for
deterministic iteration order. Allow exception in perception/query.rs
where iteration order is irrelevant (per-frame scratch buffer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:34:04 +01:00
jpmschweitzerandClaude Opus 4.6 066f8031fd feat(simulation): sprint 12 server — tier system, sound events, KG access, line previewer
Implements 4 completed tickets + partial progress on 2 more:

- #93 Tier marker components (ActiveSim, BackgroundSim, StateSaved + TierPlugin)
- #138 Information tag schema (ObserverAccess enum in knowledge/types.rs)
- #124 Sound event system (SoundEventEmitter, SoundEventQueue, bridge wiring)
- #193 Line previewer CLI (line_preview binary with filter/explain/sequence modes)
- #94 Active tier simulation (in progress — With<ActiveSim> filters)
- #139 Component-level access control (in progress — filter_by_access)

Updates snapshot fixtures and test golden files for new sound_events field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:33:23 +01:00
jpmschweitzerandClaude Sonnet 4.6 c6bd7c2db2 feat(client): Sprint 12 — renderer fix, sound pipeline, medium-range indicators
#345: entity_renderer.gd already used entity_id; added regression tests
  confirming old "id" field is rejected and "entity_id" is accepted.

#447 (OQ-29): DIALOGUE_MAX_WIDTH = 1920 added to constants.gd. Full
  viewport width at target resolution (60 × TILE_SIZE), per D-061 Lead
  directive "max-width". Recorded as D-076 in decisions/perception.md.

#126: SoundIndicatorRenderer — fog-edge directional arrows for medium-range
  sound events (D-018). Node2D at z:951 in World scene. Color-coded per
  D-018/D-069 (neutral/voice/danger). GameState.medium_sound_events
  partitions Medium events from snapshot sound_events field. Tests added
  to test_rendering.gd; Hoshe's test_sound_indicators.gd stubs updated.

#125: Close-range stereo audio pipeline wired. AudioManager.play_sound_event()
  maps event_type to D-038 asset key (Footstep/FootstepSprint → sfx_footstep_*).
  GameState.close_sound_events partitions Close events. main.gd calls
  _play_close_sound_events() each snapshot tick. test_audio_bus_routing.gd
  Layer 4 stubs upgraded to real tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:33:05 +01:00
jpmschweitzerandClaude Sonnet 4.6 aa6541c745 docs(visual): Sprint 12 — visual grammar and placeholder art spec
Delivers #303 (v0.1 Visual Grammar Document) and #252 (Placeholder Art Spec).

#303 — visual-grammar-v01.md
- 7 sections: zone color palettes (hex values, Zone 1/2/3), entity sizing/proportions
  (24x32px art area in 64x64px canvas, dual-scale grid per D-066), entity color system
  (D-033 relationship colors, transition behavior, saturation hierarchy), z-level rendering
  stack (8 layers, y-sort rules, wall rendering, overhead occlusion), typography baseline
  (Michroma at 4 role sizes, dialogue layout per D-061), animation tier baseline (D-047
  Tier 1/Tier 2 with frame budgets), neural insert overlay (D-048 geometric/bloom spec,
  detective vs smuggler variants, fog interaction, insert-off behavior per D-056/D-057 OQ-07).
- Closes #304 (entity color system spec — covered in §3).
- Unblocks #311, #312, #313, #314, #315, #316, #317, #318, #334.

#252 — placeholder-art-spec-v01.md
- Tile dimensions: 64x64px visual tile, 32x32px sim tile, 1080p base resolution.
- Structural tile colors per zone, object tile spec (tinted rectangles + labels).
- Entity sprite spec: 24x32px art in 64x64px canvas, color-via-shader (no baked colors).
- Animation scope: static placeholder in v0.1, production frame budgets documented.
- File format (PNG with alpha), asset directory structure, naming convention.
- Lighting constraints: no baked lighting, Light2D pipeline does all atmosphere.
- Unblocks #133 (placeholder art pipeline).

Cross-references: D-033, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052,
D-056, D-057, D-059, D-061, D-066.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:28:17 +01:00
192 changed files with 30389 additions and 389 deletions
+7 -1
View File
@@ -54,7 +54,13 @@
"Skill(git-commit)",
"Skill(worktree-update)",
"Skill(sprint-start)"
"Skill(sprint-start)",
"Skill(sprint-plan)",
"Skill(pr-push)",
"Skill(pr-review)",
"Skill(ticket)",
"Skill(docs-search)",
"Skill(workshop-start)"
],
"deny": [
"Bash(git push --force *)",
+43 -1
View File
@@ -45,7 +45,38 @@ Then follow the **first matching case**:
### Case A: An active sprint exists
The active sprint needs to be closed before moving on.
First, check whether the sprint's work is actually done:
```bash
db/connectors/sprint status
```
This shows ticket counts by status (done, in_progress, backlog).
Also check for open PRs that may contain completed work waiting for
review or merge:
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
Report the full picture to the user:
- Ticket progress (done / in_progress / backlog counts)
- Open PRs (if any — these represent work that's done but not merged)
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
exist**, do NOT close the sprint. Instead, report the current progress
and ask the user what they want to do:
- **Continue working** — switch to a team branch and run `/sprint-start`
there to resume work
- **Review & merge PRs first** — (only if open PRs exist) merge pending
work before deciding whether to close
- **Close anyway** — proceed with the close workflow below (carries over
unfinished tickets)
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
explicitly chooses to close.
#### A1. Close the active sprint
@@ -257,6 +288,17 @@ Task(
prompt: "You are on the {team} team for Sprint {N}.
Branch: `{team}`
RULES:
- GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead.
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
db/connectors/ticket show 528
db/connectors/ticket list --sprint {N}
Do NOT prepend python3, do NOT chain with && or ;, do NOT
add cleanup commands. Just the bare command.
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
2. Read the decision files referenced in the briefing.
3. Check TaskList for available work.
+2 -4
View File
@@ -116,12 +116,10 @@ git merge origin/main --no-edit
If clean, report the result (fast-forward or merge commit, files changed).
If conflicts, report them and stop.
### 3. Push prompt
### 3. Push
After a successful merge, ask the user if they want to push:
After a successful merge, push the branch:
```bash
git push origin <current-branch>
```
Never push without explicit confirmation.
+104
View File
@@ -6,6 +6,110 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.14] — 2026-02-21
### Added
- Unified dialogue log — player-NPC and overheard NPC-NPC conversations in one chronological scrolling panel (#535, D-061/D-078)
- F3 debug overlay — real-time game state display with tick, FPS, position, entity counts, dialogue/monologue status (#511)
- Monologue display — multi-line priority queue with character colours, italic BBCode, stagger animation (#122)
- Protocol v9 — conversation_events, conversation_ended, dialogue_response fields with carry-forward logic
- Dialogue theme system — configurable NPC name colour palette, entry timing, passive opacity via dialogue-theme.yaml
- Monologue display system visual spec — typography, positioning, stacking, priority, fade animation, character color differentiation, 80-char line constraint (#315)
- Entity color system spec — D-033 relationship-to-player mapping, transition animations, color blindness assessment (#304)
- Text display hierarchy spec — 4 content pipelines (dialogue, monologue, observation, environmental) with z-layers and positioning (#316)
- Sound indicator visual design — fog-edge pulse for D-018 three-range sound model with direction encoding and range differentiation (#317)
- THE FRIEND visual treatment spec — 3-phase earned visual detail for Kael Davan and Sera Venn (#318)
- Environmental text visual standards — signage, terminal, and news ticker rendering with bilingual Concordat/Krenn treatment (#334)
- Tell visual/behavioral expression spec — 5 tell categories mapped to 6 Tier 2 behaviors (#251)
- Monologue line pool maxLength raised from 160 to 256 chars (soft guidance ≤160)
- NPC name masking infrastructure — entity-anchored dialogue log with server-side role labels, retroactive name update on learning, NpcColorIndex for stable color assignment
- Dialogue option keyboard selection (1/2/3 number keys) and numbered option labels
- Interaction list chrome — background panel, mouse hover highlighting, click-to-interact, pointing hand cursor
### Changed
- D-061 updated to document unified conversation log architecture from Sprint 14
- Dialogue options switched from RichTextLabel to Label for reliable VBoxContainer sizing
### Fixed
- Visual grammar dialogue max-width corrected from "~70% screen width" to 640px per D-076
- BBCode injection in dialogue log formatting — server-sourced strings now escaped with [lb]
- Per-frame dialogue log rebuild replaced with dirty flag (performance)
- dialogue_active lifecycle — now cleared after panel fade completes per D-064
- PAUSE/UNPAUSE routed through main.gd input recording for bug report replay (#507)
- WASD input freeze after filing bug report — LineEdit focus not released before queue_free() across CanvasLayers
- WASD not reactivating after Talk — dialogue_active held for entry_lifetime instead of cleared immediately
- Recognition chime spam — entity IDs now tracked permanently per room instead of expiring
- Audio path warning — res://audio/ corrected to res://assets/audio/ in AudioManager
- world_radial.tscn anchors_preset warning — changed from 15 to 0
- bug_report_dialog.gd push_warning changed to print for informational message
## [v0.1.13] — 2026-02-20
### Added
- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion
- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6)
## [v0.1.12] — 2026-02-19
### Added
- Tier marker components (#93), active tier simulation (#94), tier transition logic (#99)
- Information tag schema (#138), component-level access control (#139)
- Line previewer CLI (#193)
- Sound event system — server pipeline (#124)
- Close-range stereo audio — client positional 2D (#125)
- Medium-range visual indicators — fog-edge directional arrows (#126)
- HashMap ban in simulation crate via clippy (#343)
- Tracing crate infrastructure — JSON format, tick duration logging (#344)
- System dependency graph debug command — `--dump-schedule` CLI flag (#346)
- rng_seed field on ObserverSnapshot for deterministic replay (#527)
- v0.1 Visual Grammar Document (#303)
- Placeholder art specification (#252)
- Spatial layouts: Logistics Hub (#311), Bar (#312), Smuggling corridors (#313)
- Cultural generation guide — 5-dimension framework for Sova Transit District cultural voice (#189)
- Sova Texture Appendix — 20-term slang glossary, sensory profile, Meridian self-censorship rules (#302)
- Contraband specification — unlicensed lattice components, supply chain, street terminology (#321)
- Sova Station Profile — 6 districts, governance, off-station references (#320)
- Span Gate Transit Schedule — hourly schedule, maintenance windows, ring operational calendar (#336)
- Meridian Coverage Map — 10 named zones from Commission-grade to dead air (#335)
- Character definition schema and both character builds — smuggler + detective (#179, #180, #181)
- Divergent starting knowledge and relationships per character (#182, #183)
- Detective institutional chain of command (#322)
- Contradiction arc design document — reusable FRIEND pattern (#332)
- Mirror moment design document — 7 core dual-perspective observation triggers (#329)
- First 5 minutes experience design — systemic opening per character (#259)
- Opening hook content per character (#260)
- Knowledge vocabulary for v0.1 content — entity/world categories, prerequisite format (#368)
- Knowledge state vocabulary — author-facing quick reference (#309)
- Knowledge fact catalogs — 10 YAML files in content/global/knowledge/, 73 canonical facts
- D-075 endorsement — archetype dimension review recorded in decisions/content.md
- Flat NPC memorable trait pass — Pael, Ren, Tev with noise-floor profiles (#307)
- Environmental text content — 20 items across Terminal, Bar, and Corridors with dual-lens notes (#262)
- Diegetic insert flavor text — per-character labels and notification strings (#331)
- News ticker / Meridian feed — 30 lines including batch 44xx recall dual-lens moment (#306)
- Workplace content pack — The Terminal: 5 NPC dialogue files (#190)
- Bar content pack — The Last Shift: 3 NPC dialogue files (#191)
- Smuggling ring content pack — maintenance corridors: coded vocabulary, dual registers (#192)
- Generation pass expansion — 80 ambient variant lines across all 9 dialogue files (#194)
- Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual teams; full audio architecture + gauntlet expansion + monologue display spec
### Fixed
- Entity renderer field name bug — `id` vs `entity_id` (#345)
- Dialogue max-width pixel value — 640px per D-076 (#447)
- Routine tests missing ActiveSim — 3 of 5 tests passed trivially without the required tier marker
- `_observer_pos` misleading unused prefix renamed to `observer_pos` (used for sound event filtering)
- Stale protocol version doc comment "Current: 9" corrected to 10
- FactionOnly non-numeric `faction_id` attribute now logs a tracing::warn instead of silently denying
- SOUND_EVENT_ASSETS walk-speed key mismatch — `sfx_footstep_metal` corrected to `sfx_footstep_metal_walk`
### Changed
- Removed orphaned `SimulationTier`/`LastInteraction`/`ScopeTag`/`ScopeKind` types from tier.rs (unused outside own tests)
- Sound pipeline documented as intentionally empty in v0.1 (no producers yet, full pipeline wired)
- Observer test setup now inserts SoundEventQueue resource for integration coverage
- Added FactionOnly positive test case and Medium-range occlusion TODO
- Sound indicator colors sourced from Constants instead of duplicated hex literals
- play_loop() null guard on stream.duplicate()
- Camera zoom fallback uses Constants.CAMERA_DEFAULT_ZOOM
## [v0.1.11] — 2026-02-19
### Added
+7 -1
View File
@@ -35,7 +35,7 @@ db/
decisions/ # Decision domain files (source of truth)
README.md # Domain index and query examples
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
process.md # D-004, D-021, D-022
@@ -132,6 +132,12 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
Key rules:
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
- **Use `--output simple`** for machine-readable output (no table borders)
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
```bash
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
# Step 2: Post via cat
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
```
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
+8 -1
View File
@@ -7,7 +7,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate \
perf-baseline
perf-baseline debug-schedule
# --- Configuration ---
@@ -53,6 +53,7 @@ help:
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
@echo ""
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
@echo ""
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
@@ -285,6 +286,12 @@ checklist-generate:
perf-baseline:
@tooling/perf-baseline
# --- Schedule debug (#346) ---
debug-schedule:
@echo "Dumping bevy_ecs schedule graph..."
@cd server && cargo run -- --dump-schedule
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://caeuztw9ah5h5l"
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_npc_murmur.ogg"
dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+47
View File
@@ -0,0 +1,47 @@
# Dialogue Log Theme — The Settled Reach
#
# Ticket: #535 | Sprint: 14
# Colors and timing for the unified dialogue log panel.
# Loaded by dialogue_box.gd at runtime.
# ============================================================
# PLAYER COLOR
# Fixed color for the player's name in dialogue log entries.
# ============================================================
player_color: "#e0e8ff"
# ============================================================
# NPC COLOR PALETTE
# 8 distinct colors for NPC names. Indexed by hash(npc_name) % 8.
# Must be readable on a dark semi-transparent panel background.
# ============================================================
npc_colors:
0: "#4a9ebb" # teal
1: "#6bc9a6" # green
2: "#e8c547" # amber
3: "#d49e5d" # warm orange
4: "#b586d4" # lavender
5: "#d45d5d" # muted red
6: "#5daa7d" # forest
7: "#7daccc" # sky blue
# ============================================================
# TEXT COLORS
# Arrow separator and speech text color.
# ============================================================
arrow_color: "#8890a0"
speech_color: "#c8d0e0"
# ============================================================
# PASSIVE (OVERHEARD) OPACITY
# Base opacity multiplier for non-player-centric lines.
# 1.0 = full opacity, 0.0 = invisible.
# ============================================================
passive_opacity: 0.9
# ============================================================
# ENTRY TIMING
# All entry types share the same lifetime and fade duration.
# ============================================================
entry_lifetime_seconds: 15.0
entry_fade_seconds: 3.0
+43
View File
@@ -0,0 +1,43 @@
[gd_resource type="AudioBusLayout" format=3]
; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds.
; All buses route to Master. Volumes managed at runtime by AudioManager autoload.
; AudioManager._setup_buses() creates any missing buses on startup (no-op if present).
[resource]
bus/0/name = "Master"
bus/0/solo = false
bus/0/mute = false
bus/0/bypass_fx = false
bus/0/volume_db = 0.0
bus/0/send = &""
bus/1/name = "Music"
bus/1/solo = false
bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = &"Master"
bus/2/name = "Ambient"
bus/2/solo = false
bus/2/mute = false
bus/2/bypass_fx = false
bus/2/volume_db = 0.0
bus/2/send = &"Master"
bus/3/name = "WorldSFX"
bus/3/solo = false
bus/3/mute = false
bus/3/bypass_fx = false
bus/3/volume_db = 0.0
bus/3/send = &"Master"
bus/4/name = "PlayerActions"
bus/4/solo = false
bus/4/mute = false
bus/4/bypass_fx = false
bus/4/volume_db = 0.0
bus/4/send = &"Master"
bus/5/name = "UISounds"
bus/5/solo = false
bus/5/mute = false
bus/5/bypass_fx = false
bus/5/volume_db = 0.0
bus/5/send = &"Master"
+9
View File
@@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[audio]
buses/default_bus_layout="res://default_bus_layout.tres"
[gui]
theme/custom="res://assets/theme/game_theme.tres"
@@ -116,6 +120,11 @@ bug_report={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_overlay={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
teleport_hub={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+24 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
@@ -16,9 +16,12 @@
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
[ext_resource type="Script" path="res://scripts/rendering/sound_indicator_renderer.gd" id="20_soundind"]
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -96,6 +99,13 @@ script = ExtResource("4_fog")
z_index = 950
script = ExtResource("16_fogent")
; --- z:951 — Medium-range sound indicators (#126, D-018) ---
; Directional arrows at fog boundary for sounds outside LOS.
; Above FogEntities (z:950), below InsertOverlay (CanvasLayer 10).
[node name="SoundIndicators" type="Node2D" parent="World"]
z_index = 951
script = ExtResource("20_soundind")
; --- Camera ---
[node name="Camera2D" type="Camera2D" parent="."]
position_smoothing_enabled = true
@@ -143,6 +153,16 @@ layer = 20
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
; #511: F3 debug overlay — real-time game state, toggled by F3
[node name="DebugOverlay" type="Control" parent="UILayer"]
anchors_preset = 0
offset_left = 16
offset_top = 120
offset_right = 400
offset_bottom = 400
mouse_filter = 2
script = ExtResource("22_debug")
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
script = ExtResource("10_cursor")
@@ -154,3 +174,6 @@ layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
+129 -5
View File
@@ -5,6 +5,11 @@ extends Node
## No-op fallback when audio assets absent (D-038).
## Spatial audio positioning for close-range sounds (D-018).
# --- D-067: Recognition chime asset key ---
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
const CHIME_RECOGNITION := "sfx_monologue_chime"
# --- Bus names (D-068) ---
const BUS_MUSIC := "Music"
const BUS_AMBIENT := "Ambient"
@@ -36,6 +41,23 @@ const DIP_SPECS := {
# Low-pass filter default cutoff — effectively bypassed at this value.
const FILTER_CUTOFF_DEFAULT := 20500.0
# --- D-073: Zone crossfade constants ---
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
# they are the same location type, so hub→workplace transition is a same-asset no-op
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
const ZONE_ASSETS: Dictionary = {
"hub": "amb_hub_layer",
"workplace": "amb_hub_layer",
"bar": "amb_bar_layer",
"corridor": "amb_corridor_layer",
}
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
var _registry: Dictionary = {}
@@ -52,12 +74,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
var _ambient_players: Dictionary = {}
# D-073: Zone crossfade state
var _current_zone_id: String = ""
var _zone_tweens: Array = []
signal dip_changed(profile: String)
const PREFS_PATH := "user://audio_prefs.cfg"
func _ready() -> void:
_setup_buses()
_scan_registry()
_load_prefs()
# --- Bus setup ---
@@ -105,6 +134,7 @@ func _scan_dir(path: String) -> void:
if stream:
_registry[file_name.get_basename()] = stream
file_name = dir.get_next()
dir.list_dir_end()
func has_asset(asset_key: String) -> bool:
@@ -137,6 +167,8 @@ func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlaye
return null
stop_loop(asset_key)
var loop_stream := stream.duplicate() as AudioStream
if loop_stream == null:
return null
_enable_loop(loop_stream)
var player := AudioStreamPlayer.new()
player.stream = loop_stream
@@ -163,6 +195,32 @@ func stop_all_loops() -> void:
stop_loop(key)
# --- Audio asset registry: event type → asset key (D-018, #125) ---
# Maps server-sent sound event_type strings to audio asset keys.
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
# Missing assets no-op gracefully (D-038 fallback pattern).
const SOUND_EVENT_ASSETS: Dictionary = {
"Footstep": "sfx_footstep_metal_walk",
"FootstepWalk": "sfx_footstep_metal_walk",
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepSprint": "sfx_footstep_metal_run",
"FootstepRun": "sfx_footstep_metal_run",
}
## Play a close-range sound event at a world tile position (D-018, #125).
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
## world_tile_pos: server tile coordinates — converted to world pixels internally.
## No-ops if event_type has no registered asset or asset file is absent.
func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
if asset_key.is_empty():
return
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
# --- Playback: spatial (D-018 close-range) ---
## Play a one-shot spatial sound at a world position (pixels).
@@ -270,19 +328,85 @@ func set_volume(bus: String, volume_db: float) -> void:
var buses: Dictionary = spec.get("buses", {})
var offset_db: float = buses.get(bus, 0.0)
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
_save_prefs()
func get_volume(bus: String) -> float:
return _bus_volumes.get(bus, 0.0)
# --- Zone crossfade (D-073 stub) ---
# --- Volume persistence (user://audio_prefs.cfg) ---
func _load_prefs() -> void:
var cfg := ConfigFile.new()
if cfg.load(PREFS_PATH) != OK:
return
for bus_name in BUSES:
if cfg.has_section_key("audio", bus_name):
var db: float = cfg.get_value("audio", bus_name, 0.0)
# Apply directly: bypass _save_prefs() on initial load.
_bus_volumes[bus_name] = db
var idx := AudioServer.get_bus_index(bus_name)
if idx >= 0:
AudioServer.set_bus_volume_db(idx, db)
func _save_prefs() -> void:
var cfg := ConfigFile.new()
for bus_name in BUSES:
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
var err := cfg.save(PREFS_PATH)
if err != OK:
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
# --- Zone crossfade (D-073) ---
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
## Full crossfade implementation deferred to Sprint 9+ (D-073).
## Stub exists so server integration can call it without conditional checks.
func set_zone(_zone_id: String) -> void:
pass
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
## Interruptible — mid-crossfade zone change tweens from current position.
## No-op if assets absent (D-038) or same zone.
func set_zone(zone_id: String) -> void:
if zone_id == _current_zone_id:
return
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
_current_zone_id = zone_id
_kill_zone_tweens()
# Fade out old ambient layer (if different asset from incoming zone)
if not old_asset.is_empty() and old_asset != new_asset:
if _ambient_players.has(old_asset):
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
if is_instance_valid(old_player):
var tween := create_tween()
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
tween.tween_callback(stop_loop.bind(old_asset))
_zone_tweens.append(tween)
# Fade in new ambient layer
if not new_asset.is_empty():
if _ambient_players.has(new_asset):
# Already playing (interrupted reverse crossfade) — tween from current volume
var existing: AudioStreamPlayer = _ambient_players[new_asset]
if is_instance_valid(existing):
var tween := create_tween()
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
elif has_asset(new_asset):
var new_player := play_loop(new_asset, BUS_AMBIENT)
if new_player:
new_player.volume_db = -80.0
var tween := create_tween()
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
func _kill_zone_tweens() -> void:
for tween in _zone_tweens:
if tween != null and tween.is_valid():
tween.kill()
_zone_tweens.clear()
# --- Internal helpers ---
+91 -1
View File
@@ -23,7 +23,12 @@ var player_entity_id: int = 1
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
# v5 fields (#414)
var current_monologue: Variant = null # {id, text, duration_seconds} or null
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
# Server sends this field as part of the player's capability snapshot.
var lattice_profile: String = "lattice_baseline"
# v6 fields (#449, D-053, D-065)
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
@@ -54,6 +59,32 @@ var rng_seed: Variant = null
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
# v8 fields (#305, D-028): NPC follow-up after player dialogue choice
var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
# v9 fields (#535, D-078): Overheard NPC-to-NPC conversations
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
var conversation_ended: Array = [] # [{speaker_id, target_id}]
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
var medium_sound_events: Array = []
# #125, D-018: Close-range sound events for positional 2D audio.
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
var close_sound_events: Array = []
# D-071 (#530): Consecutive ticks without player position change.
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -75,6 +106,14 @@ func apply_snapshot(snapshot: Dictionary) -> void:
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
# Compares current player_position against previous snapshot's position.
if player_position == _prev_player_position:
stationary_ticks += 1
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
if snapshot.has("tiles"):
visible_tiles = snapshot.tiles
@@ -111,6 +150,10 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
current_monologue = null
# #122: lattice_profile — character insert capability level for monologue colour
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
lattice_profile = snapshot.lattice_profile
# v6: player_stance (#449, D-053)
if snapshot.has("player_stance") and snapshot.player_stance is String:
player_stance = snapshot.player_stance
@@ -133,6 +176,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
pending_recognitions = []
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
conversation_events = snapshot.conversation_events
else:
conversation_events = []
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
conversation_ended = snapshot.conversation_ended
else:
conversation_ended = []
# v8: dialogue_response (#305, D-028) — NPC follow-up after player choice
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
dialogue_response = snapshot.dialogue_response
else:
dialogue_response = null
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
gauntlet_mode = true
@@ -157,6 +218,35 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
rng_seed = null
# D-018: Sound events from server — partition by range_category.
# #126: Medium → fog-edge directional indicators.
# #125: Close → positional 2D audio via AudioManager.
if snapshot.has("sound_events") and snapshot.sound_events is Array:
medium_sound_events = []
close_sound_events = []
for se in snapshot.sound_events:
if not se is Dictionary:
continue
var rc: String = se.get("range_category", "")
if rc == "Medium":
medium_sound_events.append(se)
elif rc == "Close":
close_sound_events.append(se)
else:
medium_sound_events = []
close_sound_events = []
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
# positional indexing — use the same tile iteration below instead.
current_zone_id = ""
var _px := int(player_position.x)
var _py := int(player_position.y)
for _ztile in visible_tiles:
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
current_zone_id = _ztile.get("zone_id", "")
break
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+44
View File
@@ -241,6 +241,17 @@ func receive_bytes(bytes: PackedByteArray) -> void:
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null:
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
if old_conv_events.size() > 0:
var new_conv_events: Array = snapshot.get("conversation_events", [])
snapshot["conversation_events"] = old_conv_events + new_conv_events
var old_conv_ended: Array = _last_snapshot.get("conversation_ended", [])
if old_conv_ended.size() > 0:
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
_last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding.
@@ -396,6 +407,37 @@ func _test_snapshot() -> Dictionary:
"total_delay_ticks": total_delay,
})
# #535: Mock overheard NPC-NPC conversation (D-078)
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
# Conversation ends after 6 exchanges (~30 ticks).
var conv_events: Array = []
var conv_ended: Array = []
var conv_start := 3
var conv_lines := [
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
]
var conv_tick_interval := 5
var conv_total_ticks := conv_lines.size() * conv_tick_interval
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
var conv_index := (_test_tick - conv_start) / conv_tick_interval
var within_tick := (_test_tick - conv_start) % conv_tick_interval
if within_tick == 0 and conv_index < conv_lines.size():
var cl: Dictionary = conv_lines[conv_index]
conv_events.append({
"speaker_id": 10,
"target_id": 11,
"speaker_name": cl.speaker,
"target_name": cl.target,
"occluded_line": cl.line,
})
elif _test_tick == conv_start + conv_total_ticks:
conv_ended.append({"speaker_id": 10, "target_id": 11})
return {
"tick": _test_tick,
"version": Protocol.PROTOCOL_VERSION,
@@ -417,6 +459,8 @@ func _test_snapshot() -> Dictionary:
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": _test_gauntlet_mode,
"conversation_events": conv_events,
"conversation_ended": conv_ended,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
+9
View File
@@ -90,6 +90,15 @@ const PERIPHERAL_ALPHA: float = 0.5
const FACING_INDICATOR_SIZE: float = 6.0
const FACING_INDICATOR_OFFSET: float = 14.0
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
# Tyre architecture review 2026-02-19: readability over max-width; fits
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 640
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
# #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
+160 -4
View File
@@ -14,15 +14,23 @@ extends Node2D
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -54,6 +62,8 @@ func _ready() -> void:
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
@@ -113,6 +123,9 @@ func _process(_delta: float) -> void:
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
_play_recognition_chimes()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
@@ -121,12 +134,31 @@ func _process(_delta: float) -> void:
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
# D-018 #125: Play close-range sound events via positional 2D audio
_play_close_sound_events()
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
_update_zone()
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
# D-061: Show dialogue if server sent one this tick (#434)
_consume_dialogue()
# #535: Consume overheard conversation events and responses
_consume_conversation_events()
_consume_conversation_ended()
_consume_dialogue_response()
# Track camera to player position every frame (D-015: locked, no panning)
if _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
@@ -155,6 +187,14 @@ func _process(_delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
if settings_dialog.is_open():
settings_dialog.close()
else:
settings_dialog.open()
continue
if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
@@ -191,6 +231,67 @@ func _process(_delta: float) -> void:
_pending_record_inputs.clear()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
# Consume-once: events are cleared after processing so they don't replay if
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
func _play_close_sound_events() -> void:
for evt in GameState.close_sound_events:
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
continue
AudioManager.play_sound_event(
evt.get("event_type", ""),
Vector2(float(evt.x), float(evt.y))
)
GameState.close_sound_events = []
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
# enters the cognitive delay recognition queue for the first time.
# "The chime marks the character's attention shifting" (D-067).
# Entities that complete recognition (leave pending_recognitions) are removed
# from _known_recognition_ids so they can chime again if re-encountered.
func _play_recognition_chimes() -> void:
var active_ids: Dictionary = {}
for rec in GameState.pending_recognitions:
if not rec is Dictionary or not rec.has("entity_id"):
continue
var eid: int = rec.entity_id
active_ids[eid] = true
if not _known_recognition_ids.has(eid):
_known_recognition_ids[eid] = true
AudioManager.play(AudioManager.CHIME_RECOGNITION)
# Expire IDs no longer in the recognition queue
for eid in _known_recognition_ids.keys():
if not active_ids.has(eid):
_known_recognition_ids.erase(eid)
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
# (extracted in apply_snapshot(), server-authoritative per D-020).
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
func _update_zone() -> void:
var zone := GameState.current_zone_id
if zone != _current_zone:
_current_zone = zone
AudioManager.set_zone(zone)
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
# Only activates when no other dip (dialogue/confrontation) is running.
# Only deactivates its own dip — never touches dialogue/confrontation.
# D-070: no UI indicator — the boost is "felt, not computed."
func _update_listening_focus() -> void:
var current_dip := AudioManager.get_active_dip()
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
if threshold_met and current_dip == "":
AudioManager.apply_dip("listening_focus")
elif not threshold_met and current_dip == "listening_focus":
AudioManager.clear_dip()
# Consume-once per tick: show monologue text, then clear.
# Tick guard prevents re-triggering when the same tick is polled multiple
# times (client FPS > sim tick rate).
@@ -201,7 +302,12 @@ func _consume_monologue() -> void:
return
_last_monologue_tick = GameState.current_tick
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
monologue_display.show_monologue(
mono.get("text", ""),
mono.get("duration_seconds", 5.0),
mono.get("priority", 2),
mono.get("is_urgent", false)
)
# #502: Amber flash on room reset
var mono_id: String = mono.get("id", "")
if mono_id.begins_with("room_reset"):
@@ -209,6 +315,7 @@ func _consume_monologue() -> void:
GameState.current_monologue = null
# Consume-once per tick with ID tracking: show dialogue, then clear.
# Tick guard + is_dialogue_active check prevent re-triggering.
func _consume_dialogue() -> void:
@@ -222,6 +329,7 @@ func _consume_dialogue() -> void:
_last_dialogue_tick = GameState.current_tick
var dlg: Dictionary = GameState.current_dialogue
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
_last_dialogue_npc_name = dlg.get("npc_name", "")
dialogue_box.show_dialogue(
dlg.get("npc_name", ""),
dlg.get("speech", ""),
@@ -230,6 +338,34 @@ func _consume_dialogue() -> void:
GameState.current_dialogue = null
# #535: Consume overheard NPC-NPC conversation events (D-078).
# Each event carries pre-occluded text — render verbatim in the dialogue log.
func _consume_conversation_events() -> void:
if not dialogue_box:
return
for event in GameState.conversation_events:
dialogue_box.append_conversation_event(event)
GameState.conversation_events = []
# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs.
func _consume_conversation_ended() -> void:
if not dialogue_box:
return
for event in GameState.conversation_ended:
dialogue_box.on_conversation_ended(event)
GameState.conversation_ended = []
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
func _consume_dialogue_response() -> void:
if GameState.dialogue_response == null or not dialogue_box:
return
var dr: Dictionary = GameState.dialogue_response
dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", ""))
GameState.dialogue_response = null
# D-061: Handle dialogue option selection → send to server
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
SimBridge.send_input({
@@ -244,9 +380,29 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void:
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
func _on_confrontation_monologue(text: String, duration: float) -> void:
if monologue_display:
monologue_display.show_monologue(text, duration)
if not monologue_display:
return
if GameState.current_tick == _last_confrontation_tick:
return
_last_confrontation_tick = GameState.current_tick
monologue_display.show_monologue(text, duration, 3, true)
# D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3)
func _on_dialogue_pause_requested() -> void:
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3)
func _on_dialogue_unpause_requested() -> void:
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# D-064: Handle walk-away → send WalkAway{npc_id} to server
+29 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 8
const PROTOCOL_VERSION: int = 9
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -180,6 +180,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
}
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines.
# Each event carries pre-occluded text plus speaker/target attribution.
var conversation_events: Array = []
var raw_conv_events: Variant = raw.get("conversation_events")
if raw_conv_events is Array:
for raw_ce in raw_conv_events:
if raw_ce is Dictionary and raw_ce.has("occluded_line"):
conversation_events.append({
"speaker_id": int(raw_ce.get("speaker_id", 0)),
"target_id": int(raw_ce.get("target_id", 0)),
"speaker_name": str(raw_ce.get("speaker_name", "")),
"target_name": str(raw_ce.get("target_name", "")),
"occluded_line": str(raw_ce["occluded_line"]),
})
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
var conversation_ended: Array = []
var raw_conv_ended: Variant = raw.get("conversation_ended")
if raw_conv_ended is Array:
for raw_end in raw_conv_ended:
if raw_end is Dictionary:
conversation_ended.append({
"speaker_id": int(raw_end.get("speaker_id", 0)),
"target_id": int(raw_end.get("target_id", 0)),
})
return {
"tick": tick,
"entities": entities,
@@ -195,6 +221,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"current_dialogue": current_dialogue,
"dialogue_response": dialogue_response,
"pending_recognitions": pending_recognitions,
"conversation_events": conversation_events,
"conversation_ended": conversation_ended,
}
@@ -0,0 +1,152 @@
class_name SoundIndicatorRenderer
extends Node2D
## Medium-range sound indicators (#126, D-018).
## Renders directional arrows at the fog boundary for sounds outside LOS.
##
## Close-range sounds → 2D positional audio (handled by AudioManager).
## Medium-range sounds → visual arrow at fog edge pointing toward source.
##
## Color per D-018/D-069:
## Neutral #c8d0e0 — footsteps, generic sounds
## Voice #e8c547 — speech, conversation, social sounds
## Danger #d45d5d — alert, gunshot, explosion, threat
##
## Each indicator lives for INDICATOR_LIFETIME seconds and fades out over
## the last FADE_DURATION seconds. New events are appended each tick;
## expired indicators are removed by _process(). Deduplication prevents
## the same source position from stacking multiple arrows.
const TILE_SIZE: int = Constants.TILE_SIZE
# Visual parameters
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
const FADE_DURATION: float = 0.6 # Fade-out window at end
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
const ARROW_LEN: float = 12.0 # Length from tip to base
# D-018/D-069 colors — sourced from Constants to prevent palette drift
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
# Indicators: [{x, y, event_type, elapsed}]
var _indicators: Array = []
func _process(delta: float) -> void:
if _indicators.is_empty():
return
var i := _indicators.size() - 1
while i >= 0:
_indicators[i].elapsed += delta
if _indicators[i].elapsed >= INDICATOR_LIFETIME:
_indicators.remove_at(i)
i -= 1
queue_redraw()
## Append new medium-range sound events from snapshot.
## events: Array of {x: float, y: float, event_type: String}
## Only Medium range_category events should be passed.
## Deduplicates by tile position — if an indicator already exists at (x,y),
## its timer resets instead of spawning a duplicate arrow.
func update_sound_events(events: Array) -> void:
for evt in events:
if not evt.has("x") or not evt.has("y"):
continue
var ex: float = float(evt.x)
var ey: float = float(evt.y)
# Deduplicate: reset timer if an indicator already exists at this tile
var found := false
for ind in _indicators:
if is_equal_approx(ind.x, ex) and is_equal_approx(ind.y, ey):
ind.elapsed = 0.0
ind.event_type = evt.get("event_type", "")
found = true
break
if not found:
_indicators.append({
"x": ex,
"y": ey,
"event_type": evt.get("event_type", ""),
"elapsed": 0.0,
})
if not _indicators.is_empty():
queue_redraw()
func _draw() -> void:
if _indicators.is_empty():
return
var player_world: Vector2 = GameState.player_position * TILE_SIZE
# Compute half-extents of the visible world area from camera zoom + viewport.
# Arrows are placed at this boundary minus EDGE_INSET so they sit just inside
# the fog edge and don't clip to the physical screen border.
var vp_size := get_viewport().get_visible_rect().size
var cam := get_viewport().get_camera_2d()
var zoom := cam.zoom if cam else Constants.CAMERA_DEFAULT_ZOOM
var half_extents: Vector2 = vp_size / (2.0 * zoom)
for ind in _indicators:
var sound_world: Vector2 = Vector2(ind.x, ind.y) * TILE_SIZE
var dir: Vector2 = sound_world - player_world
if dir.is_zero_approx():
continue
dir = dir.normalized()
# Project direction onto the visible-area rectangle boundary
var edge_pt: Vector2 = _rect_edge_point(player_world, dir, half_extents, EDGE_INSET)
# Alpha: full for most of lifetime, fade out over last FADE_DURATION seconds
var t: float = ind.elapsed / INDICATOR_LIFETIME
var fade_start: float = 1.0 - FADE_DURATION / INDICATOR_LIFETIME
var alpha: float
if t < fade_start:
alpha = 1.0
else:
alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME))
var color: Color = color_for_type(ind.event_type)
color.a = alpha * 0.9
_draw_arrow(edge_pt, dir, color)
## Project from center along dir to the boundary of a rectangle with
## half_extents, inset by inset pixels. Returns the boundary point.
func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float) -> Vector2:
var h := Vector2(
maxf(half.x - inset, 8.0),
maxf(half.y - inset, 8.0),
)
# Ray-AABB slab test: find smallest positive t where the ray exits
var t_x: float = INF if abs(dir.x) < 1e-6 else abs(h.x / dir.x)
var t_y: float = INF if abs(dir.y) < 1e-6 else abs(h.y / dir.y)
var t: float = minf(t_x, t_y)
return center + dir * t
## Draw a filled arrowhead at pos pointing in dir.
## The tip is at pos; the base is ARROW_LEN pixels behind along -dir.
func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
var perp := Vector2(-dir.y, dir.x)
var base_center := pos - dir * ARROW_LEN
draw_polygon(
PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
PackedColorArray([color, color, color])
)
## Map event type string → D-018 color category.
func color_for_type(event_type: String) -> Color:
var et := event_type.to_lower()
if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"):
return COLOR_VOICE
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \
or et.contains("alert") or et.contains("threat"):
return COLOR_DANGER
return COLOR_NEUTRAL
@@ -16,6 +16,7 @@ extends Node2D
@onready var tile_renderer = $FogGroup/FloorTiles
@onready var fog_renderer = $FogOverlay
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
var _last_tick: int = -1
@@ -42,3 +43,7 @@ func update_from_state() -> void:
# Update entity sprites
if entity_renderer and entity_renderer.has_method("update_entities"):
entity_renderer.update_entities(GameState.visible_entities)
# D-018 #126: Update medium-range sound indicators
if sound_indicator_renderer and sound_indicator_renderer.has_method("update_sound_events"):
sound_indicator_renderer.update_sound_events(GameState.medium_sound_events)
+115
View File
@@ -0,0 +1,115 @@
extends Control
# #511: F3 debug overlay — real-time game state display for dev use.
const HEADER_COLOR := Color("#e8c547")
const LABEL_COLOR := Color("#8890a0")
const VALUE_COLOR := Color("#c8d0e0")
const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
const FONT_SIZE := 12
const LINE_HEIGHT := 16
const PADDING := Vector2(10, 8)
const COL_GAP := 16 # gap between left and right columns
var _cached_font: Font = null
func _ready() -> void:
visible = false
_cached_font = ThemeDB.fallback_font
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("debug_overlay"):
visible = not visible
if visible:
queue_redraw()
func update_from_state() -> void:
if not visible:
return
queue_redraw()
func _draw() -> void:
if not visible:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
# Build lines as [label, value, label, value] pairs (two columns)
var left_lines: Array = []
var right_lines: Array = []
left_lines.append(["tick", str(GameState.current_tick)])
right_lines.append(["fps", str(Engine.get_frames_per_second())])
var pos := GameState.player_position
left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]])
right_lines.append(["facing", GameState.player_facing])
left_lines.append(["stance", GameState.player_stance])
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
left_lines.append(["entities", str(GameState.visible_entities.size())])
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
left_lines.append(["interactions", str(GameState.nearby_interactions.size())])
right_lines.append(["recognitions", str(GameState.pending_recognitions.size())])
var mono_status := "active" if GameState.current_monologue != null else "idle"
var dlg_status := "active" if GameState.dialogue_active else "idle"
left_lines.append(["monologue", mono_status])
right_lines.append(["dialogue", dlg_status])
left_lines.append(["stationary", str(GameState.stationary_ticks)])
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
var gt := GameState.game_time
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
left_lines.append(["time", time_str])
right_lines.append(["tick_rate", rate_str])
var mode_str := "test" if SimBridge.test_mode else "live"
var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF"
left_lines.append(["mode", mode_str])
right_lines.append(["gauntlet", gauntlet_str])
# Measure column widths
var left_label_w: float = 0.0
var left_value_w: float = 0.0
var right_label_w: float = 0.0
var right_value_w: float = 0.0
for line in left_lines:
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
for line in right_lines:
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
var header_text := "F3 DEBUG"
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count := max(left_lines.size(), right_lines.size())
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
y += LINE_HEIGHT
# Data lines (two columns)
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count):
if i < left_lines.size():
var lbl: String = left_lines[i][0] + ": "
var val: String = left_lines[i][1]
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
var lbl: String = right_lines[i][0] + ": "
var val: String = right_lines[i][1]
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
y += LINE_HEIGHT
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+357
View File
@@ -0,0 +1,357 @@
## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069)
## Spec refs:
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds)
## D-069: Audio dip profiles — confrontation drops WorldSFX 46dB
## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio
##
## Test layers:
## 1. D-068 Bus architecture constants (no implementation required)
## 2. D-069 Dip profile spec values (no implementation required)
## 3. AudioManager API — dip state machine, signal emission
## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events
class_name TestAudioBusRouting
extends GdUnitTestSuite
func before_test() -> void:
AudioManager.clear_dip()
for bus in AudioManager.BUSES:
AudioManager.set_volume(bus, 0.0)
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
func after_test() -> void:
AudioManager.clear_dip()
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
# ==============================================================================
# Layer 1: D-068 Bus Architecture Constants
# ==============================================================================
func test_d068_bus_world_sfx_name() -> void:
## D-068: WorldSFX bus name must match exactly — used by all sound event routing.
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
func test_d068_bus_ambient_name() -> void:
assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient")
func test_d068_bus_player_actions_name() -> void:
assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions")
func test_d068_bus_ui_sounds_name() -> void:
assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds")
func test_d068_bus_music_name() -> void:
assert_that(AudioManager.BUS_MUSIC).is_equal("Music")
func test_d068_five_buses_defined() -> void:
## D-068: Exactly 5 buses in the architecture.
assert_that(AudioManager.BUSES.size()).is_equal(5)
func test_d068_all_bus_names_present_in_buses_array() -> void:
## D-068: BUSES array must contain all 5 named buses.
assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true()
func test_d068_bus_names_are_unique() -> void:
## D-068: No two buses share a name.
var seen: Dictionary = {}
for bus_name in AudioManager.BUSES:
assert_that(seen.has(bus_name)).is_false()
seen[bus_name] = true
# ==============================================================================
# Layer 2: D-069 Dip Profile Spec Values
# Close-range sound events go on WorldSFX; confrontation dips that bus 46 dB.
# ==============================================================================
func test_d069_three_dip_profiles_exist() -> void:
## D-069: Three profiles — dialogue, confrontation, listening_focus.
assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true()
assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true()
assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true()
func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void:
## D-069: Confrontation must drop WorldSFX by 46 dB. Sprint 12 uses -5.0 dB.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
var dip_db: float = buses["WorldSFX"]
assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true()
func test_d069_confrontation_dips_ambient() -> void:
## D-069: Confrontation suppresses both Ambient and WorldSFX.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("Ambient")).is_true()
func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void:
## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz.
var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"]
assert_that(spec.has("filter_hz")).is_true()
assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0)
func test_d069_dialogue_dips_ambient_not_world_sfx() -> void:
## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed.
## Confrontation is the one that mutes world SFX.
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
assert_that(buses.has("Ambient")).is_true()
assert_that(buses.has("WorldSFX")).is_false()
func test_d069_listening_focus_boosts_world_sfx() -> void:
## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping.
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
assert_that(buses["WorldSFX"] > 0.0).is_true()
func test_d069_all_profiles_have_positive_ease_times() -> void:
## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions).
for profile in AudioManager.DIP_SPECS.keys():
var spec: Dictionary = AudioManager.DIP_SPECS[profile]
assert_that(spec.has("ease_in")).is_true()
assert_that(spec.has("ease_out")).is_true()
assert_that(spec["ease_in"] > 0.0).is_true()
assert_that(spec["ease_out"] > 0.0).is_true()
func test_d069_confrontation_ease_out_longer_than_dialogue() -> void:
## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive.
var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
assert_that(conf_out >= dial_out).is_true()
# ==============================================================================
# Layer 2b: D-067 Recognition Chime
# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX).
# ==============================================================================
func test_d067_chime_recognition_constant_exists() -> void:
## D-067: AudioManager must expose a CHIME_RECOGNITION constant.
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void:
## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key).
## "Neural lattice firing" feel — soft crystalline tone.
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void:
## D-067/D-038: Monologue chime is a UI sound, not a simulation sound.
## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX.
## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS.
assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true()
## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS.
## No further assertion needed — play() default bus IS UISounds by design.
func test_d067_chime_noop_when_asset_absent() -> void:
## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry,
## play(CHIME_RECOGNITION) must be a no-op (no crash).
AudioManager.play(AudioManager.CHIME_RECOGNITION)
## No assertion — absence of crash is the test.
# ==============================================================================
# Layer 3: AudioManager API — Dip State Machine
# ==============================================================================
func test_audio_manager_initial_state_no_active_dip() -> void:
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_sets_active() -> void:
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
func test_audio_manager_clear_dip_resets_active() -> void:
AudioManager.apply_dip("confrontation")
AudioManager.clear_dip()
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
## Unknown profile: push_warning + early return, active dip stays empty.
AudioManager.apply_dip("not_a_real_profile")
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
## apply_dip() must emit dip_changed(profile) synchronously.
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
## so a mutable reference type is required to observe signal argument inside the closure.
var received := [""]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("dialogue")
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("dialogue")
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
## clear_dip() must emit dip_changed("") to signal audio restored.
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
AudioManager.apply_dip("dialogue")
var received := ["sentinel"]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.clear_dip()
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("")
func test_audio_manager_apply_dip_interrupts_previous() -> void:
## Switching profiles mid-dip: active profile must update to the new one.
AudioManager.apply_dip("dialogue")
AudioManager.apply_dip("confrontation")
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
## Switching from dialogue to confrontation emits dip_changed("confrontation").
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
AudioManager.apply_dip("dialogue")
var received := [""]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("confrontation")
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("confrontation")
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
## has_asset() must return false for a key that was never registered.
## Ensures no-op fallback path (D-038) is correctly guarded.
assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false()
# ==============================================================================
# Layer 4: D-018 Snapshot Integration — #125 implemented
# GameState partitions sound_events by range_category into close_sound_events
# and medium_sound_events. Close events are played via AudioManager.play_sound_event().
# ==============================================================================
func test_snapshot_close_events_stored_in_close_sound_events() -> void:
## #125: Close-range events must land in GameState.close_sound_events.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep")
func test_snapshot_medium_events_stored_in_medium_sound_events() -> void:
## #126/#125: Medium-range events land in medium_sound_events, not close.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(0)
func test_snapshot_partitions_close_and_medium_events() -> void:
## D-018: Mixed batch — Close and Medium events partitioned correctly.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events.size()).is_equal(1)
func test_snapshot_empty_sound_events_clears_both_arrays() -> void:
## When snapshot has no sound_events, both close and medium arrays are cleared.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.close_sound_events.size()).is_equal(0)
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# ==============================================================================
# Layer 5: AudioManager.play_sound_event — asset registry and routing
# ==============================================================================
func test_audio_manager_sound_event_assets_registry_exists() -> void:
## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary.
assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0)
func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void:
## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk")
func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void:
## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run")
func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void:
## #125 / D-038: Unknown event types must be silently skipped (no-op).
## play_sound_event should not crash and should not emit sound.
AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
## #125: Empty event type string must be a no-op.
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
# ==============================================================================
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
# ==============================================================================
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
func test_d071_stationary_ticks_resets_on_movement() -> void:
## D-071: stationary_ticks must reset to 0 when the player position changes.
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
assert_that(GameState.stationary_ticks).is_equal(1)
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
assert_that(GameState.stationary_ticks).is_equal(0)
func test_d071_stationary_ticks_reaches_threshold() -> void:
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
for tick in range(31):
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
assert_that(GameState.stationary_ticks >= 30).is_true()
# ==============================================================================
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
# ==============================================================================
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
AudioManager.apply_dip("confrontation")
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
func test_d069_listening_focus_cleared_by_dialogue() -> void:
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
AudioManager.apply_dip("listening_focus")
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
func test_d069_clear_dip_noop_when_empty() -> void:
## D-069: clear_dip() when no dip active must be a safe no-op.
AudioManager.clear_dip() # Should not crash
assert_that(AudioManager.get_active_dip()).is_equal("")
+634
View File
@@ -0,0 +1,634 @@
## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073).
##
## Spec refs:
## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion.
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds).
## D-069: Audio dip profiles — timing values, dB offsets, interruptibility.
## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility).
## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants.
## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible.
##
## Tickets covered:
## #529 — Zone crossfade implementation (set_zone body + tween timing)
## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring)
## #531 — Recognition chime fires at cognitive delay onset
## #533 — NPC conversation murmur wired to WorldSFX bus
##
## Test layers:
## 1. Zone crossfade spec and API (D-073 / #529)
## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing)
## 3. Volume slider proportional dip (D-068 / D-069)
## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530)
## 5. Recognition chime onset verification (D-067 / #531)
## 6. NPC murmur routing (D-072 / #533)
## 7. AudioManager no-op fallback sanity (D-068 / D-038)
class_name TestAudioSprint13
extends GdUnitTestSuite
func before_test() -> void:
AudioManager.clear_dip()
AudioManager.stop_all_loops()
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0)
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0)
AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0)
func after_test() -> void:
AudioManager.clear_dip()
AudioManager.stop_all_loops()
# ==============================================================================
# Layer 1: Zone Crossfade — D-073 / #529
# set_zone() stub exists now; full implementation lands in #529.
# ==============================================================================
func test_d073_set_zone_method_exists() -> void:
## D-073 / #529: AudioManager must expose set_zone(zone_id: String).
assert_that(AudioManager.has_method("set_zone")).is_true()
func test_d073_set_zone_hub_does_not_crash() -> void:
## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation.
AudioManager.set_zone("hub")
func test_d073_set_zone_bar_does_not_crash() -> void:
## D-073: set_zone("bar") is a safe call.
AudioManager.set_zone("bar")
func test_d073_set_zone_corridor_does_not_crash() -> void:
## D-073: set_zone("corridor") is a safe call.
AudioManager.set_zone("corridor")
func test_d073_set_zone_empty_string_does_not_crash() -> void:
## D-073: set_zone("") edge case — no zone ID. Must not crash.
AudioManager.set_zone("")
func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op.
AudioManager.set_zone("nonexistent_zone_xyz")
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
## Test verifies naming convention is documentable. Activates once #529 adds the map.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("hub")).is_true()
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
## D-073 / #529: bar → amb_bar_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("bar")).is_true()
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
## D-073 / #529: smuggling corridor → amb_corridor_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("corridor")).is_true()
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
func test_d073_game_state_extracts_zone_id_from_player_tile() -> void:
## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as
## a first-class field (like player_facing, player_stance), avoiding O(N) tile
## scan in main.gd per D-020 server-authoritative state.
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({
"tick": 1,
"entities": [player],
"tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"},
],
})
assert_that(GameState.current_zone_id).is_equal("bar")
GameState.apply_snapshot({"tick": 2})
func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09).
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({
"tick": 1,
"entities": [player],
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}],
})
assert_that(GameState.current_zone_id).is_equal("")
GameState.apply_snapshot({"tick": 2})
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
## D-073: Crossfade tween duration must be 1.5-2.0s.
## Activates once #529 defines the duration constant.
if not "CROSSFADE_DURATION" in AudioManager:
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
return
var duration: float = AudioManager.CROSSFADE_DURATION
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
## D-073: Crossing back to the current zone should not restart a crossfade.
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
return
AudioManager.set_zone("hub")
AudioManager.set_zone("hub")
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
## Stub: assert no crash and ambient_players size is 0 or 1, not 2.
assert_that(AudioManager._ambient_players.size() <= 1).is_true()
func test_d073_rapid_zone_crossing_interruptible() -> void:
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
## volume — new tween starts from current position. No stacked tweens, no crash.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
return
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
AudioManager.set_zone("hub")
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween
AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween
## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in).
## No stacked tweens — _kill_zone_tweens clears previous tweens each time.
assert_that(AudioManager._ambient_players.size() <= 2).is_true()
## Zone tweens array should only contain active tweens from the last set_zone call.
assert_that(AudioManager._zone_tweens.size() <= 2).is_true()
AudioManager.stop_all_loops()
func test_d068_load_prefs_persistence_roundtrip() -> void:
## #528: Volume persistence — save/load roundtrip via ConfigFile.
## Sets non-default volumes, saves, reloads, and verifies values match.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0)
## _save_prefs() fires inside set_volume() — prefs file is written.
## Reload prefs by calling _load_prefs() directly.
AudioManager._load_prefs()
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01)
## Reset to defaults for subsequent tests.
for bus in AudioManager.BUSES:
AudioManager.set_volume(bus, 0.0)
func test_d068_load_prefs_handles_missing_config_gracefully() -> void:
## #528: _load_prefs() with a missing/corrupted config file must not crash.
## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early.
## This test documents the graceful fallback behavior.
AudioManager._load_prefs()
## No assertion — absence of crash is the test.
# ==============================================================================
# Layer 2: D-069 Dip Timing and dB Spec Values
# These tests supplement test_audio_bus_routing.gd with timing and dB precision.
# ==============================================================================
func test_d069_dialogue_ease_in_is_300ms() -> void:
## D-069: Dialogue dip ease-in = 300ms (0.3s).
var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"]
assert_that(ease_in).is_equal_approx(0.3, 0.05)
func test_d069_dialogue_ease_out_is_500ms() -> void:
## D-069: Dialogue dip ease-out = 500ms (0.5s).
var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
assert_that(ease_out).is_equal_approx(0.5, 0.05)
func test_d069_confrontation_ease_in_is_500ms() -> void:
## D-069: Confrontation dip ease-in = 500ms (0.5s).
var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"]
assert_that(ease_in).is_equal_approx(0.5, 0.05)
func test_d069_confrontation_ease_out_is_1000ms() -> void:
## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion.
var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
assert_that(ease_out).is_equal_approx(1.0, 0.05)
func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void:
## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used.
var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"]
assert_that(dip >= -8.0 and dip <= -6.0).is_true()
func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void:
## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used.
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"]
assert_that(dip >= -12.0 and dip <= -10.0).is_true()
func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void:
## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through).
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"]
assert_that(dip >= -6.0 and dip <= -4.0).is_true()
func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void:
## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus).
var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"]
assert_that(boost >= 2.0 and boost <= 3.0).is_true()
func test_d069_dialogue_spec_only_affects_ambient_bus() -> void:
## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music
## must NOT appear in the spec — world events remain audible during conversation.
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
assert_that(buses.has("Ambient")).is_true()
assert_that(buses.has("WorldSFX")).is_false()
assert_that(buses.has("PlayerActions")).is_false()
assert_that(buses.has("UISounds")).is_false()
assert_that(buses.has("Music")).is_false()
func test_d069_confrontation_spec_does_not_affect_player_actions() -> void:
## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("PlayerActions")).is_false()
func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void:
## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("UISounds")).is_false()
func test_d069_listening_focus_spec_does_not_affect_ambient() -> void:
## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified.
## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip.
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
assert_that(buses.has("Ambient")).is_false()
func test_d069_filter_cutoff_default_is_approx_20khz() -> void:
## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed.
## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz.
assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true()
func test_d069_confrontation_filter_hz_is_800hz() -> void:
## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070).
var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"]
assert_that(filter_hz).is_equal_approx(800.0, 50.0)
# ==============================================================================
# Layer 3: Volume Slider Proportional Dip — D-068 / D-069
# ==============================================================================
func test_d068_set_volume_get_volume_roundtrip() -> void:
## D-068: set_volume / get_volume roundtrip preserves the slider value.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01)
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
func test_d068_set_volume_persists_across_all_buses() -> void:
## D-068: Each of the 5 buses has an independent volume setting.
AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0)
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01)
func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void:
## D-069: get_volume() always returns the player slider setting (base).
## The effective AudioServer volume during a dip = base + offset_db.
## Callers storing the slider value must always read get_volume(), not AudioServer directly.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.apply_dip("dialogue")
## Even during dip, get_volume returns the base (not base + dip offset).
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01)
func test_d069_set_volume_during_active_dip_updates_base() -> void:
## D-069: Changing slider mid-dip must update the base so the proportional
## calculation uses the new slider value (not the pre-dip value).
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.apply_dip("dialogue")
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01)
func test_d069_clear_dip_does_not_change_stored_slider_value() -> void:
## D-069: clear_dip() restores AudioServer volumes to base, but get_volume()
## must still reflect the player slider setting (not the dipped value).
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
AudioManager.apply_dip("dialogue")
AudioManager.clear_dip()
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
# ==============================================================================
# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530
#
# These tests verify GameState has the snapshot fields needed for #530 wiring.
# The assertions on AudioManager dip activation are stubbed pending implementation.
# ==============================================================================
func test_d069_game_state_current_dialogue_field_exists() -> void:
## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip.
## Field must exist so #530 wiring can check it.
assert_that("current_dialogue" in GameState).is_true()
func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void:
## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "Kael", "npc_entity_id": 2,
"speech": "Haven't seen you around.", "options": [],
},
})
assert_that(GameState.current_dialogue != null).is_true()
assert_that(GameState.current_dialogue is Dictionary).is_true()
GameState.apply_snapshot({"tick": 2})
func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void:
## #530 precondition: Snapshot without current_dialogue → current_dialogue is null.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_dialogue == null).is_true()
func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void:
## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires.
## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state.
AudioManager.clear_dip()
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
## Precondition: dialogue IS active in GameState.
assert_that(GameState.current_dialogue != null).is_true()
## ASSERTION (activate once #530 is implemented):
## assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
GameState.apply_snapshot({"tick": 2})
AudioManager.clear_dip()
func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void:
## D-069 / #530: When current_dialogue returns to null, clear_dip() fires.
## TODO(#530): Uncomment the assertion once call site is wired.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
GameState.apply_snapshot({"tick": 2}) # dialogue ends
## ASSERTION (activate once #530 is implemented):
## assert_that(AudioManager.get_active_dip()).is_equal("")
assert_that(GameState.current_dialogue == null).is_true()
func test_d071_listening_focus_gate_is_30_ticks() -> void:
## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks.
## The tick gate is the CALLER's responsibility per AudioManager comment.
## This test documents the threshold so it doesn't silently drift.
## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530.
const LISTENING_FOCUS_TICK_GATE := 30
## Stub: verify the spec value is documented.
assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30)
func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void:
## D-070: Confrontation muffling is felt, not announced. No UI indicator.
## Verify AudioManager does not expose a confrontation_ui_shown signal.
var signals: Array = AudioManager.get_signal_list().map(
func(s: Dictionary) -> String: return s.name
)
assert_that(signals.has("confrontation_ui_shown")).is_false()
assert_that(signals.has("listening_focus_shown")).is_false()
# ==============================================================================
# Layer 5: Recognition Chime Onset — D-067 / #531
#
# Chime fires at ONSET of cognitive delay (when entity FIRST appears in
# pending_recognitions), NOT at completion (when it leaves).
# ==============================================================================
func test_d067_chime_recognition_constant_defined() -> void:
## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant.
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
func test_d067_chime_recognition_matches_d038_asset_key() -> void:
## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel.
## Key must match filename stem in res://audio/.
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void:
## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default.
## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX.
## Verify play() default bus is UISounds (the chime caller uses the default).
## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct.
assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS)
func test_d067_play_chime_recognition_noop_when_asset_absent() -> void:
## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent.
## Client must not crash when audio branch has not yet provided the .ogg file.
AudioManager.play(AudioManager.CHIME_RECOGNITION)
## No assertion needed — absence of crash is the test.
func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
## D-067: "Onset" of cognitive delay = first frame an entity appears in
## pending_recognitions, at remaining_ticks == total_delay_ticks.
## This is the moment the chime must fire.
GameState.apply_snapshot({
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 6, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
var rec: Dictionary = GameState.pending_recognitions[0]
## Onset condition: remaining == total (delay just started)
assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks)
GameState.apply_snapshot({"tick": 2})
func test_d067_completion_is_when_entity_absent_from_pending() -> void:
## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions.
## The chime must NOT fire at this point — it fired at onset.
GameState.apply_snapshot({
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 1, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
## Completion: entity removed from array
GameState.apply_snapshot({"tick": 2, "pending_recognitions": []})
assert_that(GameState.pending_recognitions.size()).is_equal(0)
## Chime state: AudioManager must not have a dip triggered by recognition.
## (Chime is a play() call, not a dip — this verifies no side effects on dip state.)
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd
## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop.
## This test verifies FogEntities correctly identifies the onset condition.
## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay.
var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 6, "total_delay_ticks": 6}
var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 3, "total_delay_ticks": 6}
## Frame 1: entity APPEARS (onset — chime should fire here)
GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]})
assert_that(GameState.pending_recognitions[0].remaining_ticks
== GameState.pending_recognitions[0].total_delay_ticks).is_true()
## Frame 2: entity mid-progress (chime must NOT re-fire)
GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]})
assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3)
## Frame 3: entity completes (chime must NOT fire)
GameState.apply_snapshot({"tick": 3, "pending_recognitions": []})
assert_that(GameState.pending_recognitions.size()).is_equal(0)
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
## This test documents the spec range so asset authoring can be validated.
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
## return a value in this range.
const CHIME_MIN_DURATION := 0.3
const CHIME_MAX_DURATION := 0.4
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
return
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
if stream == null:
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
return
assert_that(stream.get_length() >= CHIME_MIN_DURATION
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
# ==============================================================================
# Layer 6: NPC Murmur Routing — D-072 / #533
# Single universal asset, WorldSFX bus, no zone-specific variants.
# ==============================================================================
func test_d072_world_sfx_bus_is_correct_for_murmur() -> void:
## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture.
## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined
## by that bus's noise floor relative to the murmur volume.
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
func test_d072_play_at_method_accepts_bus_parameter() -> void:
## D-072 / #533: play_at() must accept an optional bus string parameter.
## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX).
assert_that(AudioManager.has_method("play_at")).is_true()
func test_d072_play_at_noop_when_murmur_asset_absent() -> void:
## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532).
## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op.
AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX)
## No assertion — absence of crash is the test.
func test_d072_play_noop_when_murmur_asset_absent() -> void:
## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully.
AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX)
## No assertion — absence of crash is the test.
func test_d072_no_zone_specific_murmur_variants() -> void:
## D-072: SINGLE universal murmur asset — no zone-specific variants.
## "One murmur asset + zone-dependent conspicuousness creates the signal/noise
## dynamic naturally." Zone-specific variants MUST NOT exist in the registry.
assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false()
func test_d072_murmur_does_not_use_ambient_bus() -> void:
## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background).
## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient.
## Verify bus constant distinction.
assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX)
# ==============================================================================
# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038
# ==============================================================================
func test_d068_registry_size_is_non_negative() -> void:
## D-068: Registry is empty when res://audio/ is absent, non-negative always.
assert_that(AudioManager.get_registry_size() >= 0).is_true()
func test_d068_play_loop_returns_null_for_missing_asset() -> void:
## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash).
var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc")
assert_that(result == null).is_true()
func test_d068_stop_loop_noop_for_unknown_key() -> void:
## D-068: stop_loop() on a key never started — no crash, no error.
AudioManager.stop_loop("nonexistent_key_xyzabc")
func test_d068_stop_all_loops_when_none_playing() -> void:
## D-068: stop_all_loops() with no active ambient players — no crash.
AudioManager.stop_all_loops()
func test_d068_has_asset_returns_false_for_unknown_key() -> void:
## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key.
assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false()
func test_d068_play_noop_does_not_change_dip_state() -> void:
## D-068: play() on a missing asset must not modify the dip state machine.
## Verifies no-op fallback has zero side effects.
AudioManager.apply_dip("dialogue")
AudioManager.play("nonexistent_asset_xyzabc")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
+490
View File
@@ -0,0 +1,490 @@
## #122: Monologue display — client tests (Sprint 14)
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
##
## API per Tyre architecture review:
## show_monologue(text, duration, priority=2, is_urgent=false)
## GameState.lattice_profile selects colour palette
class_name TestMonologueDisplay
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _make_display() -> Node:
if not ResourceLoader.exists("res://ui/monologue_display.tscn"):
push_warning("TestMonologueDisplay: scene not found — tests skipped")
return null
var node = load("res://ui/monologue_display.tscn").instantiate()
add_child(node)
return node
func _label_text(d: Node) -> String:
if d._visible.is_empty():
return ""
var slot_node: Node = d._visible[0].node
return (slot_node.get_child(0) as RichTextLabel).text
# ---------------------------------------------------------------------------
# Lifecycle — ensure clean GameState between every test
# ---------------------------------------------------------------------------
func before_test() -> void:
## Reset GameState fields touched by this suite so tests don't bleed into each other.
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
## current_monologue: GameState integration tests need null as start state.
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
func after_test() -> void:
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
# ---------------------------------------------------------------------------
# Initial state
# ---------------------------------------------------------------------------
func test_nothing_visible_on_init() -> void:
var d = _make_display()
if d == null: return
assert_int(d._visible.size()).is_equal(0)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_stagger_timer_zero_on_init() -> void:
var d = _make_display()
if d == null: return
assert_float(d._next_fade_in_msec).is_equal(0.0)
d.queue_free()
func test_show_monologue_with_empty_text_does_not_set_displaying() -> void:
# Empty text must be ignored — no visible slot created, no queue entry.
# Prevents ghost display nodes and stagger timer contamination.
var d = _make_display()
if d == null: return
d.show_monologue("", 5.0)
assert_int(d._visible.size()).is_equal(0)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_empty_text_does_not_advance_stagger_timer() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("", 5.0)
assert_float(d._next_fade_in_msec).is_equal(0.0)
d.queue_free()
func test_empty_text_when_slots_full_does_not_enqueue() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("", 5.0)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
# ---------------------------------------------------------------------------
# Single-line display
# ---------------------------------------------------------------------------
func test_single_line_goes_to_visible() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("One.", 5.0)
assert_int(d._visible.size()).is_equal(1)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_show_monologue_sets_stagger_timer() -> void:
var d = _make_display()
if d == null: return
var before := float(Time.get_ticks_msec())
d.show_monologue("Stagger.", 5.0)
assert_float(d._next_fade_in_msec).is_greater(before)
d.queue_free()
# ---------------------------------------------------------------------------
# MAX_VISIBLE = 3 simultaneous lines
# ---------------------------------------------------------------------------
func test_three_lines_all_visible() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
assert_int(d._visible.size()).is_equal(3)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_fourth_line_queues_when_slots_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("D", 5.0)
assert_int(d._visible.size()).is_equal(3)
assert_int(d._queue.size()).is_equal(1)
d.queue_free()
# ---------------------------------------------------------------------------
# No-overwrite contract (P0, #477)
# ---------------------------------------------------------------------------
func test_new_line_does_not_replace_first_visible_line() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("First line.", 10.0)
var first_text := _label_text(d)
# Force stagger active; second call must queue, not display
d._next_fade_in_msec = float(Time.get_ticks_msec()) + 10000.0
d.show_monologue("Second line.", 5.0)
assert_that(_label_text(d)).is_equal(first_text)
d.queue_free()
func test_two_visible_lines_coexist_without_overwriting() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("Alpha", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("Beta", 10.0)
assert_int(d._visible.size()).is_equal(2)
d.queue_free()
# ---------------------------------------------------------------------------
# Priority queue
# ---------------------------------------------------------------------------
func test_queue_sorted_highest_priority_first() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("low", 5.0, 1)
d.show_monologue("high", 5.0, 4)
d.show_monologue("normal", 5.0, 2)
assert_int(d._queue[0].priority).is_equal(4)
d.queue_free()
func test_queue_drop_replaces_lowest_when_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE):
d.show_monologue("low_%d" % i, 5.0, 1)
d.show_monologue("critical!", 5.0, 9)
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
var has_critical := false
for e in d._queue:
if e.priority == 9:
has_critical = true
assert_that(has_critical).is_true()
d.queue_free()
func test_queue_ignores_lower_priority_when_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE):
d.show_monologue("hi_%d" % i, 5.0, 5)
d.show_monologue("noise", 1.0, 1)
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
for e in d._queue:
assert_int(e.priority).is_equal(5)
d.queue_free()
func test_queue_never_exceeds_max_depth() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE + 20):
d.show_monologue("flood_%d" % i, 1.0, 2)
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE)
d.queue_free()
# ---------------------------------------------------------------------------
# Expire and drain
# ---------------------------------------------------------------------------
func test_expired_slot_removed_from_visible() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Expires.", 1.0)
d._visible[0].expire_timer = -0.1
d._process(0.0)
assert_int(d._visible.size()).is_equal(0)
d.queue_free()
func test_queue_drains_when_slot_opens() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("Queued.", 5.0)
d._visible[0].expire_timer = -0.1
d._next_fade_in_msec = 0.0 # stagger elapsed
d._process(0.0)
assert_int(d._queue.size()).is_equal(0)
assert_int(d._visible.size()).is_equal(3)
d.queue_free()
# ---------------------------------------------------------------------------
# Stagger
# ---------------------------------------------------------------------------
func test_second_call_within_stagger_period_queues() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("First.", 5.0)
# _next_fade_in_msec is now ~150ms in the future
d.show_monologue("Second.", 5.0)
assert_int(d._visible.size()).is_equal(1)
assert_int(d._queue.size()).is_equal(1)
d.queue_free()
func test_stagger_elapsed_allows_second_visible() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("First.", 5.0)
d._next_fade_in_msec = 0.0 # manually expire stagger
d.show_monologue("Second.", 5.0)
assert_int(d._visible.size()).is_equal(2)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
# ---------------------------------------------------------------------------
# BBCode output
# ---------------------------------------------------------------------------
func test_text_wrapped_in_italic_bbcode() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Italic line.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[i]")
assert_that(txt).contains("[/i]")
assert_that(txt).contains("Italic line.")
d.queue_free()
func test_text_has_color_bbcode() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Coloured.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#")
assert_that(txt).contains("[/color]")
d.queue_free()
# ---------------------------------------------------------------------------
# Lattice colour palette
# ---------------------------------------------------------------------------
func test_augmented_colour_differs_from_baseline() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_augmented"
d.show_monologue("Detective.", 5.0)
var aug_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Smuggler.", 5.0)
var base_txt := _label_text(d)
assert_that(aug_txt).is_not_equal(base_txt)
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
func test_urgent_colour_differs_from_standard() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Normal.", 5.0, 2, false)
var std_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
d.show_monologue("Urgent!", 5.0, 3, true)
var urg_txt := _label_text(d)
assert_that(std_txt).is_not_equal(urg_txt)
d.queue_free()
func test_unknown_profile_falls_back_without_crash() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_hypothetical_tier_x"
d.show_monologue("Future proof.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
# ---------------------------------------------------------------------------
# Slot lifecycle
# ---------------------------------------------------------------------------
func test_visible_slot_has_tween() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Has tween.", 5.0)
assert_that(d._visible[0].tween).is_not_null()
d.queue_free()
func test_visible_slot_stores_priority() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Priority 7.", 5.0, 7)
assert_int(d._visible[0].priority).is_equal(7)
d.queue_free()
func test_expire_timer_decrements_in_process() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Timer.", 10.0)
d._process(1.5)
assert_float(d._visible[0].expire_timer).is_less(10.0)
d.queue_free()
# ---------------------------------------------------------------------------
# GameState integration — current_monologue field (v5, #414)
# These tests do not instantiate the scene — they verify apply_snapshot()
# correctly populates and clears current_monologue so the display layer
# receives valid data (or null) every tick.
#
# Joint test plan (sprint-14/joint.md §Test Plan Alignment):
# "Verify current_monologue: None produces no display (no ghost text from previous tick)"
# ---------------------------------------------------------------------------
func test_gamestate_monologue_null_when_snapshot_omits_field() -> void:
## D-016: Snapshot without current_monologue must clear the field.
## Prevents a stale line from a previous tick persisting as ghost text.
GameState.current_monologue = {"id": "stale", "text": "Old.", "duration_seconds": 3.0}
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_monologue).is_null()
func test_gamestate_monologue_set_from_snapshot() -> void:
## apply_snapshot populates current_monologue when the field is a dict.
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "m001", "text": "A thought.", "duration_seconds": 5.0},
})
assert_that(GameState.current_monologue).is_not_null()
assert_that(GameState.current_monologue.get("text")).is_equal("A thought.")
func test_gamestate_monologue_null_when_field_is_non_dict() -> void:
## Defensive: a non-dictionary value from the server must be rejected (null).
GameState.apply_snapshot({"tick": 1, "current_monologue": "not-a-dict"})
assert_that(GameState.current_monologue).is_null()
func test_gamestate_monologue_duration_seconds_survives_round_trip() -> void:
## duration_seconds feeds show_monologue()'s duration param — must not be lost.
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "m1", "text": "Test.", "duration_seconds": 7.5},
})
var dur: float = float(GameState.current_monologue.get("duration_seconds"))
assert_that(dur).is_equal_approx(7.5, 0.001)
func test_gamestate_monologue_id_survives_round_trip() -> void:
## The id field is used by SimBridge carry-forward deduplication (#477).
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "enter_cargo_bay_001", "text": "Hmm.", "duration_seconds": 3.0},
})
assert_that(GameState.current_monologue.get("id")).is_equal("enter_cargo_bay_001")
func test_gamestate_monologue_replaced_by_next_snapshot() -> void:
## Each snapshot with a monologue replaces the previous value — no accumulation.
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "first", "text": "First.", "duration_seconds": 3.0},
})
GameState.apply_snapshot({
"tick": 2,
"current_monologue": {"id": "second", "text": "Second.", "duration_seconds": 3.0},
})
assert_that(GameState.current_monologue.get("id")).is_equal("second")
# ---------------------------------------------------------------------------
# D-049 Z-layer / canvas scope compliance
# MonologueDisplay must be parented to UILayer (CanvasLayer, layer=20).
# Two tests: (1) constant sanity, (2) scene tree structural verification.
# ---------------------------------------------------------------------------
func test_canvas_ui_constant_is_20() -> void:
## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display.
## Sanity check — constant must not drift from the spec.
assert_that(Constants.CANVAS_UI).is_equal(20)
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
## D-049: Structural verification — MonologueDisplay must be a direct child of
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
## Catches regressions where the node gets accidentally moved to InsertOverlay
## (layer=10) or ModalLayer (layer=30), or dropped into the world z-stack.
##
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var mono: Node = scene.get_node_or_null("UILayer/MonologueDisplay")
assert_that(mono != null).is_true()
var parent: Node = mono.get_parent()
assert_that(parent is CanvasLayer).is_true()
assert_that((parent as CanvasLayer).layer).is_equal(Constants.CANVAS_UI)
+199
View File
@@ -5,6 +5,7 @@ extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
var SoundIndicatorScript = load("res://scripts/rendering/sound_indicator_renderer.gd")
# -- Test data matching Protocol decoded format --
@@ -230,6 +231,19 @@ func test_entity_renderer_skips_missing_entity_id() -> void:
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
# Regression test for #345: protocol uses entity_id (not id).
# An entity dict keyed with "id" (old/wrong format) must be silently dropped.
func test_entity_renderer_rejects_old_id_field_format() -> void:
var renderer := _make_entity_renderer()
# Old (wrong) format: "id" instead of "entity_id"
renderer.update_entities([{"id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(0)
# Correct format: "entity_id" — should be accepted
renderer.update_entities([{"entity_id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_entity_renderer_player_color_differs_from_npc() -> void:
var renderer := _make_entity_renderer()
renderer.update_entities(_test_entities)
@@ -334,6 +348,191 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
renderer.queue_free()
# -- EntityRenderer: regression #345 (entity_id field name bug) --
# Bug: entity_renderer.gd checked entity_data.has("id") instead of
# entity_data.has("entity_id"), causing all entities to be silently dropped.
# Fix: use entity_id (Protocol v2 canonical field name).
func test_regression_345_entity_id_field_renders() -> void:
# Regression: entity with correct "entity_id" field MUST be rendered.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_regression_345_old_id_field_not_rendered() -> void:
# Negative regression: entity using OLD field name "id" (not "entity_id")
# must be silently dropped. Pre-fix code accepted "id"; this verifies the fix.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
func test_regression_345_mixed_batch_only_entity_id_renders() -> void:
# Mixed batch: one entity with correct "entity_id", one with old "id" only.
# Only the entity_id entity should appear — no cross-contamination.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 1, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"id": 2, "x": 2.0, "y": 2.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(1)).is_true()
assert_that(renderer.entity_nodes.has(2)).is_false()
renderer.queue_free()
func test_regression_345_entity_id_node_keyed_by_id_value() -> void:
# Regression: entity_nodes dict must be keyed by the entity_id VALUE,
# not by a string "entity_id" or by the old "id" value.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 42, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.has(42)).is_true()
assert_that(renderer.entity_nodes.has("entity_id")).is_false()
assert_that(renderer.entity_nodes.has(0)).is_false()
renderer.queue_free()
func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
# Regression: entity rendered via entity_id must have correct pixel position.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
var node = renderer.entity_nodes[5]
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01)
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01)
renderer.queue_free()
# -- SoundIndicatorRenderer: #126 D-018 medium-range fog-edge indicators --
func _make_sound_indicator_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(SoundIndicatorScript)
add_child(renderer)
return renderer
func test_sound_indicator_accepts_medium_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 10.0, "y": 10.0, "event_type": "Footstep"},
{"x": 15.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(renderer._indicators.size()).is_equal(2)
renderer.queue_free()
func test_sound_indicator_drops_events_without_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"event_type": "Footstep"}, # missing x, y
{"x": 5.0, "event_type": "Voice"}, # missing y
{"x": 8.0, "y": 3.0, "event_type": "Footstep"}, # valid
])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_appends_new_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 1.0, "y": 1.0, "event_type": "Voice"},
{"x": 2.0, "y": 2.0, "event_type": "Footstep"},
])
assert_that(renderer._indicators.size()).is_equal(2)
# New update appends — existing indicators persist until they expire
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Footstep"}])
assert_that(renderer._indicators.size()).is_equal(3)
renderer.queue_free()
func test_sound_indicator_empty_update_preserves_existing() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 3.0, "y": 3.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Empty update does not clear existing indicators — they expire via _process
renderer.update_sound_events([])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_color_voice() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("Voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("speech")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
renderer.queue_free()
func test_sound_indicator_color_danger() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Gunshot")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("alert")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("danger")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
renderer.queue_free()
func test_sound_indicator_color_neutral_for_unknown() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Footstep")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("Unknown")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
renderer.queue_free()
func test_sound_indicator_deduplicates_same_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Same position again — should reset timer, not add a duplicate
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_elapsed_starts_at_zero() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators[0].elapsed).is_equal_approx(0.0, 0.001)
renderer.queue_free()
func test_sound_indicator_events_expire_after_lifetime() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Footstep"}])
# Manually age the indicator past its lifetime
renderer._indicators[0].elapsed = SoundIndicatorRenderer.INDICATOR_LIFETIME + 0.01
renderer._process(0.0) # zero delta so no additional aging
assert_that(renderer._indicators.size()).is_equal(0)
renderer.queue_free()
# -- GameState: medium_sound_events from snapshot --
func test_game_state_filters_medium_sound_events() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 8.0, "y": 8.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Voice")
func test_game_state_medium_sound_events_empty_when_no_field() -> void:
GameState.apply_snapshot({"tick": 1})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
func test_game_state_medium_sound_events_cleared_between_ticks() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 5.0, "y": 5.0, "event_type": "Voice", "range_category": "Medium"}]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
# Next tick without sound_events clears them
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# -- TileRenderer: tile type constants --
func test_tile_type_map_covers_required_types() -> void:
+236
View File
@@ -0,0 +1,236 @@
## Test suite for #126: Medium-range visual indicators (D-018)
## Spec refs:
## D-018: Medium-range (outside LOS, nearby) → fog-edge directional indicators
## D-049: Z-stack — indicators must render above fog layer (z:900)
## D-069: Color palette for indicators (neutral/voice/danger)
##
## Color spec (sprint brief + D-018/D-069):
## Neutral: #c8d0e0 (matches INSERT_COLOR_TEXT — insert visual language)
## Voices: #e8c547 (matches ENTITY_COLOR_POI — amber, voice context)
## Danger: #d45d5d (matches ENTITY_COLOR_HOSTILE — muted red)
##
## Test layers:
## 1. D-018 Indicator color spec — verifies Constants match the spec
## 2. D-018 Range routing — Medium vs Close events belong to different systems
## 3. Direction geometry — angle from player position to sound source
## 4. Snapshot integration stubs — graceful skip until #126 lands
## 5. Indicator script API — graceful skip until Stig creates the script
class_name TestSoundIndicators
extends GdUnitTestSuite
# ==============================================================================
# Layer 1: D-018/D-069 Indicator Color Spec
# Sound indicators reuse existing Constants to avoid palette drift.
# ==============================================================================
func test_d018_indicator_color_neutral_is_insert_color() -> void:
## D-018: Neutral indicator = #c8d0e0 = INSERT_COLOR_TEXT.
## Indicators use the insert visual language (diegetic, not world-layer UI).
assert_that(Constants.INSERT_COLOR_TEXT).is_equal(Color("#c8d0e0"))
func test_d018_indicator_color_voices_is_poi_amber() -> void:
## D-018/D-069: Voice indicator = #e8c547 = ENTITY_COLOR_POI (amber).
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d018_indicator_color_danger_is_hostile_red() -> void:
## D-018/D-069: Danger indicator = #d45d5d = ENTITY_COLOR_HOSTILE (muted red).
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d018_three_indicator_colors_are_distinct() -> void:
## D-018: All three indicator states must be visually distinct.
var neutral: Color = Constants.INSERT_COLOR_TEXT
var voice: Color = Constants.ENTITY_COLOR_POI
var danger: Color = Constants.ENTITY_COLOR_HOSTILE
assert_that(neutral != voice).is_true()
assert_that(neutral != danger).is_true()
assert_that(voice != danger).is_true()
func test_d049_fog_layer_z_index() -> void:
## D-049: Fog overlay is at z:900. Indicators must render above it (z > 900).
## This verifies the z constant — indicator placement depends on it.
assert_that(Constants.Z_FOG).is_equal(900)
# ==============================================================================
# Layer 2: D-018 Range Routing — Medium vs Close
# Close-range events → AudioManager.play_at() (spatial audio, no indicator)
# Medium-range events → fog-edge indicators (no direct audio)
# ==============================================================================
func test_d018_medium_range_events_stored_in_snapshot() -> void:
## GameState.medium_sound_events receives Medium-range events (#126 implemented).
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Footstep")
func test_d018_close_range_events_do_not_appear_in_medium() -> void:
## D-018: Close events must NOT appear in medium_sound_events.
## They go to close_sound_events for 2D positional audio.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Voice", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(1)
func test_d018_medium_range_event_has_position_fields() -> void:
## Medium-range events must include source position for direction calculation.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 12.0, "y": 8.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
var ev: Dictionary = GameState.medium_sound_events[0]
assert_that(ev.has("x")).is_true()
assert_that(ev.has("y")).is_true()
assert_that(float(ev["x"])).is_equal_approx(12.0, 0.001)
assert_that(float(ev["y"])).is_equal_approx(8.0, 0.001)
# ==============================================================================
# Layer 3: Direction Geometry
# The fog-edge indicator must point toward the sound source from the player.
# These tests validate the math used by the indicator rendering — if the
# indicator uses Vector2.angle() or atan2, these confirm expected output.
# ==============================================================================
## Compute directional angle (radians) from player tile pos to source tile pos.
## 0 = East, -PI/2 = North, PI/2 = South, ±PI = West (Godot Y-down convention).
func _direction_angle(player: Vector2, source: Vector2) -> float:
return player.direction_to(source).angle()
func test_indicator_direction_north() -> void:
## Source directly north of player (lower y in Godot) → angle -PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 2.0, 0.01)
func test_indicator_direction_east() -> void:
## Source directly east → angle 0.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
assert_that(angle).is_equal_approx(0.0, 0.01)
func test_indicator_direction_south() -> void:
## Source directly south (higher y) → angle PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(angle).is_equal_approx(PI / 2.0, 0.01)
func test_indicator_direction_west() -> void:
## Source directly west → angle ≈ ±PI.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 10.0))
assert_that(absf(angle)).is_equal_approx(PI, 0.01)
func test_indicator_direction_northeast() -> void:
## Source equal offset northeast → angle -PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 4.0, 0.01)
func test_indicator_direction_southeast() -> void:
## Source equal offset southeast → angle PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 20.0))
assert_that(angle).is_equal_approx(PI / 4.0, 0.01)
func test_indicator_direction_southwest() -> void:
## Source equal offset southwest → angle 3PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 20.0))
assert_that(angle).is_equal_approx(3.0 * PI / 4.0, 0.01)
func test_indicator_direction_changes_with_source_position() -> void:
## Sanity: direction is not degenerate — different source positions give different angles.
var north := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
var east := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
var south := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(north != east).is_true()
assert_that(east != south).is_true()
assert_that(south != north).is_true()
func test_indicator_direction_magnitude_independent_of_distance() -> void:
## Direction angle must not vary with distance — only with angle from player.
## Source 5 tiles north vs 20 tiles north: same angle.
var near := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 5.0))
var far := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, -10.0))
assert_that(near).is_equal_approx(far, 0.01)
# ==============================================================================
# Layer 4: Indicator Script API (graceful stub)
# Tests activate when Stig creates the indicator rendering script.
# Expected paths checked in order — first match wins.
# ==============================================================================
func _load_indicator_script() -> GDScript:
## Load the sound indicator renderer script (#126 implemented).
var path: String = "res://scripts/rendering/sound_indicator_renderer.gd"
if ResourceLoader.exists(path):
return load(path) as GDScript
return null
func test_indicator_script_exists() -> void:
## #126: sound_indicator_renderer.gd must exist at the canonical path.
var script: GDScript = _load_indicator_script()
assert_that(script != null).is_true()
func test_indicator_has_update_sound_events_method() -> void:
## #126: Indicator must expose update_sound_events(events: Array).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var method_names: Array = script.get_script_method_list().map(
func(m: Dictionary) -> String: return m.name
)
assert_that(method_names.has("update_sound_events")).is_true()
func test_indicator_does_not_crash_for_empty_events() -> void:
## Edge case: empty sound_events list must not crash update_sound_events.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([])
# Empty update on fresh renderer — no indicators exist, no crash
assert_that(indicator._indicators.size()).is_equal(0)
indicator.queue_free()
func test_indicator_existing_survive_empty_update() -> void:
## Existing indicators persist through an empty update (expire via _process only).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.update_sound_events([])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.queue_free()
func test_indicator_stores_valid_medium_events() -> void:
## #126: update_sound_events must store events with x and y fields.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([
{"x": 10.0, "y": 5.0, "event_type": "Footstep"},
{"x": 20.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(indicator._indicators.size()).is_equal(2)
indicator.queue_free()
+399 -83
View File
@@ -1,37 +1,67 @@
extends Control
# Dialogue box — D-061: bottom screen, max 20% height, no portraits.
# #535: Unified conversation log — player dialogue and overheard NPC-NPC conversations
# flow chronologically. Oldest at top, scrolls up. Options at bottom.
#
# InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled.
# NPC speech top, player response options below, left-aligned.
# Max 3 visible options. No close button — walk-away (WASD) or option select only.
# Auto-pause in single-player when dialogue is open (D-061).
# D-063: Confrontation options render italic, trigger monologue beat before send.
# D-064: Walk-away via WASD dismisses active conversation (log entries persist).
# dialogue_active held until fade completes (D-064 auto-pause spec).
# D-078: Overheard NPC-NPC lines prefixed with ┃ glyph + desaturated colours.
signal option_selected(response_id: String, text: String)
signal dialogue_dismissed # Walk-away or conversation end
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
signal unpause_requested # D-061: auto-unpause
@onready var panel: PanelContainer = $PanelContainer
@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
# -- Log state --
# Entry format: {speaker, target, text, is_passive, pinned, timestamp_msec}
# pinned entries do not expire (active conversation lines, Araminta review).
var _log_entries: Array[Dictionary] = []
var _in_player_conversation: bool = false
var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1)
# -- Option state --
var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = []
var _option_texts: Array[String] = []
var _option_is_confrontation: Array[bool] = []
var _npc_name: String = ""
# -- UI state --
var _is_showing: bool = false
var _active_tween: Tween = null
var _beat_tween: Tween = null # D-063: confrontation beat delay
var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = [] # response_id per option, same index
var _option_texts: Array[String] = [] # raw display text per option
var _option_is_confrontation: Array[bool] = [] # confrontation flag per option
var _npc_name: String = ""
# -- Theme (loaded from data/dialogue-theme.yaml) --
var _player_color: Color = Color("#e0e8ff")
var _npc_colors: Array[Color] = []
var _arrow_color: Color = Color("#8890a0")
var _speech_color: Color = Color("#c8d0e0")
var _passive_opacity: float = 0.9
var _entry_lifetime: float = 15.0
var _entry_fade: float = 3.0
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
const FADE_IN: float = 0.2
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280)
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
const PLAYER_NAME: String = "You"
const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review)
const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor
const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG
# D-064: movement actions that trigger walk-away
const _WALK_AWAY_ACTIONS: Array[StringName] = [
@@ -45,26 +75,81 @@ func _ready() -> void:
visible = false
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
_load_theme()
_update_layout()
get_viewport().size_changed.connect(_update_layout)
func _process(_delta: float) -> void:
_expire_entries()
if _log_dirty:
_log_dirty = false
_rebuild_log()
func _unhandled_input(event: InputEvent) -> void:
if not _is_showing:
return
if not _in_player_conversation:
return
# D-064: WASD during dialogue → walk-away, 300ms fade
# D-064: WASD during active player dialogue → walk-away
if event is InputEventKey and event.pressed:
for action in _WALK_AWAY_ACTIONS:
if event.is_action_pressed(action):
get_viewport().set_input_as_handled()
_cancel_beat()
hide_dialogue()
_end_player_conversation()
dialogue_dismissed.emit()
return
# Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport.
# -- Theme loading --
func _load_theme() -> void:
# Default NPC palette in case file not found
_npc_colors = [
Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"),
Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"),
]
if not FileAccess.file_exists(THEME_PATH):
push_warning("DialogueBox: theme file not found: %s — using defaults" % THEME_PATH)
return
var file := FileAccess.open(THEME_PATH, FileAccess.READ)
if file == null:
return
var text := file.get_as_text()
file.close()
var strings := UIStrings._parse_yaml(text)
if strings.has("player_color"):
_player_color = Color(strings["player_color"])
if strings.has("arrow_color"):
_arrow_color = Color(strings["arrow_color"])
if strings.has("speech_color"):
_speech_color = Color(strings["speech_color"])
if strings.has("passive_opacity"):
_passive_opacity = float(strings["passive_opacity"])
if strings.has("entry_lifetime_seconds"):
_entry_lifetime = float(strings["entry_lifetime_seconds"])
if strings.has("entry_fade_seconds"):
_entry_fade = float(strings["entry_fade_seconds"])
# Load NPC colors from indexed keys (npc_colors.0 through npc_colors.7)
var loaded_colors: Array[Color] = []
for i in range(16): # support up to 16 palette entries
var key := "npc_colors.%d" % i
if strings.has(key):
loaded_colors.append(Color(strings[key]))
if loaded_colors.size() > 0:
_npc_colors = loaded_colors
# -- Responsive layout --
func _update_layout() -> void:
var vp := get_viewport_rect().size
var max_h := vp.y * MAX_HEIGHT_RATIO
@@ -74,26 +159,140 @@ func _update_layout() -> void:
panel.offset_top = -max_h
# Show dialogue with NPC speech and response options.
# npc_name: who is speaking (displayed as prefix)
# speech: the NPC's dialogue text
# options: Array of {text, response_id, priority, confrontation} — sorted by priority, max 3 shown.
# D-062: locked options are invisible (server filters before sending).
# D-063: confrontation options render italic.
# -- Log entry management --
## Append a dialogue line to the log.
## speaker/target: display names. text: the spoken line.
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void:
var pinned := not is_passive and _in_player_conversation
_log_entries.append({
"speaker": speaker,
"target": target,
"text": text,
"is_passive": is_passive,
"pinned": pinned,
"timestamp_msec": Time.get_ticks_msec(),
})
_log_dirty = true
_ensure_visible()
## Append an overheard conversation event (D-078).
func append_conversation_event(event: Dictionary) -> void:
var speaker: String = event.get("speaker_name", "?")
var target: String = event.get("target_name", "?")
var text: String = event.get("occluded_line", "")
if text.is_empty():
return
append_line(speaker, target, text, true)
## Handle conversation_ended — no-op currently (entries expire via timeout).
func on_conversation_ended(_event: Dictionary) -> void:
pass
## Append the player's chosen response to the log.
func append_player_line(target_npc: String, text: String) -> void:
append_line(PLAYER_NAME, target_npc, text, false)
## Append an NPC follow-up line (from dialogue_response).
func append_dialogue_response(npc_name: String, text: String) -> void:
append_line(npc_name, PLAYER_NAME, text, false)
# -- Active player conversation --
## Show dialogue with NPC speech and response options.
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
_npc_name = npc_name
_cancel_beat()
_in_player_conversation = true
# NPC speech — name prefix in bold
if npc_name.is_empty():
npc_speech.text = speech
else:
npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech]
# Append NPC's line to the log
if not speech.is_empty():
append_line(npc_name, PLAYER_NAME, speech, false)
# Clear old options
# Clear old options and show new ones
_clear_options()
_show_options(options)
# Sort by priority ascending, cap at MAX_OPTIONS (D-061: max 3 visible)
# Show panel
_ensure_visible()
mouse_filter = Control.MOUSE_FILTER_STOP
GameState.dialogue_active = true # D-064: block movement while in conversation
# D-069: Dialogue dip
AudioManager.apply_dip("dialogue")
# D-061: auto-pause — signal to main.gd for input recording (#507)
pause_requested.emit()
## End active player conversation — clears options but preserves log.
## D-064: dialogue_active held until fade completes.
func _end_player_conversation() -> void:
_in_player_conversation = false
_clear_options()
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Unpin all entries and reset their timestamps so timeout starts now (Araminta review)
var now := Time.get_ticks_msec()
for entry in _log_entries:
if entry.pinned:
entry.pinned = false
entry.timestamp_msec = now
_log_dirty = true
# D-069: Clear dialogue/confrontation dip
AudioManager.clear_dip()
# D-061: unpause — signal to main.gd for input recording (#507)
unpause_requested.emit()
# D-064: dialogue_active stays true — cleared after fade completes in hide callback.
# If log entries still exist, panel stays visible and entries expire via timeout.
# If no entries, hide immediately with fade.
if _log_entries.is_empty():
hide_dialogue()
## Hide the entire panel with fade.
func hide_dialogue() -> void:
if not _is_showing:
return
if _in_player_conversation:
_end_player_conversation()
return # _end_player_conversation may call hide_dialogue if log is empty
_is_showing = false
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func():
visible = false
GameState.dialogue_active = false # D-064: unblock movement after fade completes
)
func is_dialogue_active() -> bool:
return _in_player_conversation or GameState.dialogue_active
func has_active_entries() -> bool:
return _log_entries.size() > 0 or _in_player_conversation
# -- Options --
func _show_options(options: Array) -> void:
var sorted_opts: Array = options.duplicate()
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
var count := mini(sorted_opts.size(), MAX_OPTIONS)
@@ -103,7 +302,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false)
# RichTextLabel for BBCode support (D-063: confrontation italic)
var label := RichTextLabel.new()
label.bbcode_enabled = true
label.fit_content = true
@@ -117,13 +315,11 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
else:
label.text = raw_text
# Click handling
var idx := i
label.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_option_pressed(idx)
)
# Hover color
label.mouse_entered.connect(_make_hover_on(label))
label.mouse_exited.connect(_make_hover_off(label))
@@ -133,46 +329,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
_option_texts.append(raw_text)
_option_is_confrontation.append(is_confrontation)
# Show with fade
visible = true
mouse_filter = Control.MOUSE_FILTER_STOP
_is_showing = true
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
# D-061: auto-pause in single-player when dialogue opens
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
# Hide dialogue with fade (D-064: 300ms)
func hide_dialogue() -> void:
if not _is_showing:
return
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
# D-061: unpause when dialogue closes
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func():
visible = false
_clear_options()
GameState.dialogue_active = false # D-064: unblock movement after fade completes
)
func is_dialogue_active() -> bool:
return _is_showing or GameState.dialogue_active
func _on_option_pressed(index: int) -> void:
if index >= _option_controls.size():
@@ -181,46 +337,40 @@ func _on_option_pressed(index: int) -> void:
var text: String = _option_texts[index] if index < _option_texts.size() else ""
var is_confront: bool = _option_is_confrontation[index] if index < _option_is_confrontation.size() else false
# Append player's chosen response to the log
append_player_line(_npc_name, text)
if is_confront:
_start_confrontation_beat(rid, text)
else:
option_selected.emit(rid, text)
hide_dialogue()
_end_player_conversation()
# D-063: Confrontation beat — delay before sending response.
# 1. Dim dialogue to 70%, show monologue, dip audio
# 2. Wait CONFRONTATION_BEAT_DURATION
# 3. Emit option_selected, restore audio, hide dialogue
# -- Confrontation beat (D-063) --
func _start_confrontation_beat(response_id: String, text: String) -> void:
# Disable option clicks during beat
for ctrl in _option_controls:
if is_instance_valid(ctrl):
ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Dim dialogue box
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
# D-063: monologue beat — text from ui-strings.yaml (D-042)
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
# D-063: audio dip via AudioManager
AudioManager.apply_dip("confrontation")
# Delay, then complete
_beat_tween = create_tween()
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
_beat_tween.tween_callback(func():
AudioManager.clear_dip()
option_selected.emit(response_id, text)
hide_dialogue()
_end_player_conversation()
)
# Cancel an in-flight confrontation beat (e.g. player walks away mid-beat).
func _cancel_beat() -> void:
if _beat_tween and _beat_tween.is_valid():
_beat_tween.kill()
@@ -228,6 +378,172 @@ func _cancel_beat() -> void:
AudioManager.clear_dip()
# -- Log rendering --
## Rebuild the dialogue log BBCode from all non-expired entries.
func _rebuild_log() -> void:
if not dialogue_log:
return
var now := Time.get_ticks_msec()
var bbcode := ""
for entry in _log_entries:
var alpha: float = 1.0
if not entry.pinned:
var age_sec: float = (now - entry.timestamp_msec) / 1000.0
if age_sec > _entry_lifetime:
alpha = clampf(1.0 - (age_sec - _entry_lifetime) / _entry_fade, 0.0, 1.0)
if entry.is_passive:
alpha *= _passive_opacity
if alpha <= 0.0:
continue
var line := _format_entry(entry, alpha)
if not bbcode.is_empty():
bbcode += "\n"
bbcode += line
dialogue_log.text = bbcode
## Format a single log entry as BBCode.
## Hoshe #2: escape BBCode brackets in server-sourced strings.
## Araminta: passive lines get ┃ prefix + desaturated colours.
## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You).
func _format_entry(entry: Dictionary, alpha: float) -> String:
var speaker: String = _escape_bbcode(entry.speaker)
var target: String = _escape_bbcode(entry.target)
var text: String = _escape_bbcode(entry.text)
var is_passive: bool = entry.is_passive
var speaker_color := _color_for_name(entry.speaker)
var target_color := _color_for_name(entry.target)
# Desaturate passive name colours (Araminta review)
if is_passive:
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
target_color = _desaturate(target_color, PASSIVE_DESATURATION)
var sc := _color_with_alpha(speaker_color, alpha)
var ac := _color_with_alpha(_arrow_color, alpha)
var tc := _color_with_alpha(target_color, alpha)
var txc := _color_with_alpha(_speech_color, alpha)
var prefix := PASSIVE_GLYPH if is_passive else ""
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
var involves_player := entry.speaker == PLAYER_NAME or entry.target == PLAYER_NAME
if involves_player and not is_passive:
# Just "Speaker: text" or "You: text"
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
prefix, sc, speaker, txc, text
]
else:
# Full "Speaker → Target: text" for overheard
return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
prefix, sc, speaker, ac, tc, target, txc, text
]
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
static func _escape_bbcode(text: String) -> String:
return text.replace("[", "[lb]")
## Get a stable color for a character name, with contrast floor enforcement.
func _color_for_name(char_name: String) -> Color:
if char_name == PLAYER_NAME:
return _player_color
if _npc_colors.is_empty():
return _speech_color
var idx := absi(char_name.hash()) % _npc_colors.size()
var color := _npc_colors[idx]
return _enforce_contrast(color)
## Enforce minimum luminance so name colours remain readable against dark BG (Araminta #3).
static func _enforce_contrast(color: Color) -> Color:
var lum := color.r * 0.299 + color.g * 0.587 + color.b * 0.114
if lum < MIN_CONTRAST_LUMINANCE:
var boost := MIN_CONTRAST_LUMINANCE / maxf(lum, 0.001)
return Color(
minf(color.r * boost, 1.0),
minf(color.g * boost, 1.0),
minf(color.b * boost, 1.0),
color.a
)
return color
## Desaturate a colour by a factor (0.0 = no change, 1.0 = full greyscale).
static func _desaturate(color: Color, amount: float) -> Color:
var grey := color.r * 0.299 + color.g * 0.587 + color.b * 0.114
return Color(
lerpf(color.r, grey, amount),
lerpf(color.g, grey, amount),
lerpf(color.b, grey, amount),
color.a
)
## Convert a Color to a hex string with alpha baked in.
static func _color_with_alpha(base: Color, alpha: float) -> String:
return Color(base.r, base.g, base.b, base.a * alpha).to_html(true)
# -- Entry expiry --
## Remove fully expired entries. Mark dirty if any fading entries exist.
## Pinned entries (active conversation) skip expiry entirely (Araminta #2).
func _expire_entries() -> void:
if _log_entries.is_empty():
return
var now := Time.get_ticks_msec()
var total_lifetime_msec: int = int((_entry_lifetime + _entry_fade) * 1000.0)
var removed := false
var has_fading := false
# Remove expired non-pinned entries from the front (oldest first)
while _log_entries.size() > 0:
var entry: Dictionary = _log_entries[0]
if entry.pinned:
break # Pinned entries never expire
var age: int = now - entry.timestamp_msec
if age < total_lifetime_msec:
# Check if this entry is in the fading phase
if age > int(_entry_lifetime * 1000.0):
has_fading = true
break
_log_entries.remove_at(0)
removed = true
# Check remaining entries for fading state
if not has_fading:
for entry in _log_entries:
if not entry.pinned:
var age: int = now - entry.timestamp_msec
if age > int(_entry_lifetime * 1000.0):
has_fading = true
break
if removed or has_fading:
_log_dirty = true
if removed and _log_entries.is_empty() and not _in_player_conversation:
hide_dialogue()
# -- Visibility --
## Ensure panel is visible (fade in if needed).
func _ensure_visible() -> void:
if _is_showing:
return
visible = true
_is_showing = true
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
func _clear_options() -> void:
for ctrl in _option_controls:
if is_instance_valid(ctrl):
@@ -238,7 +554,7 @@ func _clear_options() -> void:
_option_is_confrontation.clear()
# Hover callbacks — closures that capture the label reference.
# Hover callbacks
static func _make_hover_on(label: RichTextLabel) -> Callable:
return func():
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
+7 -5
View File
@@ -20,9 +20,9 @@ anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -416.0
offset_left = -320.0
offset_top = -200.0
offset_right = 416.0
offset_right = 320.0
grow_horizontal = 2
grow_vertical = 0
@@ -37,12 +37,14 @@ theme_override_constants/margin_bottom = 14
layout_mode = 2
theme_override_constants/separation = 10
[node name="NpcSpeech" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
[node name="DialogueLog" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
text = ""
fit_content = true
scroll_active = false
fit_content = false
scroll_active = true
scroll_following = true
[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
+150 -35
View File
@@ -1,48 +1,163 @@
extends Control
# Internal monologue display (per D-015)
# Shows character's internal thoughts as text overlay
# Internal monologue display — multi-line, priority-queued (per D-016, #122).
# Per Tyre architecture review, Sprint 14.
#
# Up to MAX_VISIBLE lines display simultaneously in a VBoxContainer.
# Additional arrivals queue up to MAX_QUEUE depth; lowest-priority entry is
# dropped when the queue is full and an equal-or-higher-priority line arrives
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
#
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
@onready var text_panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
const MAX_VISIBLE: int = 3
const MAX_QUEUE: int = 5
const STAGGER_SEC: float = 0.15
const FADE_IN_SEC: float = 0.3
const FADE_OUT_SEC: float = 0.5
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
# standard opacity = 0.85, urgent opacity = 1.0.
# Source: Tyre architecture review, Sprint 14.
const _LATTICE_COLORS: Dictionary = {
"lattice_augmented": { # detective
"standard": Color("#d0d4e0"),
"urgent": Color("#e0e8f8"),
},
"lattice_baseline": { # smuggler
"standard": Color("#d8d0c4"),
"urgent": Color("#f0e4d4"),
},
}
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
@onready var _vbox: VBoxContainer = $VBoxContainer
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
var _visible: Array[Dictionary] = []
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
var _queue: Array[Dictionary] = []
# Msec timestamp when the next fade-in may begin (stagger enforcement)
var _next_fade_in_msec: float = 0.0
var fade_timer: float = 0.0
var fade_duration: float = 5.0 # Display duration before fade
var is_visible: bool = false
var _active_tween: Tween = null
func _ready() -> void:
print("MonologueDisplay: Initialized")
text_panel.modulate.a = 0.0
is_visible = false
pass
func _process(delta: float) -> void:
# Auto-fade after display
if is_visible:
fade_timer += delta
if fade_timer >= fade_duration:
_fade_out()
# Expire visible lines
for slot in _visible.duplicate():
slot.expire_timer -= delta
if slot.expire_timer <= 0.0:
_retire_slot(slot)
# Show internal monologue text
func show_monologue(text: String, duration: float = 5.0) -> void:
text_label.text = text
fade_duration = duration
fade_timer = 0.0
is_visible = true
# Drain queue into available visible slots (one per stagger interval)
if not _queue.is_empty() and _visible.size() < MAX_VISIBLE:
var now := float(Time.get_ticks_msec())
if now >= _next_fade_in_msec:
var next: Dictionary = _queue.pop_front()
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
# Cancel any active tween before starting a new one
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(text_panel, "modulate:a", 1.0, 0.3)
# Fade out the monologue
func _fade_out() -> void:
if not is_visible:
# Display a monologue line.
# priority: higher number = more important (default 2; urgent beats normal).
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
# Empty text is silently ignored — no slot created, no queue entry.
# lattice_profile is read from GameState here and passed down — renderer stays
# decoupled from the autoload (D-020 renderer contract).
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
if text.is_empty():
return
var profile := GameState.lattice_profile
var now := float(Time.get_ticks_msec())
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
_show_line(text, duration, priority, is_urgent, profile)
else:
_enqueue(text, duration, priority, is_urgent, profile)
is_visible = false
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(text_panel, "modulate:a", 0.0, 0.5)
# ---------------------------------------------------------------------------
# Internal
# ---------------------------------------------------------------------------
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
var line_node := _build_line_node(text, is_urgent, lattice_profile)
_vbox.add_child(line_node)
var slot := {
node = line_node,
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
priority = priority,
tween = null as Tween,
}
_visible.append(slot)
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
line_node.modulate.a = 0.0
var tween := create_tween()
slot.tween = tween
var target_opacity := 1.0 if is_urgent else 0.85
tween.tween_property(line_node, "modulate:a", target_opacity, FADE_IN_SEC)
func _retire_slot(slot: Dictionary) -> void:
_visible.erase(slot)
var node: Node = slot.node
var t: Tween = slot.tween
if t and t.is_valid():
t.kill()
var tween := create_tween()
tween.tween_property(node, "modulate:a", 0.0, FADE_OUT_SEC)
tween.tween_callback(node.queue_free)
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
if _queue.size() < MAX_QUEUE:
_queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile})
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
else:
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
var lowest := _lowest_priority_idx()
if priority >= _queue[lowest].priority:
_queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile}
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
# else: incoming line is strictly lower priority — silently drop; no sort needed
func _lowest_priority_idx() -> int:
var idx := 0
for i in range(1, _queue.size()):
if _queue[i].priority < _queue[idx].priority:
idx = i
return idx
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \
else palette.get("standard", _FALLBACK_STANDARD)
var container := MarginContainer.new()
container.add_theme_constant_override("margin_left", 4)
container.add_theme_constant_override("margin_right", 4)
container.add_theme_constant_override("margin_top", 2)
container.add_theme_constant_override("margin_bottom", 2)
var label := RichTextLabel.new()
label.bbcode_enabled = true
label.fit_content = true
label.scroll_active = false
label.add_theme_font_size_override("normal_font_size", 13)
# Escape [ to prevent BBCode injection from server-sourced text.
# [lb] is Godot's BBCode entity for a literal left bracket.
var safe_text := text.replace("[", "[lb]")
label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), safe_text]
container.add_child(label)
return container
+13 -27
View File
@@ -2,37 +2,23 @@
[ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"]
; Monologue display area — bottom-left of viewport.
; Anchors: 5% left margin, 50% max width, 25% height from bottom (spec §3.1, z-layer 7).
; Lines are created dynamically inside VBoxContainer by monologue_display.gd.
[node name="MonologueDisplay" type="Control"]
layout_mode = 3
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -150.0
grow_horizontal = 2
layout_mode = 1
anchor_left = 0.05
anchor_top = 0.75
anchor_right = 0.55
anchor_bottom = 0.98
grow_horizontal = 1
grow_vertical = 0
clip_contents = true
mouse_filter = 2
script = ExtResource("1_monologue")
[node name="PanelContainer" type="PanelContainer" parent="."]
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_left = 100.0
offset_right = -100.0
offset_bottom = 120.0
grow_horizontal = 2
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12
[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
layout_mode = 2
bbcode_enabled = true
text = "Internal monologue will appear here..."
fit_content = true
scroll_active = false
anchor_bottom = 1.0
theme_override_constants/separation = 4
+159
View File
@@ -0,0 +1,159 @@
extends Control
## #528: Audio settings dialog — 5-bus volume sliders.
## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button.
## Volumes persist via AudioManager._save_prefs() on each slider change.
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
const TITLE_COLOR := Color("#4a9ebb")
const FONT_SIZE := 14
const BOX_WIDTH := 460
const BOX_HEIGHT := 340
const PADDING := 20
const ROW_HEIGHT := 36
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
const BUS_ROWS: Array = [
["Music", "Music"],
["Ambient", "Ambient"],
["World SFX", "WorldSFX"],
["Player Actions", "PlayerActions"],
["UI Sounds", "UISounds"],
]
var _active: bool = false
var _container: VBoxContainer = null
signal closed
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
func open() -> void:
if _active:
return
_active = true
visible = true
_build_ui()
queue_redraw()
func close() -> void:
if not _active:
return
_active = false
visible = false
_destroy_ui()
queue_redraw()
closed.emit()
func is_open() -> bool:
return _active
func _build_ui() -> void:
var vp_size := get_viewport_rect().size
var box_pos := Vector2(
(vp_size.x - BOX_WIDTH) / 2.0,
(vp_size.y - BOX_HEIGHT) / 2.0
)
_container = VBoxContainer.new()
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
_container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0)
_container.add_theme_constant_override("separation", 4)
add_child(_container)
for row_data in BUS_ROWS:
var label_text: String = row_data[0]
var bus_name: String = row_data[1]
var hbox := HBoxContainer.new()
hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
_container.add_child(hbox)
var label := Label.new()
label.text = label_text
label.custom_minimum_size = Vector2(150, 0)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(label)
var slider := HSlider.new()
slider.min_value = -40.0
slider.max_value = 0.0
slider.step = 0.5
slider.value = AudioManager.get_volume(bus_name)
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(slider)
var db_label := Label.new()
db_label.text = _format_db(slider.value)
db_label.custom_minimum_size = Vector2(70, 0)
db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
db_label.add_theme_font_size_override("font_size", FONT_SIZE)
db_label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(db_label)
slider.value_changed.connect(func(value: float) -> void:
AudioManager.set_volume(bus_name, value)
db_label.text = _format_db(value)
)
# Spacer
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 8)
_container.add_child(spacer)
# Close button
var close_btn := Button.new()
close_btn.text = "CLOSE"
close_btn.add_theme_font_size_override("font_size", FONT_SIZE)
close_btn.pressed.connect(close)
_container.add_child(close_btn)
func _destroy_ui() -> void:
if _container:
_container.queue_free()
_container = null
func _draw() -> void:
if not _active:
return
var viewport_size := get_viewport_rect().size
# Dim overlay
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
# Dialog box
var box_pos := Vector2(
(viewport_size.x - BOX_WIDTH) / 2.0,
(viewport_size.y - BOX_HEIGHT) / 2.0
)
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
# Title
var font := ThemeDB.fallback_font
draw_string(font,
box_pos + Vector2(PADDING, PADDING + 18),
"AUDIO SETTINGS",
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
static func _format_db(db: float) -> String:
if db <= -40.0:
return "mute"
return "%d dB" % int(db)
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"]
; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle
[node name="SettingsDialog" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_settings")
+288
View File
@@ -0,0 +1,288 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "dialogue-line.schema.json",
"title": "Dialogue Line — canonical D-035 tag taxonomy",
"description": "Canonical single-line schema for dialogue and monologue pools. Implements the converged tag taxonomy from D-035 (6 structural + 3 selection + 2 authoring-only tags). Monologue-specific additions (character, trigger, prerequisite) are defined in $defs/monologue_extension. Mood vocabulary renamed Sprint 14 to match voice guide (anxious/frustrated/content/suspicious/warm/hostile/relieved/focused).",
"type": "object",
"required": ["id", "text", "role", "access", "trust", "situation"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_(d|m)_[0-9]{3}$",
"description": "Stable machine-parseable line ID: {template}_{d|m}_{###}. d = dialogue, m = monologue."
},
"text": {
"type": "string",
"minLength": 1,
"description": "The authored line text."
},
"role": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Template-defined role slug (e.g. dock-worker, bar-owner, player_character). Not NPC name — NPC assignment is runtime."
},
"access": {
"type": "array",
"items": {
"type": "string",
"enum": ["public", "insider", "authority", "peer", "hostile"]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-028 Layer 1: access tiers this line is eligible for. List — a line can be eligible for multiple tiers. Hard filter."
},
"trust": {
"type": "string",
"enum": ["surface", "real", "secret"],
"description": "D-028 Layer 3: minimum trust tier required. Hard filter. Ordering: surface < real < secret."
},
"situation": {
"type": "array",
"items": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-028 Layer 2: situations in which this line can fire. 14 v0.1 values (13 original + greeting added Sprint 8 for PC dialogue initial contact lines). NOTE: 'greeting' is not yet in server/src/content/line_pool.rs — lines using it will be skipped until Rust is updated."
},
"topic": {
"type": "array",
"items": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
]
},
"uniqueItems": true,
"description": "D-028 Layer 4: topic tags for weighted selection. 9 v0.1 values. Optional — defaults to empty if omitted. Note: 'crime' deliberately excluded; NPCs think of it as 'cargo' or 'money'."
},
"mood": {
"type": "array",
"items": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
]
},
"uniqueItems": true,
"description": "D-028 Layer 4: mood tags for weighted selection. 8 v0.1 values. Neutral mood = omit tag (untagged lines are always eligible). Renamed Sprint 14 to match voice guide vocabulary."
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Freeform escape hatch for author intent not covered by the structured taxonomy. Not consumed by the engine selection pipeline."
},
"knowledge_grant": {
"type": "object",
"description": "Knowledge the player gains from hearing this line. Feeds into the knowledge graph (D-041).",
"required": ["fact_id", "confidence"],
"additionalProperties": false,
"properties": {
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368)."
},
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "D-041 confidence tier granted."
}
}
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: per-character notes for content with different resonance for smuggler vs detective. NOT consumed by the engine.",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes, context, or intent documentation. NOT consumed by the engine."
}
},
"$defs": {
"monologue_extension": {
"title": "Monologue-specific additions (D-035)",
"description": "Additional required fields for monologue lines. Applied ON TOP OF the base dialogue line schema. Pool-level character partitioning (D-032) is enforced at the pool root, not per-line.",
"type": "object",
"required": ["trigger"],
"properties": {
"character": {
"type": "string",
"enum": ["smuggler", "detective"],
"description": "D-032: hard partition tag. Which playable character this line belongs to. Must match the parent pool's character field."
},
"trigger": {
"type": "string",
"enum": [
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit"
],
"description": "What causes this monologue line to fire. 9 v0.1 trigger types."
},
"prerequisite": {
"description": "Knowledge state gate. null = unconditional (fires whenever triggered). Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
"oneOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
}
}
}
]
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 5,
"description": "Selection priority. Higher = more likely to fire when multiple lines are eligible. Default: 5."
},
"cooldown": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Minimum simulation ticks before this line can fire again. Default: 0 (no cooldown)."
}
}
},
"fact_prerequisite": {
"type": "object",
"required": ["fact_id", "min_confidence"],
"additionalProperties": false,
"properties": {
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368, D-041)."
},
"min_confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "Minimum D-041 confidence level required for this fact."
}
}
},
"attribute_prerequisite": {
"type": "object",
"required": ["entity", "key", "value"],
"additionalProperties": false,
"properties": {
"entity": { "type": "string" },
"key": { "type": "string" },
"value": { "type": "string" }
}
},
"situation_enum": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
],
"description": "14 v0.1 situation values (D-035 + Sprint 8 amendment)."
},
"topic_enum": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
],
"description": "9 v0.1 topic values (D-035)."
},
"mood_enum": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
],
"description": "8 v0.1 mood values. Renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
}
}
}
+19 -6
View File
@@ -90,13 +90,12 @@
"items": {
"type": "string",
"enum": [
"fond", "comfortable", "worried", "suspicious",
"analytical", "conflicted", "concerned", "relieved",
"focused"
"anxious", "frustrated", "content", "suspicious",
"warm", "hostile", "relieved", "focused"
]
},
"uniqueItems": true,
"description": "Mood tags for selection weighting (D-035: list<enum>, v0.1 8 moods + focused added Sprint 8)"
"description": "Mood tags for selection weighting (D-035: list<enum>). 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
},
"tags": {
"type": "array",
@@ -106,14 +105,28 @@
"knowledge_grant": {
"type": "object",
"description": "Knowledge the player gains from hearing this line",
"required": ["fact_id", "confidence"],
"additionalProperties": false,
"properties": {
"fact_id": { "type": "string" },
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
}
},
"required": ["fact_id", "confidence"]
}
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: per-character resonance notes (NOT consumed by engine)",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes (NOT consumed by engine)"
}
}
}
+144 -34
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "monologue-pool.schema.json",
"title": "Monologue Line Pool",
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035).",
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035). Monologue lines carry all 6 D-035 structural tags for schema compliance, but role/access/trust are fixed constants for player-character internal voice (role=player_character, access=[public], trust=surface). The engine does not gate monologue on access or trust — these tags exist for taxonomy uniformity only.",
"type": "object",
"required": ["character", "location", "lines"],
"additionalProperties": false,
@@ -10,12 +10,12 @@
"character": {
"type": "string",
"enum": ["smuggler", "detective"],
"description": "Playable character this pool belongs to (hard partition per D-032)"
"description": "Playable character this pool belongs to (hard partition per D-032). All lines in this pool belong to this character."
},
"location": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Location slug, or 'general' for location-independent lines"
"description": "Location slug, or 'general' for location-independent lines."
},
"lines": {
"type": "array",
@@ -26,70 +26,176 @@
"$defs": {
"monologue_line": {
"type": "object",
"required": ["id", "text", "trigger"],
"required": ["id", "text", "role", "access", "trust", "situation", "trigger"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}"
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "Line text — 160 char max to fit monologue display without scrolling"
"maxLength": 256,
"description": "Line text — 256 char hard cap, aim for ≤160 to avoid line wrap"
},
"role": {
"type": "string",
"const": "player_character",
"description": "D-035 structural tag — always player_character for monologue. Monologue is the player character's internal voice."
},
"access": {
"type": "array",
"items": { "const": "public" },
"minItems": 1,
"maxItems": 1,
"description": "D-035 structural tag — always [public] for monologue. No access gating applies to internal voice."
},
"trust": {
"type": "string",
"const": "surface",
"description": "D-035 structural tag — always surface for monologue. No trust gating applies to internal voice."
},
"situation": {
"type": "array",
"items": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-035 structural tag: situations in which this monologue line is contextually appropriate. 14 v0.1 values. The engine selects using trigger; situation provides additional authoring context for filtering by the caller. NOTE: 'greeting' is not yet in server/src/content/line_pool.rs Situation enum."
},
"trigger": {
"type": "string",
"enum": [
"enter_location", "observe_npc", "hear_sound",
"observe_anomaly", "post_conversation", "discover_evidence",
"witness_interaction", "time_idle", "return_visit"
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit"
],
"description": "What causes this line to fire"
"description": "What causes this line to fire. 9 v0.1 trigger types (D-035 monologue-specific tag)."
},
"prerequisites": {
"type": "object",
"description": "AND-only prerequisite conditions",
"properties": {
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"description": "Knowledge state gate (D-035 monologue-specific tag). null or omitted = unconditional. Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
}
}
}
}
]
},
"topic": {
"type": "array",
"items": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
]
},
"uniqueItems": true,
"description": "D-035 selection tag: topic tags for weighted selection. 9 v0.1 values. Optional."
},
"mood": {
"type": "array",
"items": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
]
},
"uniqueItems": true,
"description": "D-035 selection tag: mood tags for weighted selection. 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 5,
"description": "Selection priority (higher = more likely to fire)"
"description": "Selection priority (higher = more likely to fire when multiple lines are eligible). Default: 5."
},
"cooldown": {
"type": "integer",
"minimum": 0,
"description": "Minimum ticks before this line can fire again"
"default": 0,
"description": "Minimum simulation ticks before this line can fire again. Default: 0."
},
"tags": {
"type": "array",
"items": { "type": "string" }
"items": { "type": "string" },
"description": "D-035 selection tag: freeform tags. Not consumed by the selection pipeline."
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: notes on how this line reads differently for smuggler vs detective. NOT consumed by engine.",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes. NOT consumed by engine."
}
}
},
@@ -98,10 +204,14 @@
"required": ["fact_id", "min_confidence"],
"additionalProperties": false,
"properties": {
"fact_id": { "type": "string" },
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368, D-041)."
},
"min_confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "Minimum D-041 confidence level required."
}
}
},
@@ -1,2 +1,379 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: ring-operative at Maintenance Corridors
# NPC: Generic ring operative (Tier 3 template — members without individual profiles)
# Voice: two registers. Surface: maintenance worker, brief, task-focused. Real: coded, precise, never explicit.
# The crucial thing: both registers sound identical to a casual listener.
# Ring-aware: full ring membership. Knows the route, the window, the players.
# Ring code vocabulary (from Sova texture appendix):
# "running maintenance" = cover for ring operation
# "the usual pickup" = ring cargo handoff
# "cans are ready" = containers cleared for movement
# "manifest is clean" = paperwork cover confirmed
# "Drin was asking" = warning, inspection proximity
# "dead air" = Meridian surveillance gaps
# "the gap" = maintenance corridors, the route itself
# Ticket: #192 | Sprint: 12
location: maintenance-corridors
role: ring-operative
lines:
# ==========================================
# SURFACE COVER — sounds like maintenance work
# ==========================================
- id: maintenance-corridors_d_100
text: "Running maintenance on the B-7 conduit. Could be a while."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, b7]
dual_lens:
smuggler: "Cover phrase. He's not doing maintenance."
detective: "Maintenance worker. Conduit repair. Routine."
notes: "'Running maintenance' is the canonical ring cover phrase."
- id: maintenance-corridors_d_101
text: "Bay four access from here is faster than going around. If you're in a hurry."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, bay-four, spatial]
dual_lens:
smuggler: "He's telling me the route is faster. This is spatial information, not just courtesy."
detective: "Ring operative casually establishes corridor-to-bay-four spatial relationship. Note it."
- id: maintenance-corridors_d_102
text: "Quiet in here tonight. Good working conditions."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, ambient]
dual_lens:
smuggler: "Dead air confirmed. Meridian's not picking this up."
detective: "Routine corridor comment."
- id: maintenance-corridors_d_103
text: "Pael was through here earlier. Seal replacement on the ventilation unit."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [ring-operative, surface-cover, pael, corridor-traffic]
dual_lens:
smuggler: "Pael was through. Legitimate. Corridor's been active — that means traffic covers us."
detective: "References Pael's B-7 presence without suspicion. Maintenance workers know each other's routes."
- id: maintenance-corridors_d_104
text: "C-4 access is at the far end. About three minutes from here."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, c4, spatial]
dual_lens:
smuggler: "He's giving me the timing. Three minutes to C-4 from this point."
detective: "Operative establishes C-4 proximity to maintenance corridor. That's the link."
knowledge_grant:
fact_id: location.smuggling_route
confidence: suspects
# ==========================================
# OPERATIONAL COORDINATION — insider/real only
# ==========================================
- id: maintenance-corridors_d_105
text: "Cans are ready. Bay four, per the schedule."
role: ring-operative
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, cans-ready]
dual_lens:
smuggler: "Containers cleared. Bay four. We're on."
detective: "Never hears this line."
- id: maintenance-corridors_d_106
text: "Manifest is clean. Window opens in eighteen minutes."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, manifest-clean, window]
dual_lens:
smuggler: "Paperwork's set. Eighteen minutes. That's earlier than planned — adjust."
detective: "Never hears this line."
- id: maintenance-corridors_d_107
text: "Usual pickup. Route's the same."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition, routine]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, usual-pickup]
dual_lens:
smuggler: "Same route, same handoff. No variations."
detective: "Never hears this line."
- id: maintenance-corridors_d_108
text: "The gap's clear. Scan's done at 14:20. Next window's yours."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, scan-gap, surveillance]
dual_lens:
smuggler: "14:20 scan, then the window opens. Twenty-five minutes clear."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_109
text: "Drin was asking about B-7. Twice today."
role: ring-operative
access: [insider]
trust: real
situation: [routine, confrontation]
topic: [danger, colleague]
mood: [concerned]
tags: [ring-operative, ring-ops, coded, drin-asking, warning]
dual_lens:
smuggler: "Drin's circling B-7. That's a warning. Push the pickup or hold it."
detective: "Never hears this line."
knowledge_grant:
fact_id: investigation.drin_inspection_pattern
confidence: suspects
- id: maintenance-corridors_d_110
text: "Dead air through here to the C-4 junction. No coverage."
role: ring-operative
access: [insider]
trust: real
situation: [routine]
topic: [routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, dead-air, surveillance]
dual_lens:
smuggler: "Full confirmation. Meridian doesn't reach this section."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_111
text: "Voss confirmed the hold clears at 1400. He'll run the bay himself."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, voss, bay-four, clearance]
dual_lens:
smuggler: "Voss is personally running bay four at 1400. That's the signal the window is clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_112
text: "Kael's on dock. He knows."
role: ring-operative
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [colleague, routine]
mood: [focused]
tags: [ring-operative, ring-ops, kael, coordination]
dual_lens:
smuggler: "Kael's in position. Ring is coordinated."
detective: "Never hears this line."
# ==========================================
# PRESSURE / WARNING SIGNALS
# ==========================================
- id: maintenance-corridors_d_113
text: "Commission eyes on the terminal floor. Not here yet."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation, investigation]
topic: [danger, institution]
mood: [concerned]
tags: [ring-operative, ring-ops, commission, warning]
dual_lens:
smuggler: "Detective is visible on the main floor. Corridors are still clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_114
text: "Hold the run. Drin's in the corridor section."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation]
topic: [danger]
mood: [concerned]
tags: [ring-operative, ring-ops, drin, abort-signal]
dual_lens:
smuggler: "Stop. Drin's in position. The window's closed."
detective: "Never hears this line."
- id: maintenance-corridors_d_115
text: "Maret filed again. Voss has it. Don't move anything tonight."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation, routine]
topic: [danger, institution]
mood: [concerned]
tags: [ring-operative, ring-ops, maret, voss, caution]
dual_lens:
smuggler: "Maret flagged something. Voss is managing it. Stand down tonight."
detective: "Never hears this line."
- id: maintenance-corridors_d_116
text: "We're clean. Same time tomorrow."
role: ring-operative
access: [insider]
trust: real
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, ring-ops, run-complete, closing]
dual_lens:
smuggler: "Run's done. No issues. Tomorrow."
detective: "Never hears this line."
# ==========================================
# IF DETECTIVE SOMEHOW ENTERS — surface only
# ==========================================
- id: maintenance-corridors_d_117
text: "Restricted access back here. Service personnel only."
role: ring-operative
access: [authority, public]
trust: surface
situation: [investigation, confrontation]
topic: [institution, routine]
mood: [wary]
tags: [ring-operative, detective-path, access-denial]
- id: maintenance-corridors_d_118
text: "I'm running maintenance. If there's a Commission access request, file it through Voss."
role: ring-operative
access: [authority]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [wary]
tags: [ring-operative, detective-path, voss-redirect]
dual_lens:
smuggler: "Never encounters this."
detective: "Operative redirects to Voss. Everyone redirects to Voss. That's structural, not coincidence."
- id: maintenance-corridors_d_119
text: "Nothing unusual back here. Conduit work. Same every cycle."
role: ring-operative
access: [authority, public]
trust: surface
situation: [investigation]
topic: [routine]
mood: [wary]
tags: [ring-operative, detective-path, deflection]
# ==========================================
# GENERATION PASS — surface cover variants
# Surface-only: covers routine corridor presence without operational tells.
# ==========================================
- id: maintenance-corridors_d_120
text: "Power relay's been cycling weird. I'm watching it."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_121
text: "Pael was supposed to handle B-7. He didn't show."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [ring-operative, surface-cover, pael, generation-pass]
- id: maintenance-corridors_d_122
text: "Watch the floor here. Drain panel's been loose three cycles."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_123
text: "Airflow's better down the B-section. C-section's got the recycler issue."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, spatial, generation-pass]
- id: maintenance-corridors_d_124
text: "You want the service bay, it's left at the junction. Don't go right — that's the restricted access."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, navigation, generation-pass]
dual_lens:
smuggler: "He's directing people away from the route. Social traffic management."
detective: "Operative redirects me away from C-4 access junction instinctively."
- id: maintenance-corridors_d_125
text: "Long shift. These corridors look the same after a while."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [tired]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_126
text: "Scan's clear. I checked it an hour ago."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, generation-pass]
@@ -0,0 +1,77 @@
# Dialogue: transit-worker at Maintenance Corridors
# NPC: Tev Osel (Tier 3, NOBODY/CIVILIAN)
# Voice: patient, mildly distracted, confused why anyone is asking him anything.
# KEY LINE: maintenance-corridors_d_001 — sounds like surveillance positioning inquiry
# Actually: Tev missed Kosse at the corridor junction and is looking for them
# Ticket: #307 | Sprint: 12
location: maintenance-corridors
role: transit-worker
lines:
# ==========================================
# "SEEMS IMPORTANT BUT ISN'T" LINE
# Sounds like Tev is checking who came through a surveillance point.
# Actually: Tev missed his partner at the regular meeting spot.
# ==========================================
- id: maintenance-corridors_d_001
text: "You see which way Kosse went? Should've been through here twenty minutes ago."
role: transit-worker
access: [public]
trust: surface
situation: [routine, observation]
topic: [colleague, personal]
mood: [comfortable]
tags: [tev, seems-important-but-isnt, waiting, kosse]
dual_lens:
smuggler: "Someone asking who came through the corridor. Tev. Harmless — checking for a partner."
detective: "NPC at corridor junction, tracking who passed through. Possible lookout. Follow up: who is Kosse?"
notes: >
THE key flat NPC line for Tev. Sounds like Tev monitors corridor traffic
for operational reasons. Is someone who missed a meeting time with their
partner. The detective's suspicion is reasonable; it resolves to nothing.
# ==========================================
# ROUTINE LINES
# ==========================================
- id: maintenance-corridors_d_002
text: "They shifted the freight route again. Always forget which junction."
role: transit-worker
access: [public]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_003
text: "Just on break. Out of your way in a minute."
role: transit-worker
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [tev, incidental]
- id: maintenance-corridors_d_004
text: "Late freight route runs long. Can't do anything about it."
role: transit-worker
access: [public]
trust: surface
situation: [routine, night_shift]
topic: [routine, cargo]
mood: [comfortable]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_005
text: "Waiting for someone. Shouldn't be long."
role: transit-worker
access: [public]
trust: surface
situation: [routine, observation]
topic: [personal]
mood: [comfortable]
tags: [tev, waiting]
@@ -1,2 +1,382 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bar-owner at The Last Shift
# NPC: Lera Osk (Tier 2, SYSTEM/OPERATOR)
# Voice: direct, dry. Runs her bar like a territory. Not political — protective.
# Ring-aware: knows it exists in her bar. Doesn't ask. Doesn't interfere. Not complicit.
# Torek arrangement: she gives him a tab specifically — Commission visibility is useful.
# Ticket: #191 | Sprint: 12
location: the-last-shift
role: bar-owner
lines:
# ==========================================
# GREETINGS — territorial welcome
# ==========================================
- id: the-last-shift_d_100
text: "Usual?"
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, economy-of-words]
- id: the-last-shift_d_101
text: "Long shift?"
role: bar-owner
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting]
- id: the-last-shift_d_102
text: "Bar's clean, drink's cold. That's the deal."
role: bar-owner
access: [public]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, house-rules]
- id: the-last-shift_d_103
text: "Sess'll get you. I've got something in the back."
role: bar-owner
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, redirection]
# ==========================================
# BAR MANAGEMENT — house rules, operations
# ==========================================
- id: the-last-shift_d_104
text: "Fights go outside. First and last warning."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [confrontation, routine]
topic: [routine]
mood: [comfortable]
tags: [lera, house-rules, authority]
- id: the-last-shift_d_105
text: "Tab closes at end of cycle. Not at end of your memory."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, tab, house-rules]
- id: the-last-shift_d_106
text: "Torek's running a tab. That's fine. Don't ask me why."
role: bar-owner
access: [peer]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [lera, torek, tab-arrangement]
dual_lens:
smuggler: "Lera chose to give Torek credit. Commission man. She's keeping him visible and comfortable. Smart."
detective: "Bar owner specifically maintains a tab for a Commission officer. Useful relationship or leverage?"
notes: "Lera's Torek arrangement: she keeps him comfortable because knowing where the Commission officer is drinking is better than not knowing."
- id: the-last-shift_d_107
text: "Lattice stays off at the bar. Sign's on the wall."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [lera, lattice-free, house-rules]
notes: "Not legally enforceable — Lera knows this. It's about atmosphere, not compliance."
- id: the-last-shift_d_108
text: "Grain spirit's back. Limited run. Don't make it last two nights."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [lera, drinks, community]
# ==========================================
# PEOPLE — Lera knows everyone
# ==========================================
- id: the-last-shift_d_109
text: "Kael's in the back. If you're looking."
role: bar-owner
access: [peer, insider]
trust: surface
situation: [arrival, routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [lera, kael, spatial-information]
dual_lens:
smuggler: "Good. She keeps track. That's what she does."
detective: "Lera knows where Kael Davan is without being asked. She tracks her regulars."
- id: the-last-shift_d_110
text: "Naia's been in early the last few nights. Doesn't say much."
role: bar-owner
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, naia, observation]
dual_lens:
smuggler: "Lera noticed Naia's off. She notices everything. She's saying it because she wants someone to do something."
detective: "Bar owner volunteers behavioral change in Naia Tamm. Lera observes her regulars carefully."
- id: the-last-shift_d_111
text: "Ren? Been here two months. Quiet, pays their tab. That's all I can tell you."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, ren, drifter]
dual_lens:
smuggler: "That's Lera's version of a clean assessment. She'd say more if she suspected something."
detective: "Lera's read on Ren: quiet, no trouble. If Lera's not worried, the baseline is probably accurate."
- id: the-last-shift_d_112
text: "Tev? Good worker. Three years. Minds their own."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, tev, endorsement]
dual_lens:
smuggler: "Lera vouching for Tev. She doesn't do that for people she doesn't trust."
detective: "Lera's read on Tev is straightforward. 'Minds their own' from Lera means she's tested it."
# ==========================================
# RING-ADJACENT — Lera's knowledge edge
# ==========================================
- id: the-last-shift_d_113
text: "Whatever's happening in the back corner isn't my business. Don't make it my business."
role: bar-owner
access: [insider]
trust: real
situation: [confrontation, alone]
topic: [danger, routine]
mood: [focused]
tags: [lera, ring-ops, willful-ignorance]
dual_lens:
smuggler: "She knows. She's drawing her line. That's the deal."
detective: "Never hears this line."
notes: "Lera's arrangement: she doesn't ask, she doesn't see, and she keeps things stable. Pragmatic, not complicit."
- id: the-last-shift_d_114
text: "Kael's got my number. He's been a regular long enough that that means something."
role: bar-owner
access: [peer]
trust: real
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, kael, loyalty]
dual_lens:
smuggler: "Lera's loyal to Kael. Not to the ring. There's a difference and it matters."
detective: "Lera's personal loyalty to Kael Davan is explicit. She'll protect him over process."
# ==========================================
# DETECTIVE ENCOUNTER — neutral, unwelcoming
# ==========================================
- id: the-last-shift_d_115
text: "Commission's welcome. Like everyone else. Bar rules apply."
role: bar-owner
access: [authority, public]
trust: surface
situation: [arrival, investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [lera, detective-path, neutral-welcome]
- id: the-last-shift_d_116
text: "If someone in my bar has something to say to Commission, they'll say it. I don't push that."
role: bar-owner
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [comfortable]
tags: [lera, detective-path, institutional-independence]
- id: the-last-shift_d_117
text: "I've run this bar twelve years without a Commission complaint. I'd like to keep it that way."
role: bar-owner
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [institution, routine]
mood: [wary]
tags: [lera, detective-path, authority-assertion]
- id: the-last-shift_d_118
text: "I see a lot. I don't always remember all of it."
role: bar-owner
access: [authority]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [lera, detective-path, careful-language]
dual_lens:
smuggler: "Never encounters this line."
detective: "Lera is very deliberately declining to volunteer what she knows. That's not ignorance — that's a position."
# ==========================================
# ELEVATED TRUST — what Lera actually says
# ==========================================
- id: the-last-shift_d_119
text: "Naia's been asking questions I can't answer. Someone should."
role: bar-owner
access: [peer, insider]
trust: real
situation: [routine, alone]
topic: [colleague, danger]
mood: [concerned]
tags: [lera, naia, threshold-line]
dual_lens:
smuggler: "Lera's telling me to do something about Naia. She's right."
detective: "Lera is flagging Naia Tamm as someone who's looking for answers. That's a conversation I need to have."
# ==========================================
# SIGN-OFF / CLOSING TIME
# ==========================================
- id: the-last-shift_d_120
text: "Last call. Finish up."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, closing]
- id: the-last-shift_d_121
text: "Bar's closed. Come back next cycle."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, closing]
- id: the-last-shift_d_122
text: "Good night. Stay out of trouble."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_123
text: "You look tired. Sit."
role: bar-owner
access: [peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_124
text: "Same as yesterday?"
role: bar-owner
access: [peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_125
text: "New face. Bar rules are on the wall."
role: bar-owner
access: [public]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, newcomer, generation-pass]
- id: the-last-shift_d_126
text: "No trouble tonight. I mean it."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_127
text: "Sess'll get you settled. I'm in the back."
role: bar-owner
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, redirect, generation-pass]
- id: the-last-shift_d_128
text: "We close at 2300. Don't make me say it twice."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_129
text: "Good crowd tonight. Quiet enough."
role: bar-owner
access: [peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [lera, ambient, generation-pass]
- id: the-last-shift_d_130
text: "Bar's yours. I've got accounts."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [lera, ambient, generation-pass]
@@ -1,2 +1,360 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bar-regular at The Last Shift
# NPC: Generic regular (Tier 3 template role — transit district workers, post-shift)
# Voice: off-duty, community-minded. Complains, gossips, opinionates. Belongs here.
# Ring-aware: varies. Most know something's off at the terminal. None know specifics.
# Ticket: #191 | Sprint: 12
location: the-last-shift
role: bar-regular
lines:
# ==========================================
# GREETINGS — familiar, community register
# ==========================================
- id: the-last-shift_d_300
text: "Long one today?"
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting]
- id: the-last-shift_d_301
text: "You're in early. Good shift or bad shift?"
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting]
- id: the-last-shift_d_302
text: "Grain spirit's back. I already had two. Don't judge."
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting, drinks]
# ==========================================
# TERMINAL FLOOR — off-duty opinions
# ==========================================
- id: the-last-shift_d_303
text: "Push today. Bay six was backed up until 2000. Voss wasn't happy."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [bar-regular, terminal, voss]
- id: the-last-shift_d_304
text: "Maret flagged another one. Nobody knows what Voss does with them."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, cargo]
mood: [wary]
tags: [bar-regular, maret, voss, discrepancy]
dual_lens:
smuggler: "They're talking about Maret's flags as general knowledge. That's a wider problem than I thought."
detective: "Maret's pattern is common knowledge on the floor. Multiple witnesses, informal. That can be confirmed."
- id: the-last-shift_d_305
text: "Bay four's been held all week. Three cycles. Nobody's said what's in it."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [wary]
tags: [bar-regular, bay-four, observation]
dual_lens:
smuggler: "They've noticed bay four. It's becoming visible. Not good."
detective: "Bay four hold is common knowledge at worker level. That means it predates any routine explanation."
- id: the-last-shift_d_306
text: "Transition window's the worst part of the job. Twenty minutes to hand off and nothing ever lines up."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, transition-window, complaint]
dual_lens:
smuggler: "The window is their biggest frustration. And their biggest frustration is the ring's biggest asset."
detective: "Workers discuss the transition window as a systematic failure. The gap is known."
- id: the-last-shift_d_307
text: "Commission audit's coming cycle fifteen. Everyone's running around double-checking everything."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [wary]
tags: [bar-regular, commission, audit]
dual_lens:
smuggler: "Cycle fifteen. That's the deadline. Get clean before then."
detective: "Workers are responding to the audit announcement. Behavioral change in the workforce. Note who isn't anxious."
- id: the-last-shift_d_308
text: "Voss has been on the floor more than usual this cycle. Watching everything."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [institution, colleague]
mood: [wary]
tags: [bar-regular, voss, observation]
dual_lens:
smuggler: "Voss is visible more. Is that heat from above, or is he managing something on the floor?"
detective: "Voss behavioral change: increased floor presence. Defensive positioning or active oversight?"
# ==========================================
# COMMUNITY — the full life of this district
# ==========================================
- id: the-last-shift_d_309
text: "Drifters lost again. Four-nil this time. I'm done watching until they rebuild."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, sports, drifters]
- id: the-last-shift_d_310
text: "Ventilation's still the same in Sector 3. Seven years of 'under discussion.' I've stopped expecting anything."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [bar-regular, infrastructure, grievance]
- id: the-last-shift_d_311
text: "You see the missing person notice on the board? Vessels Tamm. I don't know a Vessels Tamm."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [community, routine]
mood: [comfortable]
tags: [bar-regular, vessels-tamm, notice]
dual_lens:
smuggler: "Vessels Tamm. Naia's surname is Tamm. Don't chase it — but don't ignore it either."
detective: "Vessels Tamm notice is circulating casually. People haven't connected it to Naia Tamm yet."
- id: the-last-shift_d_312
text: "Re-em clinic's backed up four cycles for non-urgent. Main station if it's serious, apparently."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [community, routine]
mood: [comfortable]
tags: [bar-regular, clinic, community]
- id: the-last-shift_d_313
text: "Lattice upgrades are up eighteen percent this cycle. That's more than my raise."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, community]
mood: [comfortable]
tags: [bar-regular, lattice, economy]
dual_lens:
smuggler: "That's why the waiting list keeps growing. They're providing a service."
detective: "Economic grievance expressed casually. Price pressure is general knowledge. Confirms motive context."
- id: the-last-shift_d_314
text: "Workers' Association is pushing the handoff protocol again. Voss won't budge."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, community]
mood: [wary]
tags: [bar-regular, workers-association, voss, transition-window]
dual_lens:
smuggler: "Voss is defending the twenty-minute window against Association pressure. He's protecting the gap."
detective: "Voss is actively resisting worker-led protocol improvement. Institutional reason for the gap isn't mechanical."
# ==========================================
# DETECTIVE ENCOUNTER — community wariness
# ==========================================
- id: the-last-shift_d_315
text: "You new? Haven't seen you before."
role: bar-regular
access: [public]
trust: surface
situation: [arrival, investigation]
topic: [routine]
mood: [wary]
tags: [bar-regular, detective-path, recognition]
- id: the-last-shift_d_316
text: "Commission? I just drink here. Whatever you're after, I probably can't help."
role: bar-regular
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [wary]
tags: [bar-regular, detective-path, deflection]
- id: the-last-shift_d_317
text: "Ask Lera. She knows what happens in this bar better than anyone."
role: bar-regular
access: [authority, public]
trust: surface
situation: [investigation]
topic: [routine]
mood: [wary]
tags: [bar-regular, detective-path, lera-redirect]
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_318
text: "Another cycle done. Same tomorrow."
role: bar-regular
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, closing]
- id: the-last-shift_d_319
text: "Safe home. Watch the corridors after 2200."
role: bar-regular
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, closing, community]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_320
text: "Pull up a chair. Floor's full otherwise."
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_321
text: "You're in late. Voss kept you?"
role: bar-regular
access: [peer]
trust: surface
situation: [arrival]
topic: [routine, institution]
mood: [comfortable]
tags: [bar-regular, greeting, voss, generation-pass]
- id: the-last-shift_d_322
text: "Just got off. Bay six all night. My back knows it."
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [tired]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_323
text: "Drifters are playing Mirren next cycle. Home game. Might actually go."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, sports, community, generation-pass]
- id: the-last-shift_d_324
text: "Sector 3 meeting went nowhere. Same as last time. Same as the time before that."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, community]
mood: [comfortable]
tags: [bar-regular, infrastructure, grievance, generation-pass]
- id: the-last-shift_d_325
text: "New hire's asking questions on the floor again. Good. Someone should."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [bar-regular, new-hire, community, generation-pass]
- id: the-last-shift_d_326
text: "Span gate was loud today. Three cycles now. That's not normal."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [wary]
tags: [bar-regular, infrastructure, generation-pass]
- id: the-last-shift_d_327
text: "Kael's not in tonight. Unusual."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [bar-regular, kael, observation, generation-pass]
dual_lens:
smuggler: "Kael's absence is visible. Other people notice too."
detective: "Kael Davan's routine absence is noted socially. Behavioral change is general knowledge."
- id: the-last-shift_d_328
text: "Lera keeps the grain spirit back until she's sure you're a regular. You must've made the grade."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, lera, humor, generation-pass]
- id: the-last-shift_d_329
text: "Good night. Don't let the Commission ruin your morning."
role: bar-regular
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine, institution]
mood: [comfortable]
tags: [bar-regular, closing, generation-pass]
@@ -1,2 +1,311 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bartender at The Last Shift
# NPC: Sess (Tier 3 template role — Lera's staff)
# Voice: practical, pleasant, socially adept. Passes information by reflex, not design.
# Ring-aware: none. Knows regulars, patterns, who drinks with whom.
# Ticket: #191 | Sprint: 12
location: the-last-shift
role: bartender
lines:
# ==========================================
# GREETINGS — service register
# ==========================================
- id: the-last-shift_d_200
text: "What'll it be?"
role: bartender
access: [public, peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting]
- id: the-last-shift_d_201
text: "Back early tonight. Long shift?"
role: bartender
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, social]
- id: the-last-shift_d_202
text: "Grain spirit's back. Lera's running it at cycle price for two more days."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, drinks]
# ==========================================
# BAR OBSERVATIONS — social intelligence
# ==========================================
- id: the-last-shift_d_203
text: "Kael's been in most nights this week. Quiet though. Not his usual."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, kael, observation]
dual_lens:
smuggler: "Sess noticed Kael's off. She's reading it as personal. She doesn't know what's underneath."
detective: "Bartender flagging behavioral change in Kael Davan. Consistent with other observations."
- id: the-last-shift_d_204
text: "Torek tipped well again tonight. Always does. Nicer than you'd expect for Commission."
role: bartender
access: [peer, public]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, torek, observation]
dual_lens:
smuggler: "Torek's making friends with the staff. Not good."
detective: "Bartender views Torek favorably. Torek is cultivating the service staff deliberately."
- id: the-last-shift_d_205
text: "Naia was in earlier. Didn't stay. Left something on the table — looked like a note. Lera has it."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, naia, note, observation]
dual_lens:
smuggler: "Naia left something. Lera has it. Go to Lera."
detective: "Naia Tamm left written material at the bar. Lera is holding it. That's a material lead."
- id: the-last-shift_d_206
text: "Ren? Yeah, they're always in the morning crowd. Don't say much. Good for the morning."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, ren, drifter]
- id: the-last-shift_d_207
text: "Tev watches the door. Every shift. Lera likes it that way."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [sess, tev, door, observation]
dual_lens:
smuggler: "Tev watches the door for Lera. Not for anyone else. That's the setup."
detective: "Tev's entrance-monitoring is confirmed as Lera's request. Not operational surveillance."
- id: the-last-shift_d_208
text: "Sera was in earlier. She usually stays later. She left when Torek came in."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, institution]
mood: [comfortable]
tags: [sess, sera, torek, observation]
dual_lens:
smuggler: "Commission in, Commission contact out. Makes sense."
detective: "Third data point. Sera leaves when Torek arrives. Bartender confirms the pattern."
# ==========================================
# SOCIAL / COMMUNITY
# ==========================================
- id: the-last-shift_d_209
text: "Drifters lost again. Tev took it hard. Drank two too many."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, tev, sports, community]
- id: the-last-shift_d_210
text: "Workers' Association meeting's Thursday. Half the regulars will be here Friday in a mood either way."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, workers-association, community]
- id: the-last-shift_d_211
text: "Quiet night. Good for everyone."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, ambient]
# ==========================================
# DETECTIVE ENCOUNTER — helpful, unfocused
# ==========================================
- id: the-last-shift_d_212
text: "Commission comes in sometimes. Not usually this early in the cycle."
role: bartender
access: [authority, public]
trust: surface
situation: [arrival, investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, detective-path, observation]
- id: the-last-shift_d_213
text: "I know most of the regulars. Ask me anything. I probably know something."
role: bartender
access: [authority, public]
trust: surface
situation: [investigation, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, detective-path, open]
dual_lens:
smuggler: "Never encounters this line."
detective: "Bartender is cooperative. Useful — but she'll answer questions she doesn't know are questions."
- id: the-last-shift_d_214
text: "Lera's the one who knows the real history of this place. I've only been here four cycles."
role: bartender
access: [authority, public]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, detective-path, lera-redirect]
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_215
text: "Last round's on the board. After that, Lera calls it."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [sess, closing]
- id: the-last-shift_d_216
text: "Good night. Safe walk."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [sess, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_217
text: "The usual? Or trying something different tonight?"
role: bartender
access: [peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_218
text: "Busy one tonight. Give me a second."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_219
text: "Good shift or bad shift? Your face says one thing, your order'll confirm it."
role: bartender
access: [peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, social, generation-pass]
- id: the-last-shift_d_220
text: "Lera says the specials board is final. Don't ask me to substitute."
role: bartender
access: [public, peer]
trust: surface
situation: [routine, arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, lera, house-rules, generation-pass]
- id: the-last-shift_d_221
text: "Torek's in his usual spot. Corner table. Second drink already."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [institution, colleague]
mood: [comfortable]
tags: [sess, torek, observation, generation-pass]
- id: the-last-shift_d_222
text: "Ren's been here since this morning. Still on the same drink."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, ren, observation, generation-pass]
- id: the-last-shift_d_223
text: "Drifters game is on the Meridian relay tonight. If anyone's got a feed, that'll be the crowd."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, sports, community, generation-pass]
- id: the-last-shift_d_224
text: "Slow night for a change. Enjoy it."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, ambient, generation-pass]
- id: the-last-shift_d_225
text: "Tab's cleared. Lera'll be pleased."
role: bartender
access: [peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, closing, generation-pass]
@@ -0,0 +1,90 @@
# Dialogue: day-worker at The Last Shift
# NPC: Ren Tosse (Tier 3, NOBODY/CIVILIAN)
# Voice: casual-observant, asks questions without social friction. Just drifting.
# KEY LINE: the-last-shift_d_001 — sounds like intel gathering on night operations
# Actually: asking about after-hours freight work availability
# Ticket: #307 | Sprint: 12
location: the-last-shift
role: day-worker
lines:
# ==========================================
# "SEEMS IMPORTANT BUT ISN'T" LINE
# Sounds like Ren is casing night shift operations.
# Actually: asking if there's paid after-hours freight work available.
# ==========================================
- id: the-last-shift_d_001
text: "Night rotation still short-handed? Heard the after-hours freight pays decent."
role: day-worker
access: [public]
trust: surface
situation: [bar_evening, social]
topic: [money, cargo]
mood: [comfortable]
tags: [ren, seems-important-but-isnt, job-seeking, night-shift]
dual_lens:
smuggler: "Someone asking about the night rotation. Probably nothing. Watch them anyway."
detective: "Interest in night shift freight operations. Drifter, irregular schedule. Logging."
notes: >
THE key flat NPC line for Ren. Sounds like Ren is interested in the
night operation window for operational reasons. Is standard day-worker
job-seeking: which shift has openings, which shift pays best.
# ==========================================
# ROUTINE LINES
# ==========================================
- id: the-last-shift_d_002
text: "Bay seven any better this cycle? The super was a problem last time."
role: day-worker
access: [public]
trust: surface
situation: [social, bar_evening]
topic: [routine, colleague]
mood: [comfortable]
tags: [ren, job-seeking]
- id: the-last-shift_d_003
text: "Sova's decent. Station layout makes sense once you learn it."
role: day-worker
access: [public]
trust: surface
situation: [social, bar_evening]
topic: [routine]
mood: [comfortable]
tags: [ren, atmospheric]
- id: the-last-shift_d_004
text: "Just passing through. Maybe not. Depends on the work."
role: day-worker
access: [public]
trust: surface
situation: [social]
topic: [personal, routine]
mood: [comfortable]
tags: [ren, drifter]
- id: the-last-shift_d_005
text: "Cheap drink here's better than most stations I've stopped at."
role: day-worker
access: [public]
trust: surface
situation: [bar_evening, social]
topic: [personal]
mood: [comfortable]
tags: [ren, atmospheric]
- id: the-last-shift_d_006
text: "Any idea when the manifest deadline runs? Trying to time when the work window opens."
role: day-worker
access: [public]
trust: surface
situation: [social, bar_evening]
topic: [cargo, routine]
mood: [comfortable]
tags: [ren, seems-important-but-isnt, job-seeking]
notes: >
Second "seems important" line. Asking about manifest deadlines sounds
like operational intelligence. Is logistics scheduling for day work.
@@ -1,2 +1,269 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: courier at The Terminal
# NPC: Renn Harsk (Tier 2, THRESHOLD/GATEKEEPER)
# Voice: operational minimalism. Brief. Route-focused. Never volunteers more than needed.
# Ring-aware: works adjacent — carries documentation, not cargo. Knows what he carries.
# Cover: licensed freight courier. The documentation is the cover.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: courier
lines:
# ==========================================
# ARRIVALS — checking in
# ==========================================
- id: the-terminal_d_500
text: "Route's clear. Moving on schedule."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, operational]
- id: the-terminal_d_501
text: "Manifest for bay three. Needs a scheduler stamp."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, procedural]
- id: the-terminal_d_502
text: "Inbound from Havara. Three cans, all matched."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, cargo]
# ==========================================
# SURFACE ROUTINE — low-information, on-script
# ==========================================
- id: the-terminal_d_503
text: "Not staying. Just the drop."
role: courier
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [renn, operational, brief]
- id: the-terminal_d_504
text: "Paperwork's clean. You can check."
role: courier
access: [public, peer, authority]
trust: surface
situation: [routine, investigation]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, procedural, documentation]
- id: the-terminal_d_505
text: "Pickup's scheduled at 1600. I'll be back."
role: courier
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, operational]
- id: the-terminal_d_506
text: "Corridor's clear from my end. No delays."
role: courier
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [renn, operational]
dual_lens:
smuggler: "Clean phrase. Public-safe. He'd say it either way."
detective: "Courier references corridor access unprompted. Note it."
# ==========================================
# RING-ADJACENT — insider/real trust only
# ==========================================
- id: the-terminal_d_507
text: "Documentation's handled. Bay four, per the note."
role: courier
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, documentation]
dual_lens:
smuggler: "Renn's confirming the paperwork trail. Bay four, as arranged."
detective: "Never hears this line."
- id: the-terminal_d_508
text: "Transit stamp's current. Nothing flagged at the gate."
role: courier
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, documentation]
- id: the-terminal_d_509
text: "Pickup window is the same as last cycle. Twenty minutes."
role: courier
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, transition-window]
dual_lens:
smuggler: "Twenty minutes. Transition window confirmation."
detective: "Never hears this line."
- id: the-terminal_d_510
text: "Tell Voss the hold note clears at 1400."
role: courier
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, voss, bay-four]
dual_lens:
smuggler: "1400. The hold clears on schedule. Pass it up."
detective: "Never hears this line."
# ==========================================
# DETECTIVE ENCOUNTER — surface cooperation
# ==========================================
- id: the-terminal_d_511
text: "Licensed freight courier. Manifest's available on request."
role: courier
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, cargo]
mood: [comfortable]
tags: [renn, detective-path, procedural]
- id: the-terminal_d_512
text: "I drop documentation. What's inside the containers isn't my business."
role: courier
access: [authority]
trust: surface
situation: [investigation]
topic: [cargo, institution]
mood: [comfortable]
tags: [renn, detective-path, deflection]
dual_lens:
smuggler: "That's the answer. Clean and correct."
detective: "Classic courier deflection. Legally accurate. Operationally suspicious."
- id: the-terminal_d_513
text: "I've been running this route for two years. Nothing unusual."
role: courier
access: [authority, public]
trust: surface
situation: [investigation, confrontation]
topic: [cargo, institution]
mood: [comfortable]
tags: [renn, detective-path, authority-assertion]
# ==========================================
# DEPARTURES
# ==========================================
- id: the-terminal_d_514
text: "That's the drop. I'm out."
role: courier
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, closing]
- id: the-terminal_d_515
text: "Next run's tomorrow. Early."
role: courier
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [renn, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_516
text: "Two stops today. First one's done."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, generation-pass]
- id: the-terminal_d_517
text: "Outbound for Havara. Needs a bay assignment."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, procedural, generation-pass]
- id: the-terminal_d_518
text: "Gate's backed up. Running twenty minutes late."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, delay, generation-pass]
- id: the-terminal_d_519
text: "Paperwork's in order. Same as always."
role: courier
access: [public, peer, authority]
trust: surface
situation: [routine, investigation]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, procedural, generation-pass]
- id: the-terminal_d_520
text: "Quick turnaround today. Back out by 1300."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, operational, generation-pass]
- id: the-terminal_d_521
text: "Signed off on bay three. That's me done here."
role: courier
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, closing, generation-pass]
@@ -1,2 +1,397 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: dock-worker at The Terminal
# NPC: Generic dock worker (Tier 3 template role — multiple workers share this pool)
# Voice: short, practical, trade jargon. Community over institution. Not a lead.
# Ring-aware: none. Floor is their whole world.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: dock-worker
lines:
# ==========================================
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_200
text: "Push today. Bay six's backed up."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_start, arrival]
topic: [routine, cargo]
mood: [comfortable]
tags: [dock-worker, greeting]
- id: the-terminal_d_201
text: "Late night last cycle. Bay eight ran until 2300."
role: dock-worker
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [tired]
tags: [dock-worker, greeting]
- id: the-terminal_d_202
text: "Voss is on already. Floor's locked down tight."
role: dock-worker
access: [peer]
trust: surface
situation: [arrival, shift_start]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, voss, caution]
dual_lens:
smuggler: "Voss is in early. If the schedule's tight, the window shifts. Check it."
detective: "Workers treat the supervisor as a weather system — something to navigate around, not toward."
# ==========================================
# FLOOR TALK — routine, cargo, observation
# ==========================================
- id: the-terminal_d_203
text: "Bay four's been on hold three days. Nobody says why."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [dock-worker, bay-four, procedural]
dual_lens:
smuggler: "He doesn't know what's in it. Just annoyed at the backup."
detective: "Worker notices Bay 4 hold as unusual. Not suspicious — just a worker tracking the floor."
- id: the-terminal_d_204
text: "Manifest looks clean. Three inbounds, two outbounds. Light day."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [cargo, routine]
mood: [comfortable]
tags: [dock-worker, manifest]
- id: the-terminal_d_205
text: "Maret's been at that terminal all morning. Never good."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [wary]
tags: [dock-worker, maret, observation]
dual_lens:
smuggler: "Maret reviewing something. If she flags a discrepancy, Voss'll sit on it. He always does."
detective: "Workers have noticed Maret's behavior. Whatever she found, it's not small."
- id: the-terminal_d_206
text: "Drin ran bay six again last cycle. Third inspection this week."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [dock-worker, drin, inspection]
dual_lens:
smuggler: "Drin on six while bay four stays held. Scheduled or arranged?"
detective: "Workers notice inspection distribution. Drin on six, bay four untouched."
- id: the-terminal_d_207
text: "Transition window's twice as long with Commission eyes on the floor."
role: dock-worker
access: [peer]
trust: surface
situation: [shift_transition, routine]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, commission, transition-window]
dual_lens:
smuggler: "Commission presence during transition. Window's compromised."
detective: "Workers slow down when Commission is visible. Normal — or trained."
- id: the-terminal_d_208
text: "Good crew this cycle. Linn and Pael are both on."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [colleague, routine]
mood: [comfortable]
tags: [dock-worker, pael, community]
- id: the-terminal_d_209
text: "Cargo mark's off by forty, according to Maret. Voss logged it and moved on."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, colleague]
mood: [wary]
tags: [dock-worker, maret, voss, discrepancy]
dual_lens:
smuggler: "Forty kilos. Maret flagged it. Voss buried it. Same as last time."
detective: "Worker knows about a flagged discrepancy that was suppressed. That's a witness."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_210
text: "C-4 containers haven't been checked in three days. Not our problem, apparently."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [wary]
tags: [dock-worker, c4-storage, observation]
dual_lens:
smuggler: "C-4's getting casual attention. Note it."
detective: "C-4 storage. Three days, no verification. That's the anomaly window."
# ==========================================
# COMMUNITY / CASUAL
# ==========================================
- id: the-terminal_d_211
text: "You heading to the Last Shift after? Lera's got the grain spirit back."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, social, the-last-shift]
- id: the-terminal_d_212
text: "Workers' Association meeting's cycle twenty-two. Handoff protocol's back on the agenda."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [dock-worker, workers-association, community]
- id: the-terminal_d_213
text: "Drifters lost again. Four-three. Pael seemed to take it personally."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, social, pael]
# ==========================================
# DETECTIVE ENCOUNTER — deflection, redirect
# ==========================================
- id: the-terminal_d_214
text: "Manifest questions go to the scheduler. I'm floor-side."
role: dock-worker
access: [authority, public]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, detective-path, redirect]
- id: the-terminal_d_215
text: "Commission's asked before. Same answers. We run a clean floor."
role: dock-worker
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, detective-path, deflection]
- id: the-terminal_d_216
text: "Voss handles discrepancies. That's above my level."
role: dock-worker
access: [authority, peer]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, detective-path, voss, redirect]
# ==========================================
# PEER CANDOR — elevated trust
# ==========================================
- id: the-terminal_d_217
text: "Maret's going to push it. She doesn't leave a number sitting."
role: dock-worker
access: [peer]
trust: real
situation: [routine]
topic: [colleague]
mood: [wary]
tags: [dock-worker, maret, peer-candor]
dual_lens:
smuggler: "Maret's not going to drop it. That's the problem."
detective: "Workers think Maret will force the issue. She may be the break."
- id: the-terminal_d_218
text: "Voss runs a tight floor. Maybe too tight, if you take my meaning."
role: dock-worker
access: [peer]
trust: real
situation: [routine]
topic: [institution, colleague]
mood: [wary]
tags: [dock-worker, voss, peer-candor]
dual_lens:
smuggler: "He suspects Voss is shaving something. Doesn't know what. Doesn't want to."
detective: "Workers sense something off about Voss's management without naming it."
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-terminal_d_219
text: "Bay's clear. Good shift."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, closing]
- id: the-terminal_d_220
text: "Night crew's here. We're done."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end, shift_transition]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_221
text: "Early start. Bay two's already loaded."
role: dock-worker
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [dock-worker, greeting, generation-pass]
- id: the-terminal_d_222
text: "Air recyclers are loud this morning. Must be the outer ring issue again."
role: dock-worker
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, station, generation-pass]
- id: the-terminal_d_223
text: "Pael's in early. Something must be broken in B-7 again."
role: dock-worker
access: [peer]
trust: surface
situation: [routine, shift_start]
topic: [colleague, routine]
mood: [comfortable]
tags: [dock-worker, pael, generation-pass]
- id: the-terminal_d_224
text: "Running light today. Maybe I'll actually make it to the Last Shift before close."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, social, generation-pass]
- id: the-terminal_d_225
text: "Bay three's got a scan hold. Not my problem. Not my bay."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [dock-worker, scan-hold, generation-pass]
- id: the-terminal_d_226
text: "New hire's back. Asking questions again. Good kid."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [dock-worker, new-hire, generation-pass]
- id: the-terminal_d_227
text: "Handoff's in ten. Don't disappear on me."
role: dock-worker
access: [peer]
trust: surface
situation: [shift_transition]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, handoff, generation-pass]
- id: the-terminal_d_228
text: "Span gate cycled early. All inbounds are going to be late. Plan around it."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [wary]
tags: [dock-worker, span-gate, generation-pass]
- id: the-terminal_d_229
text: "Linn's not in today. We're running two short on bay six."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [colleague, routine]
mood: [tired]
tags: [dock-worker, staffing, generation-pass]
- id: the-terminal_d_230
text: "Lunch is in the break room. Don't touch the one with Pael's name on it. He will notice."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [dock-worker, pael, humor, generation-pass]
- id: the-terminal_d_231
text: "Overtime's posted if you want it. Bay eight, 1800 to 2200."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, overtime, generation-pass]
- id: the-terminal_d_232
text: "Off on time today. First time this cycle."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, closing, generation-pass]
@@ -0,0 +1,86 @@
# Dialogue: maintenance-tech at The Terminal
# NPC: Pael Varren (Tier 3, NOBODY/CIVILIAN)
# Voice: tired-competent, minimal, dry. Knows the infrastructure. Knows nothing else.
# KEY LINE: the-terminal_d_040 — sounds like surveillance knowledge, is a maintenance complaint
# Ticket: #307 | Sprint: 12
location: the-terminal
role: maintenance-tech
lines:
# ==========================================
# "SEEMS IMPORTANT BUT ISN'T" LINE
# Sounds like Pael monitors corridor traffic for a reason.
# Actually: the B-7 seal fails when people use the corridor too much. Pael notices load.
# ==========================================
- id: the-terminal_d_040
text: "B-7's had three seal failures this cycle. Someone's been through there more than they should."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, seems-important-but-isnt, maintenance, b7]
dual_lens:
smuggler: "Pael knows the corridor's been used. But Pael knows nothing — this is an HVAC complaint."
detective: "Corridor B-7 traffic. A maintenance tech noticing irregular use. Unreliable but worth noting."
notes: >
THE key flat NPC line for Pael. Sounds like environmental intelligence.
Is a complaint about a recurring seal failure caused by foot traffic.
Pael doesn't know what the traffic is from. Never will.
# ==========================================
# ROUTINE LINES
# ==========================================
- id: the-terminal_d_041
text: "Air recycler's running hot again. Third deferred ticket this week."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, atmospheric]
- id: the-terminal_d_042
text: "Loading arm three's been on my list for a month. Management keeps deferring it."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, atmospheric]
- id: the-terminal_d_043
text: "If the gate tone shifts, stay clear of bay six. She needs recalibration."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, warning, helpful]
- id: the-terminal_d_044
text: "Not me. I just fix them."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine, social]
topic: [routine]
mood: [comfortable]
tags: [pael, response]
- id: the-terminal_d_045
text: "Gate hum's been three cycles off-spec. Nobody cares until it stops completely."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, atmospheric]
@@ -1,2 +1,264 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: new-hire at The Terminal
# NPC: Generic new hire (Tier 3 template role — recently arrived worker)
# Voice: enthusiastic, still reading the room. Asks what veterans stopped asking.
# Ring-aware: none. Fresh eyes are the danger — they notice what regulars stopped seeing.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: new-hire
lines:
# ==========================================
# GREETINGS — early days
# ==========================================
- id: the-terminal_d_400
text: "Still figuring out the bay numbering. Six and eight are in the wrong order."
role: new-hire
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [new-hire, greeting, orientation]
- id: the-terminal_d_401
text: "Voss told me to check in with you. Is that the standard thing?"
role: new-hire
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine, institution]
mood: [comfortable]
tags: [new-hire, greeting, voss]
- id: the-terminal_d_402
text: "First full cycle on the floor. Trying to stay out of the way."
role: new-hire
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [new-hire, greeting]
# ==========================================
# FRESH OBSERVATIONS — the dangerous innocence
# ==========================================
- id: the-terminal_d_403
text: "Does bay four usually get held that long? The other bays turned over twice already."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [new-hire, bay-four, observation]
dual_lens:
smuggler: "New eyes on bay four. He doesn't know what he's looking at. But he noticed."
detective: "New hire asking exactly the right question without knowing it."
- id: the-terminal_d_404
text: "I saw someone go into C-4 last night without a scan log. Is that normal?"
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [new-hire, c4-storage, observation, inadvertent-witness]
dual_lens:
smuggler: "He saw someone in C-4 without a log. Does he know who? This is a problem."
detective: "Unlogged C-4 access. New hire, noticed it, mentioned it casually. He doesn't know what he saw."
knowledge_grant:
fact_id: investigation.cargo_anomaly
confidence: suspects
notes: "High-value inadvertent witness line. Operationally dangerous for the ring."
- id: the-terminal_d_405
text: "Why does the transition window only get twenty minutes? The stations I trained at ran thirty."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine, shift_transition]
topic: [routine]
mood: [comfortable]
tags: [new-hire, transition-window, comparison]
dual_lens:
smuggler: "He's asking about the window. Doesn't know why it matters. Answer carefully."
detective: "New hire confirms the window length is unusually short. File that."
- id: the-terminal_d_406
text: "Do containers usually get rerouted mid-hold? One went out through bay four while I thought it was still held."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [new-hire, bay-four, inadvertent-witness]
dual_lens:
smuggler: "He saw movement through bay four during the hold. This is a liability."
detective: "Container movement through bay four during official hold. Inadvertent eyewitness. Confirm timing."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_407
text: "Maintenance corridor access is just off C-4, right? I took the wrong turn twice."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [new-hire, maintenance-corridors, orientation]
dual_lens:
smuggler: "He found the corridor entrance by accident. Normal for new workers. Noted."
detective: "New hire maps spatial relationship between C-4 storage and maintenance corridor access. That's the route."
# ==========================================
# LEARNING / QUESTIONS
# ==========================================
- id: the-terminal_d_408
text: "What's the Commission kiosk actually for? Nobody seems to use it."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [new-hire, commission, curiosity]
- id: the-terminal_d_409
text: "Maret seems stressed. Is that always the case or is something going on?"
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, maret, observation]
dual_lens:
smuggler: "He noticed Maret. Good that he's asking, not reporting. Manage this."
detective: "New hire reads Maret's stress without context. Confirms it's visible."
- id: the-terminal_d_410
text: "Voss seems really serious. Is he always like that?"
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, voss, curiosity]
- id: the-terminal_d_411
text: "How long before Voss trusts you to run a bay on your own?"
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [new-hire, voss, career]
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-terminal_d_412
text: "That was a full shift. I'll have the bay numbers down by next cycle."
role: new-hire
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [new-hire, closing]
- id: the-terminal_d_413
text: "Good day. Lot to learn. But good."
role: new-hire
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [new-hire, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_414
text: "Is there a trick to the manifest terminal or does it always lag like this?"
role: new-hire
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine]
mood: [comfortable]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_415
text: "How long has Maret been here? She seems like she knows everything."
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, maret, curiosity, generation-pass]
- id: the-terminal_d_416
text: "Nobody told me about the span gate frequency. I kept thinking something was wrong."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine, arrival]
topic: [routine]
mood: [comfortable]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_417
text: "The Last Shift — is that actually a bar? I haven't been yet."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [new-hire, social, generation-pass]
- id: the-terminal_d_418
text: "I thought the scanner on bay three was broken. Turns out you have to hold the badge longer."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [new-hire, orientation, humor, generation-pass]
- id: the-terminal_d_419
text: "Who's Drin? I keep hearing the name but haven't met them."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, drin, curiosity, generation-pass]
- id: the-terminal_d_420
text: "Three cycles in and I still can't tell when Voss is satisfied. Is there a tell?"
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, voss, humor, generation-pass]
@@ -1,2 +1,371 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: scheduler at The Terminal
# NPC: Maret (Tier 2, WITNESS/SKEPTIC)
# Voice: precise, institutional. Numbers and containers, not people. Unease she keeps professional.
# Arc: flags discrepancies → gets suppressed by Voss → suspicion hardens → potential detective source
# Ring-aware: not involved. Seeing the pattern. Hasn't named it yet.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: scheduler
lines:
# ==========================================
# GREETINGS — scheduler's opening read
# ==========================================
- id: the-terminal_d_300
text: "Manifest's current. Bay assignments are posted."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, arrival]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, greeting]
- id: the-terminal_d_301
text: "Container 4471 is still in C-4. Scheduled for bay six by 1400. Check with Voss if the hold's lifted."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, container-4471, procedural]
dual_lens:
smuggler: "She's tracking 4471 as a scheduling issue. Not flagging it as a discrepancy yet."
detective: "Maret references container 4471 as an unresolved routing issue. She knows the number."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_302
text: "Three inbounds today. All verified against the manifest. No gaps so far."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, procedural, manifest]
# ==========================================
# DISCREPANCY PATTERN — the core arc
# ==========================================
- id: the-terminal_d_303
text: "Container 4474 shows a forty-seven kilo discrepancy. I've flagged it twice."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, investigation]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, discrepancy, flagging]
dual_lens:
smuggler: "Which container? Not 4471. She's flagging a different one. Two flags, no escalation."
detective: "Maret has flagged container 4474 twice. If Voss suppressed it both times, there's a paper trail."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_304
text: "It was logged as administrative. I don't know what that means for a weight discrepancy."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, discrepancy, institutional-response]
dual_lens:
smuggler: "She doesn't know what 'administrative' means either. Voss's answer covered nothing."
detective: "Maret knows the answer she got wasn't a real answer. She's still sitting with that."
- id: the-terminal_d_305
text: "The transition window anomalies cluster at shift change. I've run the numbers four times. It's not random."
role: scheduler
access: [peer]
trust: surface
situation: [routine, investigation]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, pattern, transition-window]
dual_lens:
smuggler: "She's found the pattern. Not the cause. Not yet."
detective: "Maret has independently confirmed the transition window cluster. She's already halfway to the conclusion."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_306
text: "Three separate manifest entries show weight variations under ten kilos. Within rounding, technically."
role: scheduler
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, pattern, manifest]
dual_lens:
smuggler: "She's tracking the small ones too. But 'within rounding' is what Voss told her. She's not satisfied."
detective: "Sub-threshold variations. Small enough to dismiss individually. Maret's running aggregate analysis."
- id: the-terminal_d_307
text: "I filed a formal notice with Voss. He reviewed it and told me it was resolved. It's not resolved."
role: scheduler
access: [peer]
trust: surface
situation: [routine, confrontation]
topic: [cargo, institution]
mood: [concerned]
tags: [maret, voss, suppression, frustration]
dual_lens:
smuggler: "She filed a formal notice. Voss sat on it. She knows. This is the pressure point."
detective: "Formal notice filed, suppressed by supervisor. If that notice exists in the system, it's evidence."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: knows_details
# ==========================================
# ROUTINE SCHEDULING — grounded work
# ==========================================
- id: the-terminal_d_308
text: "Bay eight's running on reduced capacity until 1700. Route inbounds through six."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, operational]
- id: the-terminal_d_309
text: "Drin's running the inspection on bay six this cycle. Bay four's on hold per Voss's directive."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, drin, bay-four, scheduling]
dual_lens:
smuggler: "Maret confirmed it: bay four is Voss's directive. Not a system hold."
detective: "The bay four hold comes directly from Voss, not from the system. Discretionary hold."
- id: the-terminal_d_310
text: "Overtime requests go through Meridian. I can't process them on paper anymore."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [maret, administrative]
- id: the-terminal_d_311
text: "Manifest update at 1300. If your containers aren't posted by then, they don't make the cycle."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, manifest]
# ==========================================
# DETECTIVE ENCOUNTER — access and resistance
# ==========================================
- id: the-terminal_d_312
text: "Commission manifest access requires a formal request through Voss. I can tell you what's on the public record."
role: scheduler
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, cargo]
mood: [comfortable]
tags: [maret, detective-path, procedural]
- id: the-terminal_d_313
text: "The flagged entries are in the system. They were logged as reviewed and closed. That's what I can confirm."
role: scheduler
access: [authority]
trust: surface
situation: [investigation]
topic: [cargo, institution]
mood: [concerned]
tags: [maret, detective-path, discrepancy-disclosure]
dual_lens:
smuggler: "Never encounters this line."
detective: "Maret confirms the flags exist in the system. And she's using careful language: 'what I can confirm.' There's more."
- id: the-terminal_d_314
text: "I'm not in a position to speak to why certain reviews were closed the way they were."
role: scheduler
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [institution, cargo]
mood: [concerned]
tags: [maret, detective-path, careful-language]
dual_lens:
smuggler: "Never encounters this line."
detective: "She's being very precise about what she will and won't say. 'Not in a position' means she has a position. Come back when she trusts me more."
# ==========================================
# ELEVATED TRUST — Maret breaks professional distance
# ==========================================
- id: the-terminal_d_315
text: "The pattern I'm seeing doesn't make sense for equipment failure. It's too consistent."
role: scheduler
access: [peer, insider]
trust: real
situation: [routine, investigation]
topic: [cargo, investigation]
mood: [concerned]
tags: [maret, peer-candor, pattern]
dual_lens:
smuggler: "She's figured it out. She hasn't said what 'it' is. But she knows the numbers aren't random."
detective: "Maret has crossed from anomaly-logging into active pattern recognition. She's running an informal parallel investigation."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_316
text: "I filed a second formal notice two cycles ago. It's been under review since then. By Voss."
role: scheduler
access: [peer]
trust: real
situation: [routine, confrontation]
topic: [cargo, institution]
mood: [concerned]
tags: [maret, voss, suppression, peer-candor]
dual_lens:
smuggler: "Two formal notices. Both sitting with Voss. He's managing her output directly."
detective: "Second formal notice. Both under Voss's review. That's documented suppression with a timestamp."
knowledge_grant:
fact_id: behavioral.voss_awareness
confidence: knows_details
- id: the-terminal_d_317
text: "I don't think I should be the one telling you this."
role: scheduler
access: [insider]
trust: real
situation: [confrontation, alone]
topic: [investigation]
mood: [concerned]
tags: [maret, detective-path, threshold-line]
notes: "Gate line. Opens when detective has built enough trust. What follows depends on the conversation state."
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-terminal_d_318
text: "Manifest posted for the evening shift. I'm off at 1500."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [maret, closing]
- id: the-terminal_d_319
text: "Numbers don't lie. People lie about numbers."
role: scheduler
access: [peer]
trust: real
situation: [shift_end, routine]
topic: [routine]
mood: [concerned]
tags: [maret, closing, character-line]
notes: "Low-access line. Maret's professional philosophy. Can fire as ambient or as a closing beat."
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_320
text: "Bay assignments updated as of 0900. Check before you touch anything."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_321
text: "Inbound from the Havara corridor is delayed six hours. Adjust your bay queue."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, operational, generation-pass]
- id: the-terminal_d_322
text: "Scan log for bay six is still open from last cycle. Close it before end of shift."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_323
text: "Overtime requests need scheduler approval now. Voss changed the process."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [maret, voss, administrative, generation-pass]
- id: the-terminal_d_324
text: "If a container's not logged by 1300, it doesn't make the cycle. That's the rule."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_325
text: "End-of-cycle reconciliation is at 1430. Be available."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_326
text: "Weight tolerance is plus or minus five kilos. Anything outside that gets a flag."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, procedural, manifest, generation-pass]
- id: the-terminal_d_327
text: "Commission audit prep starts next cycle. Get your documentation current."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [wary]
tags: [maret, commission, audit, generation-pass]
@@ -1,2 +1,356 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: shift-supervisor at The Terminal
# NPC: Voss (Tier 2, SYSTEM/OPERATOR)
# Voice: terse, procedural, institutional. Uses authority as armor.
# Ring-complicit: provides scheduling cover. Never shows it on surface.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: shift-supervisor
lines:
# ==========================================
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_100
text: "You're late. Don't be late."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [voss, greeting, authority]
- id: the-terminal_d_101
text: "Manifest's posted. Bay assignments are final — don't renegotiate them with me."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_102
text: "Bay 4's running a review hold. Nobody touches that freight until I say."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [voss, authority]
dual_lens:
smuggler: "Review hold on Bay 4. Voss knows the container's in the queue. Is he covering it or flagging it?"
detective: "Supervisor flagging Bay 4 himself. Either due diligence or containment."
- id: the-terminal_d_103
text: "Commission liaison's in the district this cycle. Keep the floor clean."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [institution, routine]
mood: [concerned]
tags: [voss, warning, commission]
dual_lens:
smuggler: "Voss knows the detective's here. That warning's not just about tidiness."
detective: "Supervisor aware of my presence. Either routine caution or he's got something to protect."
# ==========================================
# SHIFT AUTHORITY — routine management
# ==========================================
- id: the-terminal_d_104
text: "Drin was supposed to run the bay six inspection. Tell him I'm asking."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [voss, drin, authority]
- id: the-terminal_d_105
text: "Transition window's twenty minutes. Not twenty-five, not thirty. Twenty."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [shift_transition]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural, transition-window]
- id: the-terminal_d_106
text: "Overtime requests go through the Meridian form. Don't bring them to me."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_107
text: "Bay eight's going to downtime at 1300. Plan around it."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine]
mood: [comfortable]
tags: [voss, operational]
- id: the-terminal_d_108
text: "Maret flagged a discrepancy. I've reviewed it. It's logged. Move on."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, investigation]
topic: [cargo, routine]
mood: [comfortable]
tags: [voss, maret, discrepancy, cover]
dual_lens:
smuggler: "Voss reviewed it and logged it. Not escalating. He's managing it."
detective: "Supervisor reviewed a manifest discrepancy and chose not to escalate. That's not nothing."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_109
text: "Freight's moving. That's what matters. Questions slow things down."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [voss, deflection, philosophy]
# ==========================================
# SCHEDULING COVER — insider, real trust
# ==========================================
- id: the-terminal_d_110
text: "Bay four is clear for transition. I've adjusted the inspection schedule."
role: shift-supervisor
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [voss, ring-ops, scheduling-cover]
dual_lens:
smuggler: "He adjusted the schedule. The window is actually clear. Voss is part of this."
detective: "Never hears this line."
- id: the-terminal_d_111
text: "Drin's on bay six. Bay four's mine tonight."
role: shift-supervisor
access: [insider]
trust: real
situation: [shift_transition, night_shift]
topic: [routine, cargo]
mood: [focused]
tags: [voss, ring-ops, scheduling-cover, drin]
- id: the-terminal_d_112
text: "The hold note is administrative. Don't treat it as anything else."
role: shift-supervisor
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo]
mood: [focused]
tags: [voss, ring-ops, coded]
notes: "Signals to ring members that the 'hold' on Bay 4 is cover, not genuine review."
# ==========================================
# DETECTIVE ENCOUNTER — authority tier
# ==========================================
- id: the-terminal_d_113
text: "You'll need to submit a formal access request through Commission channels for any restricted data."
role: shift-supervisor
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [comfortable]
tags: [voss, detective-path, institutional-resistance]
- id: the-terminal_d_114
text: "The manifest's available to any Commission officer. Ask the scheduler."
role: shift-supervisor
access: [authority]
trust: surface
situation: [investigation]
topic: [cargo, institution]
mood: [comfortable]
tags: [voss, detective-path, redirect]
- id: the-terminal_d_115
text: "My workers perform their duties. If there's an irregularity, it'll show in the log."
role: shift-supervisor
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [routine, institution]
mood: [comfortable]
tags: [voss, detective-path, deflection]
- id: the-terminal_d_116
text: "I've been running this floor for six years. The numbers work out."
role: shift-supervisor
access: [authority, public]
trust: surface
situation: [investigation, confrontation]
topic: [routine, institution]
mood: [comfortable]
tags: [voss, detective-path, authority-assertion]
# ==========================================
# PRESSURE / CONFRONTATION
# ==========================================
- id: the-terminal_d_117
text: "Close the door."
role: shift-supervisor
access: [insider, peer]
trust: real
situation: [confrontation, alone]
topic: [danger]
mood: [concerned]
tags: [voss, confrontation, private]
- id: the-terminal_d_118
text: "Maret keeps flagging the discrepancies. That needs to stop being her problem."
role: shift-supervisor
access: [insider]
trust: real
situation: [confrontation, alone]
topic: [colleague, danger]
mood: [concerned]
tags: [voss, ring-ops, maret, caution]
knowledge_grant:
fact_id: behavioral.voss_awareness
confidence: knows_of
- id: the-terminal_d_119
text: "If you've got a problem with how I run this floor, that's a conversation for another time."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [confrontation]
topic: [routine]
mood: [concerned]
tags: [voss, deflection, shutdown]
# ==========================================
# SIGN-OFF / END OF SHIFT
# ==========================================
- id: the-terminal_d_120
text: "Sign-off is at 1400. Be done."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_121
text: "Night shift takes over at 2200. I don't want to hear about the handoff tomorrow."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_transition, shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_122
text: "Good shift. Tomorrow at 0600."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_123
text: "Assignments don't change once they're posted. Read the board."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_124
text: "If there's a discrepancy, it goes to the scheduler. Not to me directly."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, procedural, maret, generation-pass]
- id: the-terminal_d_125
text: "Span gate's cycling at 1100. Clear the bay six access before then."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_126
text: "I don't repeat myself on safety calls. If you missed it, ask someone who was listening."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [voss, authority, generation-pass]
- id: the-terminal_d_127
text: "End-of-day log goes to Meridian before you leave. No exceptions."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine, institution]
mood: [comfortable]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_128
text: "Inbounds from Havara are on the revised schedule. Check the update."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_129
text: "Clean floor, clean manifest. That's the job."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [voss, philosophy, generation-pass]
@@ -0,0 +1,195 @@
# Environmental Text: Maintenance Corridors
# Signs, terminals, documents, readable objects.
# Investigation-heavy. This is where the operational evidence lives.
# Ticket: #262 | Sprint: 12
location: maintenance-corridors
items:
# ==========================================
# ACCESS RESTRICTION SIGN — corridor entrance
# ==========================================
- id: env_corridor_001
type: sign
label: "Restricted Access Notice"
visibility: always
text: >
RESTRICTED ACCESS — MAINTENANCE PERSONNEL ONLY
B-CORRIDOR SECTOR — AUTHORIZED ENTRY REQUIRED
Unauthorized entry subject to Sova Station Authority review.
Commission Order 7741-B applies.
Report unauthorized personnel to: Station Security (Lattice: SovaStation/Security)
dual_lens:
smuggler: "Nobody calls Station Security for corridor access. Everyone goes through here."
detective: >
Restricted zone, active posting. Unauthorized entry is a violation
of Commission Order 7741-B. If the ring is using this corridor,
they're committing a documented infraction on every run.
tags: [atmospheric, b7-corridor]
# ==========================================
# MERIDIAN COVERAGE SCHEDULE — posted on corridor wall
# Most investigation-relevant item in the game.
# ==========================================
- id: env_corridor_002
type: document
label: "Meridian Scan Schedule"
visibility: examine_only
text: >
MERIDIAN SURVEILLANCE COVERAGE — SOVA TRANSIT DISTRICT
MAINTENANCE CORRIDOR SECTOR — CURRENT CYCLE
Active coverage windows (full corridor monitoring):
06:00-07:30 | 10:00-11:00 | 14:00-15:30 | 22:00-23:00
Reduced coverage (perimeter only):
07:30-10:00 | 11:00-14:00 | 15:30-22:00 | 23:00-06:00
NOTE: Span gate maintenance cycles create signal interference.
Coverage gaps during gate calibration: approx 14:20-14:45.
Posted by: Sova Meridian Network Maintenance Division
dual_lens:
smuggler: >
14:20-14:45. That's the window. Twenty-five minutes of clean dead zone
during the shift transition. The ring didn't find this. It's posted.
detective: >
Posted surveillance schedule with documented gap. 14:20-14:45 during
shift transition. The ring isn't sophisticated — they're using
publicly posted maintenance data. That means anyone could have
identified this gap.
tags: [investigation-relevant, meridian, surveillance-gap, critical]
knowledge_grant:
fact_id: "location.surveillance_gaps"
confidence: knows_details
# ==========================================
# MAINTENANCE LOG SHEET — handwritten clipboard at corridor junction
# ==========================================
- id: env_corridor_003
type: document
label: "Maintenance Log (B-7 Corridor)"
visibility: examine_only
text: >
B-7 CORRIDOR — MAINTENANCE LOG
Logged by: Pael Varren
[Month 11, Day 4]
Air scrubber pressure variance. Reset. Source unclear.
[Month 11, Day 12]
Seal failure B-7 junction. Third occurrence. Replaced again.
Noted: floor wear pattern inconsistent with posted traffic.
Possible deferred maintenance backlog creating additional load.
[Month 11, Day 22]
Recurring seal failure. Escalated to deferred queue.
NOTE: Pressure sensors reading elevated load during 14:00-15:00
window. Does not match shift transition foot traffic.
Filed maintenance anomaly report — awaiting management review.
dual_lens:
smuggler: >
Pael's logged the pressure anomaly. It'll sit in the queue.
Management never reviews the anomaly reports.
detective: >
Maintenance anomaly report filed, awaiting management review.
The report documents irregular pressure load during exact operation
window. If management reviews it, the window closes.
tags: [investigation-relevant, b7-corridor, pael, critical]
knowledge_grant:
fact_id: "location.corridor_b7_restricted"
confidence: knows_of
# ==========================================
# ROUTING DIAGRAM — cargo flow map posted at junction
# ==========================================
- id: env_corridor_004
type: sign
label: "Cargo Routing Diagram"
visibility: always
text: >
SOVA TRANSIT — MAINTENANCE CORRIDOR ROUTING
[Technical diagram showing freight flow paths]
Standard freight path: Bay > Terminal > Surface Storage
Emergency reroute: Bay > B-corridor > Temporary Hold (C-4)
Maintenance access: B-corridor > Service Lift > Environmental Systems
[Someone has drawn a thin pencil line from Bay 4 to C-4 to
the service lift, bypassing the standard terminal route]
dual_lens:
smuggler: "Someone mapped the reroute. That's not how we were shown it. That's cleaner."
detective: >
Pencil annotation on the official diagram. Whoever added it knows
the layout and took time to trace an alternative route. Not vandalism.
Operational planning.
tags: [investigation-relevant, b7-corridor, smuggling-route]
knowledge_grant:
fact_id: "location.smuggling_route"
confidence: suspects
# ==========================================
# ENVIRONMENTAL WARNING — recycled air quality
# ==========================================
- id: env_corridor_005
type: sign
label: "Environmental Quality Notice"
visibility: always
text: >
CORRIDOR ENVIRONMENTAL STATUS — CURRENT READINGS
CO2: 0.12% [ACCEPTABLE — upper threshold 0.15%]
Particulate: ELEVATED — see maintenance queue
Temperature: 19.4°C [nominal]
Recycled air freshness: 73% [minimum recommended: 80%]
If recycled air quality falls below 70%, report to maintenance.
Current status: Monitor.
dual_lens:
smuggler: "Recycled air's worse than the terminal. You get used to it."
detective: "Air quality below recommended. Maintenance deferred. Nobody's prioritizing this corridor."
tags: [atmospheric, environmental]
# ==========================================
# TEMPORARY STORAGE MANIFEST SHEET
# At the C-4 temporary hold point
# ==========================================
- id: env_corridor_006
type: document
label: "Temporary Storage Log (C-4)"
visibility: examine_only
text: >
C-4 TEMPORARY HOLD — CONTENTS LOG
[Printed manifest, partial, two pages visible]
Container 4466 — Cleared — removed Month 11 Day 1
Container 4467 — Cleared — removed Month 11 Day 3
Container 4468 — Cleared — removed Month 11 Day 3
Container 4469 — [NO ENTRY]
Container 4470 — Cleared — removed Month 11 Day 8
Container 4471 — [PENDING — entered Month 11 Day 15, no removal logged]
Container 4472 — Cleared — removed Month 11 Day 18
[Gap in numbering between 4469-4470 has no explanation column]
dual_lens:
smuggler: >
4471 is still logged as pending. It should have been moved during
last transition. Either it wasn't, or the log wasn't updated.
Either is a problem.
detective: >
Container 4471 in the C-4 hold log, entered Month 11, no removal
logged. Same container flagged in the terminal manifest. And 4469
is a gap with no explanation. This is the thread.
tags: [investigation-relevant, container-4471, critical]
knowledge_grant:
fact_id: "investigation.cargo_anomaly"
confidence: knows_of
@@ -0,0 +1,209 @@
# Environmental Text: The Last Shift (Bar)
# Signs, terminals, documents, readable objects.
# Atmosphere-heavy. Some investigation relevance through social texture.
# Ticket: #262 | Sprint: 12
location: the-last-shift
items:
# ==========================================
# DRINK MENU — handwritten, scratched-out prices
# Above the bar. Visible to anyone.
# ==========================================
- id: env_bar_001
type: sign
label: "Drink Menu"
visibility: always
text: >
THE LAST SHIFT
(Lera's)
Krenn grain spirit — 4c
Pale station brew — 3c ~~2c~~
Dark station brew — 3c
Import (off-station) — 8c
Sova standard water — 1c
Meal (hot, whatever's running) — 6c
No tabs. No Commission business at the bar.
House rules posted.
dual_lens:
smuggler: "Import's doubled since last year. Everything's tighter."
detective: >
"No Commission business at the bar." That's Lera's personal policy.
She'll have opinions about why.
tags: [atmospheric]
# ==========================================
# LERA'S RULES — above the bar
# ==========================================
- id: env_bar_002
type: sign
label: "House Rules"
visibility: always
text: >
LERA'S — HOUSE RULES
1. Pay before you're too drunk to remember.
2. Arguments outside. Always.
3. What's said at the bar stays at the bar.
4. No job talk after 20:00.
5. Torek Lintar runs his own tab. Don't ask.
dual_lens:
smuggler: "Rule 5. Lera's been running Torek's tab for years. Old arrangement."
detective: >
Rule 5 is specific. Named. Torek Lintar has a standing arrangement
here. That's the third time his name has come up without context.
tags: [investigation-relevant, torek-lintar, lera]
knowledge_grant:
fact_id: "relationship.torek_bar_arrangement"
confidence: suspects
# ==========================================
# NOTICE BOARD — general community notices
# Examine to read individual notices.
# ==========================================
- id: env_bar_003
type: readable_object
label: "Notice Board"
visibility: always
text: >
[NOTICE BOARD — various papers pinned over each other]
MISSING: Vessels Tamm, last seen Sova Transit District, Month 11.
Contact: Lattice Commission Sova liaison or speak to Lera.
CARGO WORKERS' ASSOCIATION — Monthly meeting, back room, Month 12,
evening shift. All Sova Transit workers welcome. Bring your own.
ROOM AVAILABLE: Residential pod B-3, lower level. Quiet. Short-term.
Speak to Harek at the bar.
[handwritten, pinned over the others:]
ANYONE KNOW WHO LEFT A TRANSIT BAG IN THE STORAGE ROOM?
IT'S BEEN THREE WEEKS.
dual_lens:
smuggler: >
Vessels Tamm. Missing person notice. That name's from the Tamm family —
Naia's connection? Or coincidence. Probably coincidence.
detective: >
Missing persons notice. Vessels Tamm. Sova Transit District. Cross-reference
Tamm with case file — possible Naia Tamm relation. Flag for follow-up.
tags: [investigation-relevant, naia-tamm-adjacent, harek]
knowledge_grant:
fact_id: "relationship.tamm_family"
confidence: suspects
# ==========================================
# GRAFFITI — bathroom wall
# Examine to read.
# ==========================================
- id: env_bar_004
type: readable_object
label: "Graffiti (Bathroom Wall)"
visibility: examine_only
text: >
[scratched into the wall surface over several years]
Voss doesn't check bay 4. Never has. Never will.
The Commission sees what it wants to see.
Lera knows.
[someone added in different handwriting:]
Lera doesn't know anything. She just sells drinks.
[original hand, below:]
Keep telling yourself that.
dual_lens:
smuggler: "Graffiti's been here since before I started. The Voss line is right, though."
detective: >
"Lera knows." Someone believes the bar owner has information.
Someone else pushes back. The debate is years old.
tags: [atmospheric, voss, lera, investigation-adjacent]
# ==========================================
# TONIGHT'S SPECIALS — chalkboard near the kitchen
# ==========================================
- id: env_bar_005
type: sign
label: "Tonight's Specials"
visibility: always
text: >
TONIGHT:
Hot grain porridge (Velen style) — 5c
Station fish fry — 7c
Ask Lera what Velen style means.
She'll tell you about it for twenty minutes.
dual_lens:
smuggler: "Lera's from Velen originally. She makes everyone ask."
detective: "Lera Sessik. Originally from Velen surface. Personal detail noted."
tags: [atmospheric, lera-backstory]
# ==========================================
# LATTICE-FREE ZONE SIGN — near the entrance
# ==========================================
- id: env_bar_006
type: sign
label: "Lattice-Free Zone Notice"
visibility: always
text: >
VOLUNTARY LATTICE-FREE ZONE
By Lera's request: please disable active lattice scanning
and recording functions while at the bar.
Conversation here is conversation. Not data.
This is not a Commission-recognized designation.
Lera doesn't care.
dual_lens:
smuggler: >
Everyone honors this. It's one of the reasons the bar works for
what it works for.
detective: >
Not legally enforceable. But anyone who honors it voluntarily is
actively choosing to reduce their surveillance footprint here.
Who honors it and who doesn't is worth noting.
tags: [investigation-relevant, lera, lattice-free]
# ==========================================
# CARGO WORKERS' ASSOCIATION FLYER — pinned to the bar
# ==========================================
- id: env_bar_007
type: document
label: "Association Meeting Flyer"
visibility: examine_only
text: >
SOVA TRANSIT CARGO WORKERS' ASSOCIATION
Monthly Meeting
Topic: Shift change protocols — the thirty-minute handoff window
and why it isn't long enough.
Also on agenda: Updated manifest compliance requirements from
the Commission (new reporting window: 6 hours, down from 8).
Back room. Evening shift. All welcome.
Bring your concerns, bring a drink.
dual_lens:
smuggler: >
Commission shortened the reporting window to six hours. That's
tighter than the eight-hour buffer. Someone noticed the gap.
detective: >
Agenda item: thirty-minute handoff window. Workers complaining
about it publicly means the window is known beyond the ring.
Or is being discussed for reasons that are less innocent.
tags: [investigation-relevant, shift-transition, commission]
knowledge_grant:
fact_id: "world.shift_schedule"
confidence: knows_of
@@ -0,0 +1,199 @@
# Environmental Text: The Terminal (Logistics Hub)
# Signs, terminals, documents, readable objects.
# Both characters can examine these. Interpretation differs.
# Ticket: #262 | Sprint: 12
location: the-terminal
items:
# ==========================================
# SHIFT BOARD — posted schedule
# Always visible. High-traffic reference point.
# ==========================================
- id: env_terminal_001
type: sign
label: "Shift Schedule Board"
visibility: always
text: >
SOVA TRANSIT DISTRICT — LOGISTICS HUB
CURRENT ROTATION — CYCLE 47
BAY 1-4: Day Shift 06:00-14:00 (Voss, K. — Supervisor)
BAY 5-8: Day Shift 06:00-14:00 (Maret, K. — Scheduler)
TRANSITION: 13:45-14:15 (30-min handoff window)
Night Shift 22:00-06:00 — ROSTER PENDING
[Bay 6: MAINTENANCE HOLD — see Pael Varren]
dual_lens:
smuggler: "Thirty-minute transition window. That's the window. Posted for everyone to see."
detective: "Night roster blank. Either unassigned or deliberately obscured. Logging the gap."
# ==========================================
# MANIFEST TRACKING TERMINAL — cargo status display
# Active terminal. Can be read from appropriate distance in investigation mode.
# ==========================================
- id: env_terminal_002
type: terminal
label: "Cargo Tracking Terminal"
visibility: always
text: >
ACTIVE MANIFEST QUEUE — SOVA LOGISTICS
Last updated: 06:23
Container 4471 — [PENDING REVIEW] — Bay 4
Container 4472 — CLEARED — Bay 2
Container 4473 — CLEARED — Bay 2
Container 4474 — [WEIGHT DISCREPANCY — 47kg] — Bay 4
Container 4475 — CLEARED — Bay 3
Container 4476 — PROCESSING — Bay 7
[8 additional items — scroll to continue]
dual_lens:
smuggler: "4471 is still in the queue. Kael hasn't cleared it yet. On schedule."
detective: >
4471 pending review with a 4474 weight discrepancy in the same bay.
Two anomalies, same location. Not random.
tags: [investigation-relevant, manifest-discrepancy]
knowledge_grant:
fact_id: "investigation.manifest_discrepancy"
confidence: suspects
# ==========================================
# MAINTENANCE REQUEST CLIPBOARD
# Physical clipboard near loading arm 3. Examine to read.
# ==========================================
- id: env_terminal_003
type: document
label: "Maintenance Request Log"
visibility: examine_only
text: >
DEFERRED MAINTENANCE — CURRENT QUEUE
(Submitted: Pael Varren, Maintenance)
Loading arm 3 — Grinding during lift cycle — DEFERRED x4
Bay 6 environmental seal — Recurring failure — DEFERRED x3 (HOLD)
B-7 corridor air scrubber — Pressure variance — DEFERRED x1
Gate calibration bay 5-6 — Off-spec hum — DEFERRED x2
Recycling unit C-4 — Thermal spike — DEFERRED x1
Note: B-7 corridor showing irregular pressure load on sensor.
Traffic heavier than logged shift count explains. Investigating.
— P. Varren
dual_lens:
smuggler: "Pael's noticed the B-7 pressure log doesn't match posted traffic. Nothing they can do about it."
detective: >
Maintenance log records irregular pressure load in B-7.
The tech attributes it to unlogged foot traffic. That's exactly right.
tags: [investigation-relevant, b7-corridor]
knowledge_grant:
fact_id: "location.corridor_b7_restricted"
confidence: suspects
# ==========================================
# COMMISSION COMPLIANCE NOTICE
# Standard posting. Required by regulation. Two years old.
# ==========================================
- id: env_terminal_004
type: sign
label: "Commission Compliance Notice"
visibility: always
text: >
LATTICE COMMISSION — FREIGHT OPERATIONS COMPLIANCE
All manifest discrepancies exceeding 15kg must be reported
within 8 hours of discovery.
All cargo flagged PENDING REVIEW must be cleared by assigned
inspector before shift transition.
Unauthorized access to restricted maintenance corridors
will result in immediate suspension pending review.
Posted: Lattice Commission, Sova Station Authority
Reference: Commission Order 7741-B
dual_lens:
smuggler: >
Eight-hour window on discrepancy reporting. In practice, the window
to move anything is the thirty-minute shift transition.
detective: >
8-hour reporting window. If discrepancy 4474 was logged at 06:23,
compliance deadline is 14:23. I have time.
tags: [atmospheric, institutional]
# ==========================================
# WORK ORDER NOTE — handwritten, coffee-stained
# Personal communication between dock workers. Examine to read.
# ==========================================
- id: env_terminal_005
type: document
label: "Handwritten Note (Coffee-Stained)"
visibility: examine_only
text: >
Kael —
Bay 4 queue backed up again. Maret says it's a weight issue
but she's not flagging it officially. If it's still there when
you come in, just route it through 7 like last time. Voss
won't check.
— D.
dual_lens:
smuggler: >
"D." is Drin. Route it through 7 like last time — dock-side
workaround, happens constantly. Probably nothing operational.
detective: >
Unsigned note, initial D. Maret not flagging a weight discrepancy
officially. Voss complicit or uninformed. Follow up with Maret.
tags: [investigation-relevant, maret, drin, voss]
knowledge_grant:
fact_id: "investigation.drin_inspection_pattern"
confidence: suspects
# ==========================================
# SAFETY PROCEDURES PLACARD — standard, faded
# ==========================================
- id: env_terminal_006
type: sign
label: "Emergency Procedures"
visibility: always
text: >
IN CASE OF EMERGENCY:
1. Activate hull breach alarm (red panel, every 20m)
2. Proceed to nearest designated shelter (yellow markers)
3. Do not use freight lifts during emergency protocols
4. Await station authority clearance before resuming operations
Last safety review: Month 4, Year 312 (Krenn calendar)
dual_lens:
smuggler: "Two years out of date. Nobody bothers."
detective: "Last review was two cycles ago. Standard compliance lag for a district this size."
tags: [atmospheric]
# ==========================================
# BAY STATUS BOARD — current bay availability
# ==========================================
- id: env_terminal_007
type: sign
label: "Bay Status Board"
visibility: always
text: >
BAY STATUS — CURRENT CYCLE
BAY 1: ACTIVE
BAY 2: ACTIVE
BAY 3: ACTIVE
BAY 4: ACTIVE — PENDING REVIEW (2 items)
BAY 5: ACTIVE
BAY 6: MAINTENANCE HOLD — Est. clear: next cycle
BAY 7: ACTIVE
BAY 8: SCHEDULED DOWNTIME 13:00-15:00
dual_lens:
smuggler: "Bay 8 scheduled downtime 13:00-15:00. That overlaps the transition window. Useful."
detective: "Bay 8 downtime during transition window. Reduced oversight at peak movement time."
tags: [investigation-relevant, operational]
@@ -0,0 +1,225 @@
# Diegetic Insert Flavor Text — Detective Character
# Neural lattice interface strings: POI labels, notifications, system status.
# Ticket: #331 | Sprint: 12 | Owner: Mellanie + Araminta
#
# Voice principle: the detective's insert is issued Commission equipment.
# Institutional, precise, surname-first. The vocabulary is the job.
# When personal breaks through, it's notable — the first name appears
# only for allies. Shift to first name IS a relationship marker.
character: detective
# ==========================================
# POI LABELS — displayed in perception mode overlay
# Appears on NPCs when viewed through insert interface.
# State-dependent: shifts as relationship changes.
# ==========================================
poi_labels:
defaults:
unknown: "Unknown subject"
known: "{surname}, {initial}."
friendly: "{first_name}"
person_of_interest: "{surname} — POI"
hostile: "{surname} — AVOID"
npc_overrides:
kael_davan:
phase_1_unknown: "Unknown subject"
phase_1_known: "Davan, K."
person_of_interest: "Davan, K. — POI"
flagged: "Davan, K. — FLAGGED"
sera_venn:
friendly: "Sera"
phase_3_poi: "Sera — ?"
phase_4_poi: "Venn — REVIEW"
voss:
default: "Voss, K. — Supervisor"
cooperative: "Voss — cooperative"
torek_lintar:
default: "Lintar, T."
flagged: "Lintar, T. — FLAG"
commission_personnel:
default: "{surname} — Commission"
ring_member_suspected:
default: "{surname} — suspect"
# ==========================================
# NOTIFICATIONS — status strings, message previews
# Displayed as brief overlay text. 40 char max per line.
# ==========================================
notifications:
case_file:
- id: insert_d_001
trigger: case_file_update
template: "Case update: {subject_name}"
example: "Case update: Davan, K."
tags: [investigation]
- id: insert_d_002
trigger: evidence_logged
text: "Evidence logged"
tags: [investigation]
- id: insert_d_003
trigger: manifest_discrepancy_filed
text: "Discrepancy filed"
tags: [investigation, manifest]
- id: insert_d_004
trigger: knowledge_updated
template: "KG updated: {fact_category}"
example: "KG updated: relationship"
tags: [investigation]
- id: insert_d_005
trigger: poi_status_change
template: "{surname} — status updated"
example: "Davan — status updated"
tags: [investigation]
- id: insert_d_006
trigger: tell_logged
template: "{surname} — behavior logged"
example: "Davan — behavior logged"
tags: [investigation, tell]
- id: insert_d_007
trigger: pattern_confirmed
template: "{surname} — pattern confirmed"
example: "Venn — pattern confirmed"
tags: [investigation, pattern]
commission:
- id: insert_d_008
trigger: commission_advisory
text: "Commission advisory"
tags: [institutional]
- id: insert_d_009
trigger: commission_advisory_urgent
text: "Commission advisory — urgent"
tags: [institutional, priority]
- id: insert_d_010
trigger: clearance_required
template: "{zone} — clearance required"
example: "Sector B — clearance required"
tags: [institutional]
- id: insert_d_011
trigger: clearance_confirmed
template: "{zone} — clearance confirmed"
example: "B-7 — clearance confirmed"
tags: [institutional]
- id: insert_d_012
trigger: case_status_active
template: "Case #{case_id}: active"
example: "Case #4471: active"
tags: [institutional, investigation]
field:
- id: insert_d_013
trigger: observation_mode_active
text: "Observation: logging"
tags: [field]
- id: insert_d_014
trigger: restricted_area_entered
text: "Restricted zone — logged"
tags: [field, institutional]
- id: insert_d_015
trigger: anomaly_detected
template: "Anomaly: {location}"
example: "Anomaly: B-7 corridor"
tags: [field, investigation]
- id: insert_d_016
trigger: no_anomaly
text: "Sector: nominal"
tags: [field]
# ==========================================
# SYSTEM STATUS — persistent interface text
# Small-print labels in perception mode corners.
# ==========================================
system_status:
- id: insert_d_100
context: default
text: "Analytical lattice: active"
- id: insert_d_101
context: perception_mode_active
text: "Perception mode: on"
- id: insert_d_102
context: fog_layer_present
text: "Reduced visibility"
- id: insert_d_103
context: meridian_active
text: "Meridian: nominal"
- id: insert_d_104
context: meridian_inactive
text: "Meridian: offline"
- id: insert_d_105
context: investigation_active
text: "Case: active"
- id: insert_d_106
context: case_status_flagged
text: "Case: flagged subjects present"
- id: insert_d_107
context: case_status_critical
text: "Case: escalation threshold"
- id: insert_d_108
context: lattice_scan_complete
text: "Scan: complete"
# ==========================================
# EXAMINATION LABELS — text shown when examining environmental objects
# ==========================================
examination_labels:
- id: insert_d_200
object_type: document
text: "Examine"
- id: insert_d_201
object_type: terminal
text: "Query manifest"
- id: insert_d_202
object_type: container_flagged
text: "Container 4471 — discrepancy logged"
- id: insert_d_203
object_type: restricted_area
text: "Restricted — B-7 sector"
- id: insert_d_204
object_type: surveillance_gap
text: "Coverage gap — logged"
- id: insert_d_205
object_type: evidence_item
text: "Log as evidence"
- id: insert_d_206
object_type: known_meeting_point
text: "Meeting point — flagged"
notes: >
Voice: institutional, precise, Commission equipment. Surname-first for
targets and unknowns. First name appears only for allies (Sera).
That shift IS the relationship marker — the player notices when
"Venn" becomes "Sera" and knows it means something.
Araminta coordinates on visual presentation constraints (#331 co-owner).
@@ -0,0 +1,193 @@
# Diegetic Insert Flavor Text — Smuggler Character
# Neural lattice interface strings: POI labels, notifications, system status.
# Ticket: #331 | Sprint: 12 | Owner: Mellanie + Araminta
#
# Voice principle: the smuggler's insert is slightly informal, modified hardware.
# It's a tool that's been lived with, not issued equipment. Short labels.
# Uses first names and operational shorthand, not institutional vocabulary.
character: smuggler
# ==========================================
# POI LABELS — displayed in perception mode overlay
# Appears on NPCs when viewed through insert interface.
# State-dependent: shifts as relationship changes.
# ==========================================
poi_labels:
defaults:
unknown: "Unknown"
known: "{first_name}"
friendly: "{first_name} — OK"
person_of_interest: "{first_name} — WATCH"
hostile: "{first_name} — AVOID"
npc_overrides:
kael_davan:
phase_1: "Kael"
phase_2: "Kael — ?"
phase_3: "Kael — WATCH"
phase_4: "Kael — LYING"
phase_5: "Kael — COMPROMISED"
voss:
default: "Voss"
hostile: "Voss — PROBLEM"
commission_personnel:
default: "Commission"
investigation_active: "Commission — ACTIVE"
ring_member:
default: "{first_name}"
operational: "{first_name} — RING"
# ==========================================
# NOTIFICATIONS — status strings, message previews
# Displayed as brief overlay text. 40 char max per line.
# ==========================================
notifications:
messages:
- id: insert_s_001
trigger: message_from_contact
template: "Msg: {contact_name}"
example: "Msg: Kael"
tags: [ring-operational]
- id: insert_s_002
trigger: message_from_contact_urgent
template: "Msg: {contact_name} — urgent"
example: "Msg: Nils — urgent"
tags: [ring-operational, priority]
- id: insert_s_003
trigger: drop_point_update
text: "Drop point updated"
tags: [ring-operational]
- id: insert_s_004
trigger: drop_point_confirmed
text: "Drop point confirmed — proceed"
tags: [ring-operational]
- id: insert_s_005
trigger: meridian_window_opening
template: "Window: {duration}m"
example: "Window: 25m"
tags: [operational, timing]
- id: insert_s_006
trigger: meridian_window_closing
template: "Window closing — {time_remaining}m"
example: "Window closing — 3m"
tags: [operational, timing, priority]
- id: insert_s_007
trigger: ring_checkin_overdue
text: "Check-in overdue"
tags: [ring-operational, concern]
- id: insert_s_008
trigger: contact_unavailable
template: "{contact_name} — no response"
example: "Kael — no response"
tags: [ring-operational, concern]
- id: insert_s_009
trigger: commission_patrol_detected
text: "Commission in sector"
tags: [caution, operational]
- id: insert_s_010
trigger: commission_patrol_cleared
text: "Sector clear"
tags: [operational]
evidence:
- id: insert_s_011
trigger: observation_logged
text: "Logged"
tags: [observation]
- id: insert_s_012
trigger: tell_observed
template: "{npc_name} — tell logged"
example: "Kael — tell logged"
tags: [observation, tell]
- id: insert_s_013
trigger: contradiction_observed
template: "{npc_name} — anomaly"
example: "Kael — anomaly"
tags: [observation, contradiction]
# ==========================================
# SYSTEM STATUS — persistent interface text
# Small-print labels in perception mode corners.
# ==========================================
system_status:
- id: insert_s_100
context: default
text: "Lattice: Sova Transit"
- id: insert_s_101
context: perception_mode_active
text: "Perception: active"
- id: insert_s_102
context: fog_layer_present
text: "Fog layer"
- id: insert_s_103
context: meridian_dead_zone
text: "Meridian: inactive"
- id: insert_s_104
context: meridian_active
text: "Meridian: active — caution"
- id: insert_s_105
context: ring_status_stable
text: "Status: stable"
- id: insert_s_106
context: ring_status_elevated
text: "Status: elevated"
- id: insert_s_107
context: ring_status_compromised
text: "Status: compromised"
# ==========================================
# EXAMINATION LABELS — text shown when examining environmental objects
# ==========================================
examination_labels:
- id: insert_s_200
object_type: document
text: "Read"
- id: insert_s_201
object_type: terminal
text: "Check manifest"
- id: insert_s_202
object_type: container_flagged
text: "4471 — flagged"
- id: insert_s_203
object_type: restricted_area
text: "B-7 — restricted"
- id: insert_s_204
object_type: dead_drop
text: "Drop point"
- id: insert_s_205
object_type: safe_location
text: "Safe"
notes: >
Voice: informal, operational shorthand. This is a modified civilian
lattice, not issued equipment. First names, not titles. Short.
Labels should feel like something you'd put on your own interface.
Araminta coordinates on visual presentation constraints (#331 co-owner).
@@ -0,0 +1,306 @@
# Monologue: Detective — Commission Kiosk
# Ticket: #120 | Author: Mellanie | Sprint: 14
# Decision refs: D-016, D-032, D-034, D-035
#
# The Commission kiosk is a small terminal alcove in the transit district —
# institutional grey, cool lighting, regulation spec. The detective has
# authorized access. This is where official investigation happens: manifest
# logs, personnel records, flagged container reports. It smells like
# commission-issue air filters. The detective is comfortable here and also
# slightly isolated — the kiosk is public but feels private.
#
# Voice note: the detective is in his element at the kiosk — data flows,
# patterns emerge, the lattice is doing its work. But the kiosk is also a
# reminder that the case is bigger than the data suggests, and that Sera works
# with Commission systems daily. These lines should feel analytically engaged
# with moments of personal cost showing through.
#
# Schema: D-035 compliant. role/access/trust/situation included on all lines.
character: detective
location: commission-kiosk
lines:
# --- Arrival ---
- id: commission-kiosk_m_d_001
text: "Commission terminal. Authorized access, full import logs. Let's see what the system knows."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, tutorial, orientation]
- id: commission-kiosk_m_d_002
text: "Standard regulation spec. Commission requisition 4-C. They didn't upgrade this terminal in three years."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric]
- id: commission-kiosk_m_d_003
text: "Filtered air. A Commission smell — sterile, precise, faintly antiseptic. Familiar."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric, sensory]
- id: commission-kiosk_m_d_004
text: "Back at the kiosk. The terminal logged my last access thirty-two minutes ago."
role: player_character
access: [public]
trust: surface
trigger: return_visit
situation: [arrival]
tags: [arrival, orientation]
- id: commission-kiosk_m_d_005
text: "Nobody uses this terminal except Commission staff. Which means whoever was here before me has credentials."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, investigation, tutorial]
# --- Perception / Sound ---
- id: commission-kiosk_m_d_006
text: "The terminal hum is different from the freight equipment. Cleaner. Higher frequency."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental]
- id: commission-kiosk_m_d_007
text: "Footsteps at the transit corridor entrance. Not Commission — wrong pace."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine, observation]
tags: [sensory, caution]
- id: commission-kiosk_m_d_008
text: "Someone's running queries at the main manifest board. Loud keystrokes — frustrated."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental]
# --- Tutorial / Evidence ---
- id: commission-kiosk_m_d_009
text: "Import logs go back forty days. Weight discrepancies flagged automatically — unless someone cleared the flag."
role: player_character
access: [public]
trust: surface
trigger: discover_evidence
situation: [routine, investigation]
tags: [tutorial, investigation, orientation]
- id: commission-kiosk_m_d_010
text: "Personnel movement log. Every authorized access to every restricted space, timestamped."
role: player_character
access: [public]
trust: surface
trigger: discover_evidence
situation: [routine, investigation]
tags: [tutorial, investigation]
# --- Time Idle / Ruminative ---
- id: commission-kiosk_m_d_011
text: "Pattern is forming. Three manifests, same routing anomaly, different filing dates. That's deliberate."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_012
text: "The kiosk shows what the system knows. The system doesn't know what it hasn't been told."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, investigation]
- id: commission-kiosk_m_d_013
text: "Commission protocols say: document everything, infer nothing. The gap between those two is where cases live."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, analytical]
- id: commission-kiosk_m_d_014
text: "Twelve access events in the manifest terminal in the last six days. Elevated for a district this size."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_015
text: "Evidence is what the system logged. Inference is what it means. Right now I have plenty of both."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
tags: [investigation, analytical]
# --- Anomaly ---
- id: commission-kiosk_m_d_016
text: "Access log shows a non-Commission credential used at 0340. That terminal should have rejected it."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine, investigation]
priority: 7
tags: [investigation, caution, analytical]
- id: commission-kiosk_m_d_017
text: "Container 4471 has three separate manifest entries with different timestamps. One of them is false."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_of
priority: 8
tags: [investigation, contraband, analytical]
- id: commission-kiosk_m_d_018
text: "Standard access log would show calibration events. This one shows something cleared three days ago. Manually."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [investigation]
tags: [investigation, caution]
# --- Knowledge-Gated: Sera Arc ---
- id: commission-kiosk_m_d_019
text: "Venn, S. — last calibration event logged here: 0615 this morning. Standard. Her schedule is precise."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
relationship:
target: npc:sera-venn
state: known
priority: 5
tags: [npc, sera, routine, friend-arc]
- id: commission-kiosk_m_d_020
text: "Venn's credentials are in the access log. Three times today. She's thorough, or she's looking for something."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
prerequisites:
facts:
- fact_id: behavioral.sera_kiosk_pattern
min_confidence: suspects
priority: 6
tags: [npc, sera, investigation, friend-arc, dual-lens]
- id: commission-kiosk_m_d_021
text: "She runs calibration checks on the Commission terminal. That's her job. But the access log shows she's also running manifest queries. That's not."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [investigation]
prerequisites:
facts:
- fact_id: behavioral.sera_kiosk_pattern
min_confidence: knows_of
priority: 7
tags: [npc, sera, tell, investigation, friend-arc]
- id: commission-kiosk_m_d_022
text: "Venn cleared a flag on container 4471 six days ago. Routine override — authorized. But 4471 is in my manifest discrepancy list."
role: player_character
access: [public]
trust: surface
trigger: discover_evidence
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_of
- fact_id: behavioral.sera_kiosk_pattern
min_confidence: suspects
priority: 9
tags: [npc, sera, investigation, contraband, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_023
text: "She knows. Either she found it and cleared it deliberately, or she was directed to. Both are worse than I want to believe."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [investigation]
prerequisites:
facts:
- fact_id: behavioral.sera_avoidance_pattern
min_confidence: knows_of
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_details
priority: 9
tags: [npc, sera, investigation, friend-arc, contaminated-trust]
# --- Post-Conversation ---
- id: commission-kiosk_m_d_024
text: "She said she hadn't touched the manifest terminal. The log says otherwise. Either she forgot or she lied."
role: player_character
access: [public]
trust: surface
trigger: post_conversation
situation: [investigation]
prerequisites:
relationship:
target: npc:sera-venn
state: person_of_interest
priority: 9
tags: [npc, sera, post-conversation, tell, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_025
text: "Davan said he runs a clean dock. The manifest data disagrees on three specific points. Filed."
role: player_character
access: [public]
trust: surface
trigger: post_conversation
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_of
tags: [npc, kael, investigation, contraband]
@@ -0,0 +1,303 @@
# Monologue: Smuggler — Smuggling Hold
# Ticket: #120 | Author: Mellanie | Sprint: 14
# Decision refs: D-016, D-032, D-035, D-037
#
# The smuggling hold is below the maintenance corridors — an unmarked
# sub-level cargo space that the ring uses for temp storage and transfers.
# Not on any official manifest. The smuggler knows every pipe and access
# point here. This is both her operational center and her greatest exposure.
#
# Voice note: the smuggler here is at maximum operational competence and
# maximum paranoia simultaneously. She's in control of the space but the
# space itself is evidence of everything she's done. Lines should carry
# that dual register — calm on the surface, tight underneath.
#
# Schema: D-035 compliant. role/access/trust/situation included on all lines.
# Existing Sprint 5 files (the-terminal, the-last-shift, maintenance-corridors)
# will be updated by #168 (schema compliance pass).
character: smuggler
location: smuggling-hold
lines:
# --- Arrival ---
- id: smuggling-hold_m_s_001
text: "Sub-level. The kind of space nobody finds unless you know where to look."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric, operational]
- id: smuggling-hold_m_s_002
text: "Coolant smell. No ventilation down here — just recycled air and waiting."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric, sensory]
- id: smuggling-hold_m_s_003
text: "Access hatch sealed from the inside. Good. Route's still ours."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, operational]
- id: smuggling-hold_m_s_004
text: "Three containers in temp. Right where they should be."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
facts:
- fact_id: knowledge.ring_routing_knowledge
min_confidence: knows_of
tags: [arrival, operational, contraband]
- id: smuggling-hold_m_s_005
text: "Back in the hold. Dust on the floor shows the last two paths in. Both mine."
role: player_character
access: [public]
trust: surface
trigger: return_visit
situation: [arrival, routine]
tags: [arrival, operational]
- id: smuggling-hold_m_s_006
text: "Nobody's been here. Good. That's how it should feel."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric]
# --- Perception / Sound ---
- id: smuggling-hold_m_s_007
text: "The water recycler's on the other side of that wall. Loud enough to cover conversation."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental, operational]
- id: smuggling-hold_m_s_008
text: "Footsteps above. Maintenance crew — they stay up top. Always."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental, caution]
- id: smuggling-hold_m_s_009
text: "That sound. Pressure shift. Someone opened the main hatch."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine, investigation]
priority: 7
tags: [sensory, caution]
- id: smuggling-hold_m_s_010
text: "Pipe drone changes pitch when cargo shifts weight in the upper tier. That's cargo moving."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, operational]
# --- Time Idle / Ruminative ---
- id: smuggling-hold_m_s_011
text: "Fifteen minutes until the oversight window closes. Plenty of time. Probably."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [shift_transition]
tags: [operational, atmospheric]
- id: smuggling-hold_m_s_012
text: "Medical-grade lattice components. People need these. That's still the reason."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, contraband]
- id: smuggling-hold_m_s_013
text: "Twelve minutes of reduced oversight. Stop counting and do something useful."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [shift_transition]
tags: [operational]
- id: smuggling-hold_m_s_014
text: "How many times have I been in this room telling myself it's almost done?"
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, personal]
- id: smuggling-hold_m_s_015
text: "Quiet. The right kind. Not the wrong kind."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric]
# --- Anomaly / Investigation ---
- id: smuggling-hold_m_s_016
text: "Container 4471's been opened. Not by me. Not by anyone I authorized."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine, investigation]
priority: 8
tags: [operational, caution, contraband]
- id: smuggling-hold_m_s_017
text: "Dust disturbed at the secondary hatch. Recent. Someone's been using the back route."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine]
tags: [caution, operational]
- id: smuggling-hold_m_s_018
text: "Scratches on the access panel. New ones, over the old ones. Different tool."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine]
tags: [caution, environmental]
- id: smuggling-hold_m_s_019
text: "The temp unit's running warm. Someone moved cargo through here fast."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine]
tags: [operational, caution, contraband]
# --- Knowledge-Gated: Kael Arc ---
- id: smuggling-hold_m_s_020
text: "Kael used to wait for me here. The one place where we could actually talk."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
relationship:
target: npc:kael-davan
state: friendly
priority: 6
tags: [npc, kael, atmospheric, friend-arc]
- id: smuggling-hold_m_s_021
text: "Kael's not answering. Should be here by now. Should have been here ten minutes ago."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
prerequisites:
relationship:
target: npc:kael-davan
state: person_of_interest
priority: 7
tags: [npc, kael, concern, friend-arc]
- id: smuggling-hold_m_s_022
text: "If Kael talked to someone in that corridor — someone outside the ring — and then this container got touched... that's not coincidence."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
prerequisites:
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: knows_of
- fact_id: investigation.container_delay
min_confidence: suspects
priority: 9
tags: [npc, kael, investigation, contraband, friend-arc]
- id: smuggling-hold_m_s_023
text: "The manifest Kael helped me falsify is in that container. If he talked, they already know."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: suspects
- fact_id: knowledge.ring_routing_knowledge
min_confidence: knows_details
priority: 9
tags: [npc, kael, operational, contraband, friend-arc, contaminated-trust]
- id: smuggling-hold_m_s_024
text: "Renn's mark is here. The route's still active. So Kael hasn't burned everything. Yet."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: suspects
priority: 7
tags: [operational, kael, renn, friend-arc]
# --- Post-Conversation ---
- id: smuggling-hold_m_s_025
text: "Renn didn't ask questions. That's either loyalty or he already knows."
role: player_character
access: [public]
trust: surface
trigger: post_conversation
situation: [routine]
tags: [npc, renn, operational]
- id: smuggling-hold_m_s_026
text: "One more run. That's what I keep telling myself. Has been for eight months."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, personal, contraband]
@@ -0,0 +1,62 @@
# NPC Profile: Pael Varren
# Tier 3 — NOBODY / CIVILIAN
# Flat NPC — noise floor. Knows nothing about the ring.
# Observable presence near B-corridor is legitimate maintenance, not surveillance.
# Ticket: #307 | Sprint: 12
canonical_id: "npc:pael"
display_name: "Pael Varren"
tier: 3
pattern: "NOBODY"
motivation: "CIVILIAN"
description: >
Station maintenance tech, early 40s. Has worked Sova Transit District for
fifteen years. Knows every duct, every failing seal, every air recycler
running off-spec. Appears throughout the district because maintenance is
everywhere. Carries a scanner everywhere; hands are perpetually stained with
lubricant. Pael has no ring connection, no secrets beyond a quiet resentment
of deferred maintenance tickets. Is frequently near B-corridor because the
environmental seal there fails on a cycle of roughly three weeks, without
fail, regardless of what Pael does about it.
want:
primary: "Happiness"
description: >
To get through a single shift without a new deferred ticket appearing on
the queue. Fifteen years of Sova has narrowed the definition of a good day.
routine:
summary: >
Arrives before any shift (05:30 entry), does a diagnostic walk of the
full district, starts on the highest-priority deferred tickets during
low-traffic windows. Reappears at the B-corridor junction around mid-shift
because the environmental seal there is always the problem. Leaves when
the shift cycle turns over. Pattern is completely predictable. That's the
point — Pael is never where they shouldn't be, they're just always where
the problems are.
personality:
demeanor: "tired-competent, dry humor, no interest in drama"
social_style: "minimal — answers when addressed, doesn't linger"
stress_response: "files a maintenance ticket and moves on"
tells: []
skills:
combat_trained: false
skills:
maintenance: 9
environmental_systems: 8
trust_levels:
surface: >
Fifteen years of Sova maintenance. "If it breaks, I've seen it break
before. Usually the same three things."
notes: >
NOBODY + CIVILIAN: pure noise floor. The "seems important but isn't" quality
is positional — frequent presence near B-corridor for genuine maintenance
reasons. One memorable observable trait: scanner always open, lubricant
stains on both hands even at shift start. Paula owns the trait design.
Mellanie owns the lines (#307).
@@ -0,0 +1,63 @@
# NPC Profile: Ren Tosse
# Tier 3 — NOBODY / CIVILIAN
# Flat NPC — noise floor. Drifter with no ring connection.
# Asks too many questions about cargo schedules. Is just looking for work.
# Ticket: #307 | Sprint: 12
canonical_id: "npc:ren"
display_name: "Ren Tosse"
tier: 3
pattern: "NOBODY"
motivation: "CIVILIAN"
description: >
Day worker, early 30s. Comes through Sova Transit on irregular rotations —
picks up freight work when it's available, moves on when it isn't. Knows the
station the way drifters do: which bays are short-handed, which supervisors
ask fewer questions, what the bar's cheap nights are. Has a worn transit bag
and a slightly too-casual awareness of exits — not operational awareness,
just the low-level situational alertness of someone who has moved through
too many unfamiliar stations. Genuinely unremarkable. No ring connection,
no secrets. Asks too many questions about night rotations and cargo schedules
because that's how you find short-handed bays.
want:
primary: "Wealth"
description: >
Regular work. Enough to stop moving for a while. Sova's a decent stop —
the freight work is steady when it comes through.
routine:
summary: >
Arrives after 10:00 when the day shift is settled. Checks the job board
at the terminal entrance. Wanders to The Last Shift for a drink and
whatever bar conversation yields useful shift information. Leaves by late
afternoon unless day work came through. Pattern is irregular by design —
no fixed schedule because there's no fixed role.
personality:
demeanor: "casual, observant, asks questions as if entitled to answers"
social_style: "sociable enough to get information, quiet when there's nothing useful"
stress_response: "moves on. There's always another station."
tells: []
skills:
combat_trained: false
skills:
cargo_handling: 6
freight_routing: 5
trust_levels:
surface: >
Passing through, looking for work. "Sova seems decent. Depends on
the freight situation."
notes: >
NOBODY + CIVILIAN: pure noise floor. The "seems important but isn't" quality
comes from drifter behavior patterns that mimic operative reconnaissance:
asking about night rotations, checking cargo schedules, casual interest in
specific bays. All genuine job-seeking. One memorable observable trait:
worn transit bag with tags from three different station systems, slightly
too-casual awareness of exits. Paula owns the trait design. Mellanie owns
the lines (#307).
@@ -0,0 +1,58 @@
# NPC Profile: Tev Osel
# Tier 3 — NOBODY / CIVILIAN
# Flat NPC — noise floor. Stands like a lookout. Is waiting for his partner.
# No ring connection. The resemblance to surveillance posture is accidental.
# Ticket: #307 | Sprint: 12
canonical_id: "npc:tev"
display_name: "Tev Osel"
tier: 3
pattern: "NOBODY"
motivation: "CIVILIAN"
description: >
Cargo staging worker, late 20s. Has an unfortunate habit of positioning
himself exactly where someone running corridor surveillance would stand —
at junctions, in doorways, facing the approach. This is where he waits for
Kosse, his partner, who works the late freight bay and takes the same route
back through the maintenance corridors. Tev times his breaks to the late
route schedule. He has no idea how this looks to someone tracking corridor
traffic. No ring connection. No secrets.
want:
primary: "Connection"
description: >
For Kosse to finish shift on time for once. It never happens on time.
routine:
summary: >
Works cargo staging 06:00-14:00. During break windows, drifts to corridor
junction C-4 to wait for Kosse on the late freight route home. Returns to
staging when Kosse doesn't appear within the break window. After shift:
walks home with Kosse when schedules align, alone otherwise. The junction
positioning is consistent and timing-predictable.
personality:
demeanor: "patient, unassuming, mildly distracted when waiting"
social_style: "polite if addressed, slightly confused why anyone is talking to him"
stress_response: "checks lattice. Waits longer."
tells: []
skills:
combat_trained: false
skills:
cargo_staging: 6
trust_levels:
surface: >
Waiting for someone. "Kosse's running late again. You know how the freight
bay is."
notes: >
NOBODY + CIVILIAN: pure noise floor. The "seems important but isn't" quality
is entirely positional — standing at corridor junctions, watching approach
traffic, checking the time, stationing himself in threshold positions. All
of this is to meet a partner after shift. One memorable observable trait:
weight always on the wrong foot, perpetually glancing the same direction.
Paula owns the trait design. Mellanie owns the lines (#307).
@@ -1,6 +1,9 @@
# News ticker headline pool — The Last Shift bar
# Meridian Feed (Krenn System local news network)
# 30 lines total | Categories: freight (9), politics (4), infrastructure (5), sports (3),
# commission (5), community (4) — see dual_lens notes on each line
# Dual-lens content: each headline reads differently to smuggler vs detective.
# Ticket: #306 | Sprint: 12 | Owner: Mellanie + Miri
# v0.1: static background text. v0.2: cycling ticker system.
location: the-last-shift
@@ -155,3 +158,85 @@ headlines:
dual_lens:
smuggler: "'Routine assessment.' Is that the detective? Is that why he's here?"
detective: "That's my cover story. Or headquarters' way of telling me the clock is running."
# --- Local Community (4) — added Sprint 12, Mellanie ---
- id: ticker_021
text: "SECTOR 3: Sova Station Admin acknowledges ventilation review — timeline 'under discussion'"
category: infrastructure
dual_lens:
smuggler: "Under discussion. Same thing they said last cycle. Seven years of under discussion."
detective: "Deferred maintenance, seven-year backlog, workers frustrated. That's a community with grievances. File that."
- id: ticker_022
text: "WORKERS: Cargo Workers' Association calls for review of thirty-minute shift handoff protocols"
category: community
dual_lens:
smuggler: "Workers pushing back on the transition window. If they shorten it, we lose our gap."
detective: "Workers know the window is too short. And they're right. The question is who else knows it."
- id: ticker_023
text: "SOVA LOCAL: Missing persons — Vessels Tamm, Sova Transit District, Month 11. Contact Sova liaison."
category: community
dual_lens:
smuggler: "Vessels Tamm. That name — any connection to Naia's family? Don't chase ghosts."
detective: "Tamm. Naia Tamm is the name in the case file as Kael Davan's partner. Related? Flag."
notes: >
Connects to notice board in the-last-shift environmental text (env_bar_003).
Detective's reaction sets up the Naia-Tamm connection thread.
- id: ticker_024
text: "HEALTH: Re-embodiment wait times at Sova Clinic — non-emergency cases: 4-6 cycles. Main station for urgent."
category: community
dual_lens:
smuggler: "Four cycles. If something goes wrong on a run, four cycles at the clinic before you're back. Remember that."
detective: "Healthcare routing. Non-urgent cases handled locally, urgent cases moved off-station. Operational detail."
# --- Lattice Economy (3) — added Sprint 12, Mellanie ---
- id: ticker_025
text: "LATTICE: Commission-approved upgrade costs rise — standard lattice enhancement up 18% this cycle"
category: freight
dual_lens:
smuggler: "Eighteen percent. That's why the waiting list for off-book components keeps growing. We're providing a service."
detective: "Price pressure on approved upgrades. Economic driver for the unlicensed market. Motive confirmed."
- id: ticker_026
text: "LATTICE COMMISSION: Public access modification registry now mandatory — self-report deadline cycle 20"
category: commission
dual_lens:
smuggler: "Self-report. Right. The people who bought off-book components are definitely filing that form."
detective: "Mandatory registry. Anyone who doesn't file either doesn't have mods or is hiding them. Narrow that list."
- id: ticker_027
text: "HEALTHCARE: Medical-grade lattice component recall — batch 44xx — contact nearest clinic for assessment"
category: community
dual_lens:
smuggler: "Batch 44xx. Kael's last shipment was 4471 series components. Are they in that range? Check."
detective: "Batch 44xx recall. Container 4471 in the manifest anomaly. Cross-reference. This could be the thread."
notes: >
High-value dual-lens trigger. Smuggler worries about operational risk; detective spots the
evidence connection. The 44xx range is intentionally ambiguous — might match, might not.
# --- Krenn System / Setting (2) — added Sprint 12, Mellanie ---
- id: ticker_028
text: "VELEN WEATHER: Coastal fog advisories in effect — Sova orbital approach window reduced, gate scheduling adjusted"
category: infrastructure
dual_lens:
smuggler: "Fog on Velen. Gate scheduling adjusted — means transit timing shifts. The window might move."
detective: "Environmental factors affecting gate operations. Adjust surveillance timing accordingly."
- id: ticker_029
text: "REACH ECONOMY: Krenn System trade volume up 7% — analysts attribute to 'informal market activity'"
category: freight
dual_lens:
smuggler: "Informal market activity. They know. The whole Reach knows. And still the demand keeps climbing."
detective: "'Informal market.' The analysts see the pattern but can't name it. I can."
- id: ticker_030
text: "ASSEMBLY: Severance technology moratorium extended — counter-surveillance devices remain Category 2 restricted"
category: politics
dual_lens:
smuggler: "Moratorium extended. Good. Severance equipment costs keep the Commission from blanketing everything."
detective: "Counter-surveillance devices still legally restricted. Anyone running them is committing a documented infraction."
+42
View File
@@ -0,0 +1,42 @@
# Fact catalog: awareness
# Situational awareness — who's present, active threats, operational status
# Source: docs/design/knowledge-vocabulary-v01.md §4.2 (#368)
facts:
- fact_id: awareness.detective_presence
description: A Commission detective is operating on Sova Station
typical_confidence: knows_of
characters: [smuggler]
notes: "Smuggler learns through rumor or direct observation. Detective does not hold this fact about themselves."
- fact_id: awareness.detective_investigating_ring
description: The detective is specifically targeting the smuggling ring, not a routine audit
typical_confidence: knows_details
characters: [smuggler]
- fact_id: awareness.commission_audit_scheduled
description: A Commission audit is coming — officially scheduled
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: awareness.ring_route_compromise
description: The current drop route may be compromised or under observation
typical_confidence: suspects
characters: [smuggler]
- fact_id: awareness.surveillance_change
description: Surveillance patterns in the maintenance corridors have changed recently
typical_confidence: suspects
characters: [smuggler]
- fact_id: awareness.ring_tension_escalation
description: Something is wrong inside the ring — someone is scared or making moves
typical_confidence: suspects
characters: [smuggler]
- fact_id: awareness.inspection_sweep_active
description: Active inspection sweep in progress right now
typical_confidence: direct
characters: [smuggler, detective]
notes: "direct confidence — set by environmental observation, not investigation"
+42
View File
@@ -0,0 +1,42 @@
# Fact catalog: behavioral
# Behavioral patterns recognized through repeated observation across multiple scenes
# Source: docs/design/knowledge-vocabulary-v01.md §4.3 (#368)
facts:
- fact_id: behavioral.kael_evasion
description: Kael looks left when lying — personal tell recognized post-conversation
typical_confidence: direct
characters: [smuggler]
notes: "Set during a conversation where evasion is detected. direct confidence = just confirmed."
- fact_id: behavioral.kael_behavioral_change
description: Kael's behavior has noticeably changed — quieter, distracted, different routine
typical_confidence: suspects
characters: [smuggler]
- fact_id: behavioral.sera_avoidance_pattern
description: Sera consistently avoids Torek Lintar — leaves when he arrives, repositions
typical_confidence: suspects
characters: [smuggler, detective]
notes: "One observation = suspects; three observations = knows_of (pattern confirmed for detective)"
- fact_id: behavioral.sera_topic_deflection
description: Sera deflects specific topics — Commission work, manifest audits, freight
typical_confidence: suspects
characters: [smuggler, detective]
- fact_id: behavioral.sera_kiosk_pattern
description: Sera habitually stops at the same Commission kiosk at the same time
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: behavioral.sera_knowledge_detail
description: Sera's offhand comments reveal detailed knowledge of manifest routing she shouldn't have
typical_confidence: knows_of
characters: [smuggler]
- fact_id: behavioral.voss_awareness
description: Voss is tracking people — situational awareness beyond normal supervisor behavior
typical_confidence: suspects
characters: [detective]
+38
View File
@@ -1 +1,39 @@
# Fact catalog: contraband
# Smuggling ring world facts — existence, components, operations, supply chain
# Source: docs/design/knowledge-vocabulary-v01.md §4.5 (#368)
facts:
- fact_id: contraband.ring_exists
description: A smuggling operation is running through Sova Transit District
typical_confidence: suspects
characters: [smuggler, detective]
notes: "Smuggler starts at knows_details (Background — they ARE in the ring). Detective starts at suspects."
- fact_id: contraband.ring_lattice_components
description: The ring moves unlicensed lattice components — aftermarket neural mods
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Smuggler starts at knows_details (Background). Detective discovers through investigation."
- fact_id: contraband.ring_medical_components
description: The ring also diverts medical-grade lattice replacements from Syndic supply chains
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Deep investigation only — the moral dimension of the operation"
- fact_id: contraband.supply_chain
description: Understanding of the ring's supply chain — where components come from
typical_confidence: knows_details
characters: [smuggler, detective]
- fact_id: contraband.severance_tech_presence
description: Severance counter-surveillance technology is in play on the station
typical_confidence: suspects
characters: [detective]
- fact_id: contraband.drop_tonight
description: A drop is scheduled for tonight specifically
typical_confidence: knows_details
characters: [smuggler]
notes: "Seed-dependent — only relevant in seeds where a drop is scheduled during play session"
+126
View File
@@ -1 +1,127 @@
# Fact catalog: investigation
# Case-specific findings — events witnessed, evidence discovered, NPC behaviors confirmed
# Source: docs/design/knowledge-vocabulary-v01.md §4.1 (#368)
facts:
- fact_id: investigation.kael_corridor_meeting
description: Kael was seen in corridor B-7 off-shift, apparently meeting someone
typical_confidence: suspects
characters: [smuggler, detective]
notes: "Shared fact ID; smuggler perspective = operational security breach; detective perspective = initial observation"
- fact_id: investigation.kael_corridor_sighting
description: Detective's initial B-7 observation — Kael spotted in maintenance corridors at unusual hour
typical_confidence: suspects
characters: [detective]
notes: "Detective-only initial ID; may upgrade to kael_corridor_meeting after corroboration"
- fact_id: investigation.kael_unknown_contact
description: The person Kael met in B-7 is unknown — not from the regular rotation
typical_confidence: suspects
characters: [smuggler, detective]
- fact_id: investigation.kael_ring_membership
description: Kael is a member of the smuggling ring
typical_confidence: suspects
characters: [smuggler, detective]
- fact_id: investigation.kael_exit_attempt
description: Kael is actively trying to leave the ring
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: investigation.manifest_discrepancy
description: Cargo manifests show irregularities — containers, weights, routing anomalies
typical_confidence: suspects
characters: [smuggler, detective]
notes: "Detective starts with this at suspects (Background). Smuggler may encounter it during operational checks."
- fact_id: investigation.cargo_anomaly
description: A specific cargo anomaly — wrong container, wrong weight, wrong route
typical_confidence: suspects
characters: [detective]
- fact_id: investigation.shift_mismatch
description: Shift records don't match observed personnel — someone is covering for someone
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: investigation.voss_ring_involvement
description: Voss manages schedule changes to cover drops — involved in ring operations
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: investigation.torek_spending_pattern
description: Torek is spending beyond his dock-worker means — visible at bar
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: investigation.drin_inspection_pattern
description: Drin's inspections are conspicuously lax — clearing containers without real checking
typical_confidence: knows_of
characters: [detective]
- fact_id: investigation.drin_gambling_debt
description: Drin has a gambling debt — leverage for whoever is running him
typical_confidence: knows_details
characters: [detective]
- fact_id: investigation.oversight_gap_pattern
description: Inspection gaps are systematic, not accidental — someone designed the blind spots
typical_confidence: knows_of
characters: [detective]
- fact_id: investigation.surveillance_gaps
description: Specific surveillance camera blind spots in the maintenance corridors
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Smuggler knows from Background (operational necessity). Detective discovers through investigation."
- fact_id: investigation.smuggling_route
description: The specific smuggling route through the station
typical_confidence: knows_details
characters: [smuggler, detective]
- fact_id: investigation.ring_existence
description: A smuggling ring is definitely operating through Sova Transit (detective's confirmation)
typical_confidence: suspects
characters: [detective]
notes: "Detective starts with suspects from manifest anomalies. Smuggler knows via Background (knows_details)."
- fact_id: investigation.renn_courier_role
description: Renn operates as a courier in the ring — runs packages between drop sites
typical_confidence: knows_of
characters: [detective]
- fact_id: investigation.corridor_regular_use
description: The maintenance corridors are regularly used for smuggling, not just incidentally
typical_confidence: knows_of
characters: [detective]
- fact_id: investigation.container_delay
description: A specific container has been held in temporary storage longer than normal
typical_confidence: suspects
characters: [smuggler]
- fact_id: investigation.sera_evidence_held
description: Sera has unreported evidence about manifest irregularities — sitting on it deliberately
typical_confidence: suspects
characters: [detective]
notes: "suspects from avoidance pattern; knows_details only if Sera discloses or evidence found independently"
- fact_id: investigation.naia_lattice_condition
description: Naia's neural lattice is degrading — the medical components are for her
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Requires Kael disclosure or independent medical evidence"
- fact_id: investigation.ring_leadership
description: Who directs ring operations — late-game discovery
typical_confidence: knows_details
characters: [smuggler, detective]
- fact_id: investigation.drin_inspection_pattern
description: Drin's inspection patterns protect specific containers at specific shift windows
typical_confidence: knows_of
characters: [detective]
+32
View File
@@ -0,0 +1,32 @@
# Fact catalog: knowledge
# Operational knowledge — ring procedures, social dynamics, institutional mechanics
# Source: docs/design/knowledge-vocabulary-v01.md §4.4 (#368)
facts:
- fact_id: knowledge.ring_routing_knowledge
description: Full knowledge of the ring's drop routes and timing protocols
typical_confidence: knows_details
characters: [smuggler]
notes: "Smuggler starts at knows_details (Background). Not available to detective through normal investigation paths."
- fact_id: knowledge.ring_bar_operations
description: The bar (The Last Shift) has a role in ring communications or social cover
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: knowledge.bar_back_room_significance
description: The bar's back room is significant — meeting space, handoff point, or storage
typical_confidence: suspects
characters: [detective]
- fact_id: knowledge.bar_ring_awareness
description: Bar regulars have varying levels of awareness of the ring — open secret at some level
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: knowledge.kael_exit_plan
description: Kael has a specific exit plan — timing, conditions, contact
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Only available through Kael's deep trust disclosure or independent investigation"
+50
View File
@@ -1 +1,51 @@
# Fact catalog: location
# Location-specific access, restrictions, and significance
# Source: docs/design/knowledge-vocabulary-v01.md §4.6 (#368)
facts:
- fact_id: location.corridor_b7_restricted
description: Corridor B-7 is a restricted access zone — requires credentials
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Both characters have this from Background (signage, institutional knowledge)"
- fact_id: location.corridor_b7_drop_site
description: B-7 is an active drop site for the ring
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Smuggler from Background. Detective through investigation."
- fact_id: location.maintenance_corridors_access
description: Character knows the maintenance corridor layout and can navigate it
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Smuggler at knows_details (Background — daily operational space). Detective at knows_of after investigation."
- fact_id: location.bay4_sealed
description: Bay 4 is sealed — inspection or deliberate cover
typical_confidence: suspects
characters: [smuggler, detective]
- fact_id: location.span_gate_schedule_gap
description: The span gate schedule has a window that enables unseen cargo drops
typical_confidence: knows_details
characters: [smuggler]
- fact_id: location.smuggling_route
description: The complete physical route the ring uses through the station
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Smuggler from Background. Detective from deep investigation."
- fact_id: location.surveillance_gaps
description: Specific camera blind spots in the maintenance corridor network
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Smuggler from Background. Detective must discover independently."
- fact_id: location.dead_drop_location
description: Specific dead drop location(s) used for handoffs
typical_confidence: knows_details
characters: [smuggler]
notes: "Smuggler operational knowledge"
+35
View File
@@ -1 +1,36 @@
# Fact catalog: progress
# Narrative milestone flags
# Note: progress.* facts are NOT recommended for direct authoring in prerequisite blocks.
# Use investigation fact chains and relationship states instead.
# These exist for potential storyteller/arc-tracking use.
# Source: docs/design/knowledge-vocabulary-v01.md §3.2 (#368)
facts:
- fact_id: progress.friend_arc_phase_1
description: FRIEND arc Phase 1 warmth established (baseline positive relationship)
notes: "Set by relationship state transition to Friendly. Do not author directly — use relationship checks."
- fact_id: progress.friend_arc_phase_2
description: FRIEND arc Phase 2 trust built (deep rapport + initial social context)
notes: "Set by trust tier advancement. Do not author directly — use investigation + relationship checks."
- fact_id: progress.friend_arc_contradiction
description: FRIEND contradiction discovered (KnowledgeState → Contradicted)
notes: "Set by contradiction detection system. Use relationship state person_of_interest + investigation fact instead."
- fact_id: progress.friend_arc_confrontation
description: FRIEND confronted about contradiction
notes: "Set post-confrontation by dialogue system."
- fact_id: progress.opening_complete
description: Opening tutorial sequence completed (first 5 minutes)
notes: "Set by storyteller after initial objective sequence."
- fact_id: progress.first_ring_contact
description: Smuggler completed first ring operation during play session
characters: [smuggler]
- fact_id: progress.first_evidence_filed
description: Detective filed first manifest discrepancy evidence
characters: [detective]
@@ -1 +1,33 @@
# Fact catalog: relationship
# Inter-NPC connections — what one character knows about relationships between others
# Source: docs/design/knowledge-vocabulary-v01.md §4.7 (#368)
facts:
- fact_id: relationship.kael_naia_connection
description: Kael and Naia are romantically partnered — not just colleagues
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Smuggler may know from background or Kael mentioning Naia. Detective through social observation."
- fact_id: relationship.ring_membership
description: Character has identified specific ring members
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "knows_of = partial list; knows_details = full ring membership identified"
- fact_id: relationship.trust_network
description: Understanding of who trusts whom inside the ring — operational reliability mapping
typical_confidence: knows_details
characters: [smuggler]
notes: "Smuggler-specific operational knowledge"
- fact_id: relationship.tamm_family
description: Connection between Naia Tamm and her family situation
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: relationship.torek_bar_arrangement
description: Torek has a financial arrangement related to the bar or its operations
typical_confidence: knows_of
characters: [smuggler, detective]
+11
View File
@@ -0,0 +1,11 @@
# Fact catalog: social
# Social positioning facts — standing in a specific social context
# Source: docs/design/knowledge-vocabulary-v01.md §4.8 (#368)
facts:
- fact_id: social.bar_regular_status
description: Character is an established regular at The Last Shift — recognized, trusted in that context
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Smuggler may have this from Background. Detective builds it through multiple bar visits."
+34
View File
@@ -1 +1,35 @@
# Fact catalog: world
# Faction and institutional knowledge — Commission, lattice regulation, broader context
# Source: docs/design/knowledge-vocabulary-v01.md §4.6 (#368)
facts:
- fact_id: world.lattice_regulation_active
description: Commission actively regulates neural lattice component access in this system
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Both characters have this from Background"
- fact_id: world.unlicensed_lattice_demand
description: High demand for unlicensed lattice upgrades on working-class stations like Sova
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "The moral context of the operation — smuggler knows it as justification; detective may learn it as complication"
- fact_id: world.commission_sova_jurisdiction
description: Commission has jurisdiction over Sova Transit District
typical_confidence: knows_details
characters: [detective]
notes: "Detective Background knowledge — institutional mandate"
- fact_id: world.ring_cross_system
description: The ring operates across multiple star systems — Sova is one node
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Late-game discovery — changes scope of the operation"
- fact_id: world.shift_schedule
description: Sova Transit District shift schedule — who works when
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Smuggler from Background (colleague). Detective through investigation."
+821
View File
@@ -0,0 +1,821 @@
# NPC-to-NPC Overheard Dialogue Pool
# Ticket: #536 | Author: Mellanie | Sprint: 14
# Decision refs: D-078, D-018, D-071, D-035
#
# These are conversations the player can overhear when stationary near two
# NPCs. The passive dialogue panel (D-078) displays them with per-word
# occlusion based on distance, ambient noise, and ListeningFocus stance.
#
# OCCLUSION-RESILIENT AUTHORING RULES (D-078):
# 1. Front-load key information — most important word in first third.
# 2. Short declarative sentences — one idea per turn.
# 3. No pronoun-first openers — first word has highest drop risk; a dropped
# pronoun without antecedent is unresolvable. Use names or nouns.
# 4. Each turn is self-contained — a player who hears only one side gets
# a complete thought.
#
# SCHEMA NOTE:
# `relationship_type` and `knowledge_payload` are custom extensions to the
# D-035 schema for this content type. NPC-sourced lines use `role: npc`,
# `access` and `trust` apply normally. Monologue-specific tags (`character`,
# `trigger`, `prerequisite`) are not used here.
# Schema confirmation with Gestalt (#168) before CI validation.
#
# REGISTERS:
# social — idle chat, personal news, relationship talk
# work — shift logistics, job gripes, operational notes
# gossip — third-party information with player-relevant knowledge payload
#
# RELATIONSHIP TYPES:
# colleague, friend, hostile, romantic
#
# KNOWLEDGE PAYLOAD FORMAT:
# Plain English description of what the player can infer from hearing this
# exchange clearly — or the key fragment they retain under partial occlusion.
# null if the exchange is social noise with no investigative value.
pairs:
# ===================================================================
# SOCIAL register — personal news, idle chat, relationships
# ===================================================================
- id: overheard_001
register: social
speaker_a:
role: dock-worker
text: "Kael brought food for the whole bay yesterday. Just showed up with it."
speaker_b:
role: dock-worker
text: "Naia must have made him feel guilty about something. Again."
relationship_type: colleague
topic: personal-gossip
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Naia have a relationship dynamic. Naia has leverage or emotional pull over Kael's behavior."
tags: [kael, naia, social, atmosphere]
- id: overheard_002
register: social
speaker_a:
role: bar-regular
text: "Lera's keeping the kitchen open late this week. Span gate delay — crews stuck here."
speaker_b:
role: bar-regular
text: "Good for Lera. Bad for everyone waiting on the gate."
relationship_type: friend
topic: station-life
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, span-gate, atmosphere, social]
- id: overheard_003
register: social
speaker_a:
role: dock-worker
text: "Drin's applying for the transfer. Again. Third time he's tried."
speaker_b:
role: dock-worker
text: "Drin's never getting off Sova. Some people just belong to a place."
relationship_type: colleague
topic: career-gossip
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Drin wants out of Sova Transit. Unhappy enough to pursue transfer requests."
tags: [drin, transfer, social, atmosphere]
- id: overheard_004
register: social
speaker_a:
role: bar-regular
text: "Naia and Kael had a fight. Loud enough that Lera had to step in."
speaker_b:
role: bar-regular
text: "Kael looked rough this morning. That tracks."
relationship_type: friend
topic: relationship-drama
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Naia are under relationship strain. Kael's mood is affected. Cross-reference: behavioral.kael_behavioral_change."
tags: [kael, naia, relationship, friend-arc-adjacent, dual-lens]
- id: overheard_005
register: social
speaker_a:
role: dock-worker
text: "Renn's finally getting his lattice service done. Been putting it off for years."
speaker_b:
role: dock-worker
text: "Commission clinic wait time is eight months. Renn found someone faster."
relationship_type: colleague
topic: lattice-access
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Renn found a non-Commission lattice service provider. Points toward unlicensed lattice components market."
tags: [renn, lattice, contraband-adjacent, atmosphere]
- id: overheard_006
register: social
speaker_a:
role: bar-regular
text: "Maret's promotion came through. Third level supervisor."
speaker_b:
role: bar-regular
text: "Good. Maret actually knows what she's doing. Unlike some."
relationship_type: colleague
topic: career-news
location_hints: [the-last-shift, the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [maret, promotion, atmosphere, social]
- id: overheard_007
register: social
speaker_a:
role: dock-worker
text: "Voss has been in a mood all week. Something from upstairs."
speaker_b:
role: dock-worker
text: "Voss is always in a mood. Week ends in -day, Voss is in a mood."
relationship_type: colleague
topic: supervisor-gossip
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss is under external pressure from 'upstairs.' Something is stressing management."
tags: [voss, management, atmosphere, social]
- id: overheard_008
register: social
speaker_a:
role: bar-regular
text: "Sera's been coming here every evening this week. Thought Commission people didn't drink."
speaker_b:
role: bar-regular
text: "Sera's different. She fits here better than she fits over there."
relationship_type: colleague
topic: social-observation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera Venn is a regular at The Last Shift, unusual for a Commission employee. She has social roots in the district."
tags: [sera, commission, atmosphere, social, dual-lens]
- id: overheard_009
register: social
speaker_a:
role: dock-worker
text: "Nils covered Kael's morning slot yesterday. No explanation, just a roster note."
speaker_b:
role: dock-worker
text: "Kael's been doing that a lot lately. Taking sick leave, then showing up mid-shift."
relationship_type: colleague
topic: roster-anomaly
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Kael's schedule is irregular. Unexplained absences and late arrivals. Cross-reference: observe_anomaly triggers for Kael."
tags: [kael, nils, roster, friend-arc-adjacent]
- id: overheard_010
register: social
speaker_a:
role: maintenance-tech
text: "Olin's kid got into the Commission cadet program. Starts next cycle."
speaker_b:
role: maintenance-tech
text: "Olin must be pleased. Cost of living here, Commission pay makes sense."
relationship_type: colleague
topic: family-news
location_hints: [maintenance-corridors, the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [olin, commission, atmosphere, social]
# ===================================================================
# WORK register — shift logistics, operational gripes, job talk
# ===================================================================
- id: overheard_011
register: work
speaker_a:
role: dock-worker
text: "Maret shifted the roster again. Bay four to bay seven, no reason given."
speaker_b:
role: dock-worker
text: "Bay seven's the low-traffic slot. Somebody wanted less oversight over there."
relationship_type: colleague
topic: roster-change
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Bay seven has been given a low-oversight crew deliberately. This is operationally significant."
tags: [maret, roster, bay-seven, investigation-adjacent]
- id: overheard_012
register: work
speaker_a:
role: dock-worker
text: "Loading arm three is grinding again. Filed the report last week."
speaker_b:
role: dock-worker
text: "Maintenance says next cycle. Means nothing gets done until someone loses a hand."
relationship_type: colleague
topic: equipment-maintenance
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [equipment, maintenance, atmosphere, work]
- id: overheard_013
register: work
speaker_a:
role: dock-worker
text: "Container 4471's been in temp for three days. Someone should move it."
speaker_b:
role: dock-worker
text: "Routing says hold. Don't ask me, I just process what the board says."
relationship_type: colleague
topic: container-routing
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Container 4471 is being deliberately held in temp storage. 'Routing says hold' — someone modified the routing instruction. Cross-reference: investigation.container_delay."
tags: [container-4471, routing, contraband-adjacent, investigation-payload]
- id: overheard_014
register: work
speaker_a:
role: dock-worker
text: "Voss cut the B-section crew by two. Says it's budget. Doesn't feel like budget."
speaker_b:
role: dock-worker
text: "Less crew in B-section means less oversight. Could be budget. Could be something else."
relationship_type: colleague
topic: crew-reduction
location_hints: [the-terminal, maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "B-section is understaffed. The speaker suspects this is deliberate. Cross-reference: ring oversight windows."
tags: [voss, b-section, oversight, investigation-payload]
- id: overheard_015
register: work
speaker_a:
role: dock-worker
text: "Span gate's backed up again. Forty-minute delay on the freight queue."
speaker_b:
role: dock-worker
text: "Forty minutes is nothing. Last month it was three hours. Patience."
relationship_type: colleague
topic: span-gate-delay
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [span-gate, freight, atmosphere, work]
- id: overheard_016
register: work
speaker_a:
role: maintenance-tech
text: "Junction C-2's camera was repositioned. Nobody filed a maintenance request."
speaker_b:
role: maintenance-tech
text: "Someone moved it without logging it. That's a compliance violation."
relationship_type: colleague
topic: camera-anomaly
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Camera at junction C-2 was moved without documentation. Suggests deliberate surveillance manipulation. Cross-reference: awareness.surveillance_change."
tags: [camera, surveillance, c-2, investigation-payload]
- id: overheard_017
register: work
speaker_a:
role: dock-worker
text: "Commission wants another inspection. Fourth one this quarter."
speaker_b:
role: dock-worker
text: "Inspections mean overtime. Inspections mean everyone's watching everyone."
relationship_type: colleague
topic: commission-inspection
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Commission is conducting elevated inspections of the terminal. Something has drawn their attention."
tags: [commission, inspection, atmosphere, investigation-adjacent]
- id: overheard_018
register: work
speaker_a:
role: dock-worker
text: "Weight manifest on the morning freight came in five hundred kilos short. Recalibration error."
speaker_b:
role: dock-worker
text: "Five hundred kilos doesn't disappear from a calibration error. It disappears from somewhere else."
relationship_type: colleague
topic: weight-discrepancy
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Manifest weight discrepancy exists and dock workers are suspicious of the official explanation. Cross-reference: investigation.manifest_discrepancy."
tags: [manifest, weight-discrepancy, contraband-adjacent, investigation-payload]
- id: overheard_019
register: work
speaker_a:
role: dock-worker
text: "Shift handover's going to be rough tonight. Torek's team hasn't filed."
speaker_b:
role: dock-worker
text: "Torek's team never files on time. Let Maret handle it."
relationship_type: colleague
topic: shift-handover
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [torek, maret, shift, atmosphere, work]
- id: overheard_020
register: work
speaker_a:
role: maintenance-tech
text: "Sub-level access has been requested twice this week. Both times outside shift hours."
speaker_b:
role: maintenance-tech
text: "Scheduled maintenance happens in-shift. Off-hours access needs sign-off."
relationship_type: colleague
topic: sub-level-access
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Someone has been accessing sub-level spaces outside normal hours. No sign-off implies unauthorized use. Cross-reference: smuggling-hold activity."
tags: [sub-level, access, maintenance, investigation-payload]
- id: overheard_021
register: work
speaker_a:
role: dock-worker
text: "Bay four's been sealed for inspection since yesterday morning."
speaker_b:
role: dock-worker
text: "Commission authorized it. Not Voss. Commission went over his head."
relationship_type: colleague
topic: bay-inspection
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Bay four inspection bypassed Voss's authority. Commission is operating independently of local management."
tags: [bay-four, commission, voss, inspection, investigation-payload]
- id: overheard_022
register: work
speaker_a:
role: dock-worker
text: "Kael's running Renn's route today. Renn's supposed to be on that."
speaker_b:
role: dock-worker
text: "Kael volunteered. Said Renn had something come up."
relationship_type: colleague
topic: route-substitution
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Kael is voluntarily taking routes outside his assignment. Could be covering for Renn, could be operational flexibility needed by the ring."
tags: [kael, renn, route, ring-adjacent]
- id: overheard_023
register: work
speaker_a:
role: bar-regular
text: "Lera's dealing with the supply chain issue again. Grain spirit supplier changed terms."
speaker_b:
role: bar-regular
text: "Lera will figure it out. Lera always figures it out."
relationship_type: friend
topic: supply-chain
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, supply, atmosphere, work]
- id: overheard_024
register: work
speaker_a:
role: maintenance-tech
text: "Condensation in the B-corridor's gotten worse. Someone's running heat-generation equipment down there."
speaker_b:
role: maintenance-tech
text: "Heat-gen in a maintenance corridor. That's either a storage issue or a very bad idea."
relationship_type: colleague
topic: corridor-anomaly
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Heat-generating equipment is being used in maintenance corridors unofficially. Points toward the smuggling hold or ring operations."
tags: [b-corridor, heat, maintenance, smuggling-adjacent]
- id: overheard_025
register: work
speaker_a:
role: dock-worker
text: "Torek's been doing double manifests for a month. Every container logged twice."
speaker_b:
role: dock-worker
text: "Double logging means one version goes somewhere it shouldn't."
relationship_type: colleague
topic: manifest-anomaly
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Torek is maintaining duplicate manifest records. One version is falsified. Direct evidence of ring operation at the administrative level."
tags: [torek, manifest, contraband, investigation-payload, high-value]
# ===================================================================
# GOSSIP register — third-party knowledge with investigative payload
# ===================================================================
- id: overheard_026
register: gossip
speaker_a:
role: dock-worker
text: "Kael was in corridor B-7 last night. Saw him myself. Off shift, wrong time, wrong place."
speaker_b:
role: dock-worker
text: "Kael lives near B-section. Could have been heading home the long way."
relationship_type: colleague
topic: kael-location
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael was in corridor B-7 off-shift. Eyewitness account. Cross-reference: investigation.kael_corridor_meeting."
tags: [kael, corridor-b7, investigation-payload, friend-arc, high-value]
- id: overheard_027
register: gossip
speaker_a:
role: bar-regular
text: "Kael was talking to someone near B-7 last night. Person I didn't recognize."
speaker_b:
role: bar-regular
text: "Unknown people at junction B-7 at night. That's not normal."
relationship_type: friend
topic: kael-unknown-contact
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael met with an unidentified person at corridor B-7 during off-hours. Cross-reference: investigation.kael_unknown_contact."
tags: [kael, unknown-contact, corridor-b7, friend-arc, high-value, investigation-payload]
- id: overheard_028
register: gossip
speaker_a:
role: bar-regular
text: "Torek spent three thousand credits at Lera's last week. Three thousand. On a dock worker's salary."
speaker_b:
role: bar-regular
text: "Torek's either very lucky or very stupid. Either way, someone's going to notice."
relationship_type: friend
topic: torek-spending
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Torek is spending significantly above his salary at The Last Shift. Cross-reference: investigation.torek_spending_pattern."
tags: [torek, spending, lera, investigation-payload, ring-adjacent]
- id: overheard_029
register: gossip
speaker_a:
role: dock-worker
text: "Renn got new boots. Commission-grade. Renn can't afford Commission-grade boots."
speaker_b:
role: dock-worker
text: "Renn's been running extra shifts. Or extra something."
relationship_type: colleague
topic: renn-spending
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Renn has unexplained extra income. Cross-reference: ring membership payments."
tags: [renn, income, ring-adjacent, investigation-adjacent]
- id: overheard_030
register: gossip
speaker_a:
role: bar-regular
text: "Sera Venn avoids Torek every time he's here. Every single time. Noticed it three weeks straight."
speaker_b:
role: bar-regular
text: "Torek does that to people. He talks too much and says things he shouldn't."
relationship_type: colleague
topic: sera-torek-avoidance
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera Venn has a consistent avoidance pattern toward Torek Lintar. Cross-reference: behavioral.sera_avoidance_pattern."
tags: [sera, torek, avoidance, friend-arc, investigation-payload, high-value]
- id: overheard_031
register: gossip
speaker_a:
role: bar-regular
text: "Commission officer's been asking questions at The Terminal. Polite questions. Thorough ones."
speaker_b:
role: bar-regular
text: "Polite and thorough is the worst combination. That's someone who has time."
relationship_type: colleague
topic: commission-investigation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "A Commission officer is conducting a quiet investigation at The Terminal. Cross-reference: awareness.detective_presence."
tags: [commission, detective, investigation-payload, awareness]
- id: overheard_032
register: gossip
speaker_a:
role: dock-worker
text: "Lattice components went through last month. Unlicensed grade. Manifest said 'mechanical parts.'"
speaker_b:
role: dock-worker
text: "Mechanical parts. Right. People find ways when the Commission won't."
relationship_type: colleague
topic: contraband-transit
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Unlicensed lattice components are moving through The Terminal under falsified manifests. Cross-reference: contraband operation confirmed."
tags: [lattice, contraband, manifest, investigation-payload, high-value]
- id: overheard_033
register: gossip
speaker_a:
role: maintenance-tech
text: "Sub-level temp storage has been accessed four times this week. Door log shows it."
speaker_b:
role: maintenance-tech
text: "Four times and no maintenance ticket filed. Someone's using it off-book."
relationship_type: colleague
topic: unauthorized-access
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "The sub-level storage (smuggling hold) has heavy unauthorized use documented in door logs. Direct evidence of ring operations."
tags: [sub-level, access-log, ring, investigation-payload, high-value]
- id: overheard_034
register: gossip
speaker_a:
role: bar-regular
text: "Voss changed the B-section rotation. Kael and Renn are both on the overnight slot now."
speaker_b:
role: bar-regular
text: "Kael and Renn on overnight in B-section. That's a very specific combination."
relationship_type: colleague
topic: roster-combination
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Renn have been placed on the same overnight B-section rotation. This creates the oversight gap the ring needs."
tags: [kael, renn, voss, roster, ring-adjacent, investigation-payload]
- id: overheard_035
register: gossip
speaker_a:
role: dock-worker
text: "Sera asked me about container routing last week. Wanted to know who approves temp storage extensions."
speaker_b:
role: dock-worker
text: "Commission tech asking about temp storage approvals. That's outside her job scope."
relationship_type: colleague
topic: sera-investigation
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Sera Venn is conducting her own investigation into temp storage approvals, outside her Commission mandate. Cross-reference: behavioral.sera_kiosk_pattern."
tags: [sera, storage, investigation-payload, friend-arc, dual-lens, high-value]
- id: overheard_036
register: gossip
speaker_a:
role: bar-regular
text: "Naia told me Kael hasn't been sleeping. Says he's up late, doesn't explain where."
speaker_b:
role: bar-regular
text: "Naia's worried about him. Kael won't talk about whatever it is."
relationship_type: friend
topic: kael-behavioral-change
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael's behavior has changed at home. Sleepless, secretive, won't explain to Naia. Cross-reference: behavioral.kael_behavioral_change, investigation.kael_attempting_exit."
tags: [kael, naia, behavior, friend-arc, investigation-payload]
- id: overheard_037
register: gossip
speaker_a:
role: dock-worker
text: "Someone flagged a manifest discrepancy on the morning freight. Voss cleared the flag himself."
speaker_b:
role: dock-worker
text: "Voss doesn't clear flags. That's Maret's job. Or Commission's."
relationship_type: colleague
topic: manifest-flag-cleared
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss manually cleared a manifest discrepancy flag, bypassing protocol. Suggests Voss is actively covering for ring activity."
tags: [voss, manifest, flag-cleared, investigation-payload, high-value]
- id: overheard_038
register: gossip
speaker_a:
role: bar-regular
text: "Kael's been asking about exit options. Not just talk — actually asking Lera if she knows anyone who could help someone disappear quietly."
speaker_b:
role: bar-regular
text: "Kael wants out of something. That's the only reason people ask that kind of question."
relationship_type: friend
topic: kael-exit-attempt
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael is actively trying to leave 'something' — most likely the ring. He's researching options. Cross-reference: investigation.kael_attempting_exit."
tags: [kael, exit, ring-adjacent, friend-arc, investigation-payload, high-value]
- id: overheard_039
register: gossip
speaker_a:
role: dock-worker
text: "Medical lattice upgrades are showing up on the black-side. Commission-grade, no paperwork."
speaker_b:
role: dock-worker
text: "People who need them can't wait on Commission approval. Someone's doing a service."
relationship_type: colleague
topic: black-market-lattice
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Medical-grade lattice components are available outside Commission channels. Confirms the moral framing of the contraband operation — it serves real medical need."
tags: [lattice, medical, black-market, contraband, moral-framing]
- id: overheard_040
register: gossip
speaker_a:
role: bar-regular
text: "Torek told someone he had a meeting last night. Past midnight. In maintenance."
speaker_b:
role: bar-regular
text: "Torek having midnight maintenance meetings. Sure. That's completely normal."
relationship_type: colleague
topic: torek-late-meeting
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Torek met with someone in the maintenance corridors late at night. Cross-reference: investigation.torek_ring_meeting."
tags: [torek, maintenance, midnight, ring-adjacent, investigation-payload]
# ===================================================================
# BONUS PAIRS — social and work depth
# ===================================================================
- id: overheard_041
register: social
speaker_a:
role: bar-regular
text: "Lera's thinking about expanding. Back room could seat twenty more."
speaker_b:
role: bar-regular
text: "Back room's the only reason this place has any privacy. Expand it and we lose that."
relationship_type: friend
topic: bar-expansion
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, bar, atmosphere, social]
- id: overheard_042
register: work
speaker_a:
role: dock-worker
text: "Shift change is late again. Maret says weather on Velen is delaying the span gate."
speaker_b:
role: dock-worker
text: "Weather on Velen. Which means fog. Which means the morning run is going to be rough."
relationship_type: colleague
topic: weather-delay
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [velen, fog, span-gate, weather, atmosphere]
- id: overheard_043
register: social
speaker_a:
role: bar-regular
text: "Olin's getting a commendation from Commission. Ten years of service."
speaker_b:
role: bar-regular
text: "Ten years. Commission gives you a piece of paper and a handshake."
relationship_type: colleague
topic: commission-recognition
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [olin, commission, atmosphere, social]
- id: overheard_044
register: work
speaker_a:
role: maintenance-tech
text: "Power fluctuation in sub-level B last night. Lasted three minutes."
speaker_b:
role: maintenance-tech
text: "Three minutes is enough to blind the cameras if you know the timing."
relationship_type: colleague
topic: power-fluctuation
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "A power fluctuation in sub-level B temporarily disabled cameras. Could be timed to enable unobserved access."
tags: [power, cameras, sub-level, timing, investigation-adjacent]
- id: overheard_045
register: gossip
speaker_a:
role: dock-worker
text: "Commission officer asked Drin about cargo manifest irregularities. Drin told him everything he knows, which isn't much."
speaker_b:
role: dock-worker
text: "Drin doesn't know much by design. That's why Drin's on inspection detail."
relationship_type: colleague
topic: commission-inquiry
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "The Commission officer is asking dock workers directly about manifest irregularities. Drin has been interviewed. Cross-reference: awareness.detective_presence."
tags: [drin, commission, detective, interview, investigation-payload]
- id: overheard_046
register: gossip
speaker_a:
role: bar-regular
text: "Sera's been keeping a list. Someone told Naia. Private list, personal data."
speaker_b:
role: bar-regular
text: "Sera keeping a list of what? And why would Naia know?"
relationship_type: friend
topic: sera-documentation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera is documenting something privately — possibly her own investigation or evidence she's sitting on. Cross-reference: investigation.sera_unreported_evidence."
tags: [sera, naia, list, evidence, friend-arc, investigation-payload, dual-lens]
- id: overheard_047
register: social
speaker_a:
role: dock-worker
text: "Sova's been home for twenty years. Still don't know if I love it or just got used to it."
speaker_b:
role: dock-worker
text: "Twenty years is the same thing."
relationship_type: friend
topic: station-life
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [sova, atmosphere, personal, social]
- id: overheard_048
register: work
speaker_a:
role: dock-worker
text: "Voss approved overtime for two extra heads on the B-section night shift. Unusual."
speaker_b:
role: dock-worker
text: "Overtime plus extra bodies in B at night. Either something's wrong or something's being made to look right."
relationship_type: colleague
topic: overtime-approval
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss approved extra overnight staffing in B-section — possibly to create cover or provide legitimate-looking explanation for movement in that area."
tags: [voss, overtime, b-section, night-shift, investigation-adjacent]
+1 -1
View File
@@ -11,7 +11,7 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
| File | Domain | Decisions |
|------|--------|-----------|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074 |
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
+3 -2
View File
@@ -83,6 +83,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Raised by:** Gestalt (schema design, Round 1/2), Mellanie (authoring validation, Round 1/2). Converged across both agents in Round 2.
- **Dissent:** None. Minor consolidations: Mellanie's 8 moods mapped to Gestalt's 8 (different names, same concepts). Mellanie's `crime` topic deliberately excluded (NPCs think of it as `cargo` or `money`).
- **Amendment (Sprint 8):** `focused` added as 9th mood (used in Kael dialogue at The Terminal and maintenance corridors). `greeting` added as 14th situation (used in PC dialogue pools for initial contact lines). Schema updated to match.
- **Amendment (Sprint 14):** Mood vocabulary renamed to match voice guide (monologue-voice-guide.md). Old → new: `fond``warm`, `comfortable``content`, `worried``anxious`, `concerned``frustrated`. Dropped: `analytical` (merged into `focused`), `conflicted` (modeled as `suspicious`+`warm` collision). Added: `hostile`. Final 8 moods: `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`. Neutral = untagged.
### D-036: Sova Transit District / Krenn System as v0.1 setting
- **Date:** 2026-02-11
@@ -178,8 +179,8 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Amends:** [D-041](architecture.md#d-041-knowledge-graph-data-model) (confirms confidence-to-trust mapping; supersedes the preliminary 1:1 sketch in D-041 "Key design choices" bullet 3 with the layered model above), [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) (Layer 3 trust now requires both relationship AND confidence)
- **Cross-reference:** [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers), [D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-062](#d-062-invisible-locked-dialogue-options) (confidence progression naturally unlocks new trust tiers, creating the "new options appearing" reward)
- **Raised by:** Tyre (technical analysis, architecture synthesis)
- **Dissent:** Pending review. Sprint briefing flags Gestalt and Nigel for archetype dimension input.
- **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.*
---
*16 decisions. Last updated: 2026-02-19*
*16 decisions. Last updated: 2026-02-19 (D-075 dissent updated)*
+60 -7
View File
@@ -273,13 +273,16 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Raised by:** Ozzie (timing values + urgency split, adopted), Araminta (visual transition spec), Gestalt (longer values not adopted, but playtesting may adjust)
- **Dissent:** Gestalt proposed 0.8-1.2s (longer, more contemplative). Lead chose Ozzie's shorter values as starting point.
### D-061: Dialogue box — bottom screen, max 20% height, no portraits
### D-061: Dialogue box — unified conversation log, bottom screen, max 20% height, no portraits
- **Date:** 2026-02-13
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width (not percentage-based — exact value TBD). Layout: NPC speech top, player response options below, left-aligned. Max 3 response options visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness.
- **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen.
- **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Stig (UI spec + no portraits), Lead (20% height constraint + max-width directive)
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width 640px ([D-076](#d-076-dialogue-box-max-width--640px-oq-29-resolution)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness.
- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log — not separate panels for active vs passive dialogue. Player-NPC conversations and overheard NPC-NPC conversations ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)) flow into the same scrolling log. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)).
- **Entry lifecycle:** All entries share the same timeout (15s + 3s fade, configurable via theme YAML). Walk-away clears response options but preserves log entries — earned information is fair game. Panel auto-hides when all entries expire and no active conversation is in progress.
- **Passive overheard lines:** Rendered at reduced opacity (0.9) per [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter). No response options for overheard content. Walk-away does not fire for passive-only display — the panel dismisses naturally when entries expire or the player walks out of earshot.
- **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen. A single unified log avoids a separate UI element for overheard content and makes the flow of conversation feel natural — active and passive dialogue interleave chronologically.
- **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers)), max-width ([D-076](#d-076-dialogue-box-max-width--640px-oq-29-resolution)), overheard NPC conversation ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter))
- **Source:** Control & Interaction Workshop (2026-02-13). Amended Sprint 14 (#535): unified log architecture.
- **Raised by:** Stig (UI spec + no portraits), Lead (20% height constraint + max-width directive). Sprint 14 unified log: Stig (implementation).
- **Dissent:** Stig initially proposed 25% height and 50% width centered. Lead constrained to 20% height and max-width.
### D-067: Recognition chime fires at onset of cognitive delay
@@ -345,6 +348,56 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Raised by:** Paula (zone-conspicuousness model), Inigo (scoping to future sprint)
- **Dissent:** None
### D-076: Dialogue box max-width — 640px (OQ-29 resolution)
- **Date:** 2026-02-19
- **Decision:** `DIALOGUE_MAX_WIDTH = 640px`. Dialogue box is max 640px wide, centered on screen.
- **Derivation:** 640px = 20 × TILE_SIZE (32px) — grid-aligned. ~33% of target 1920px viewport width. Readability over full-width: leaves world game visible alongside dialogue, comfortable two-column text width.
- **Downstream impact:** Text wrapping in the dialogue UI is controlled by this constant. Box is centered; the game world remains visible left and right.
- **Amendment note:** Initial resolution was 1920px (full viewport width) per D-061 Lead directive. Tyre architecture review (2026-02-19) revised to 640px for readability.
- **Implementation:** `Constants.DIALOGUE_MAX_WIDTH` in `client/scripts/constants.gd`.
- **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), dual-scale grid ([D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor))
- **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width)
- **Raised by:** Stig (OQ-29), revised per Tyre architecture review
### D-077: Zone temperature memory — server-tracked zone_id (OQ-09 resolution)
- **Date:** 2026-02-19
- **Decision:** Zone temperature memory is **server-tracked** via `zone_id: Option<u16>` on `VisibleTile` in `ObserverSnapshot`. The server assigns a zone ID to each tile based on the `ZoneMap` resource (spatial zone assignment). The client maps `zone_id` to temperature tint from a local lookup table for deep fog rendering ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven) layer 3: ~10% zone temperature tint — bar=warm dark, hub=cool dark, corridor=neutral dark).
- **Resolves:** OQ-09
- **Rationale:** D-073 already mandates `zone_id` per tile in `ObserverSnapshot` for audio zone crossfade. With `zone_id` already on the wire for crossfade, zone temperature memory is essentially free — no additional protocol field needed, no additional server computation beyond the zone lookup. Client-only tracking was rejected because: (1) the server is the authoritative source of zone geometry, (2) client heuristics (remembering last-visited zone) would diverge from server truth at zone boundaries, and (3) the data is already crossing the wire for D-073.
- **Implementation:**
- `VisibleTile.zone_id: Option<u16>``None` for tiles outside any defined zone (corridors, transition spaces). Uses `#[serde(default, skip_serializing_if)]` for backwards compatibility with v10 clients.
- `ZoneMap` resource (`simulation/zone.rs`): `BTreeMap<(i32, i32, i32), u16>` mapping tile coordinates to zone IDs. BTreeMap per [D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline) principle 4 (deterministic iteration). Populated by map builders at setup time.
- Zone enrichment runs in `compute_observer_snapshot` — the observer pipeline's final assembly stage. `PerceptionQuery` trait remains zone-unaware (zone assignment is map data, not perception geometry).
- Protocol version bumped to 11.
- **Client contract:** Client maintains a `zone_id → { name, temperature_tint, ambient_layer }` lookup table. Deep fog shader (layer 3) reads `zone_id` from the last-seen `VisibleTile` data to apply the ~10% temperature tint. AudioManager reads `zone_id` to trigger crossfade between ambient layers ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)).
- **Cross-reference:** Fog layers ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven)), zone crossfade ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)), ObserverSnapshot ([D-020](architecture.md#d-020-engine-and-architecture-selection--godot-client--rust-simulation-via-subprocessipc)), deterministic simulation ([D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline))
- **Raised by:** Tyre (architecture review, #523)
- **Dissent:** None
### D-078: Overheard NPC conversation — passive dialogue panel with occlusion filter
- **Date:** 2026-02-19
- **Decision:** NPC-to-NPC conversations overheard by the player are displayed in the **dialogue panel in passive mode** (read-only, no response options). An **occlusion filter** degrades displayed text based on distance, ambient noise, and player effort (ListeningFocus stance). Monologue remains a separate channel — it reacts to overheard content via the `witness_interaction` trigger but does not carry conversation text itself.
- **Passive panel behavior:**
- Same D-061 dialogue box, but read-only — no response options rendered.
- Shows speaker name and intended target (e.g. "Kael → Mira").
- Visually distinguished from interactive dialogue (dimmed border or header treatment — exact style TBD by Araminta).
- Walking out of earshot dismisses the panel naturally.
- **Occlusion filter — server-authoritative:**
- Occlusion is computed **on the server**. The server rolls per-word visibility based on distance, ambient noise, and ListeningFocus stance, then sends pre-occluded text to the client with `...` replacing dropped words. The client renders exactly what it receives — no client-side probability logic.
- Per-word visibility is a **probability**, not a deterministic cutoff. Each word is independently rolled — same distance, same conversation can yield different fragments across playthroughs.
- **Close / clear:** High probability per word — most or all text arrives intact, speaker names shown.
- **Moderate distance / some noise:** Medium probability — words arrive as `...`, speaker names visible. The player may or may not catch the crucial word.
- **Far / noisy zone:** Low probability — speaker indicators only, few or no readable words. Audio murmur still plays per D-072.
- **ListeningFocus stance:** Shifts probability curve upward — improves word visibility at current distance.
- **Multiplayer-readiness:** Each observer gets their own occluded version computed from their specific distance/noise/stance. Seedable RNG per tick ensures deterministic replay (D-010).
- **Design intent:** Stochastic word-drop means the player can never be certain they got the full picture from eavesdropping alone. This pushes them toward direct conversation to confirm — where they must reveal something about what they know. The information asymmetry loop closes on itself.
- **Dual-channel principle:** Dialogue panel = what you hear. Monologue = what you think about it. The `witness_interaction` monologue trigger fires after overheard content is displayed, producing the character's internal reaction ("She's lying to him", "So they do know about the cargo"). These are separate UI elements with separate content pipelines.
- **Content pipeline:** Copy team authors NPC-to-NPC dialogue lines once at full fidelity. The server applies occlusion degradation before sending to the client. No need to author multiple fidelity versions.
- **Rationale:** Keeps monologue as a clean internal response system — thoughts only, never quoted speech. The dialogue panel already exists for player-NPC interaction; reusing it in passive mode for overheard conversation avoids a new UI element. The occlusion filter makes proximity and attention mechanically meaningful without gating content behind binary thresholds.
- **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), eavesdropping ([D-071](#d-071-no-ambient-dip-for-eavesdropping--listeningfocus-boost)), conversation murmur ([D-072](#d-072-universal-event-driven-conversation-murmur)), monologue system ([D-016](#d-016-internal-monologue-as-core-perceptionatmosphere-system)), knowledge confidence ([D-041](architecture.md#d-041-knowledge-graph-data-model))
- **Raised by:** Lead (passive panel directive, occlusion filter, monologue separation)
- **Dissent:** None
---
*29 decisions. Last updated: 2026-02-19 (OQ-07 resolved: D-056/D-057 amendment)*
*32 decisions. Last updated: 2026-02-20 (D-061: amended with unified conversation log architecture from Sprint 14 #535)*
+12 -1
View File
@@ -15,6 +15,7 @@ Mixed methods depending on duration and character:
|----|----------|--------|-----|--------|----------|-------------|--------|
| SFX-001 | `sfx_footstep_metal_walk.ogg` | done | Player Actions | SAO hybrid | 0.3s | Single footstep on metal grating. Walk pace. SAO 5s/100 steps/CFG 8, best transient trimmed at 2.42s, LUFS -16 normalized. | S10 #327 |
| SFX-002 | `sfx_footstep_metal_run.ogg` | done | Player Actions | SAO hybrid | 0.3s | Faster footstep, sprint pace. Louder — player is broadcasting position (D-053 acoustic footprint). SAO 5s/100 steps/CFG 8, best transient trimmed at 1.03s, LUFS -16 normalized. | S10 #327 |
| SFX-003 | `sfx_npc_murmur.ogg` | done | World SFX | SAO | 5s | NPC proximity conversation murmur. Human voices in adjacent room, direction and cadence distinguishable, words not. Organic family. SAO 5s/100 steps/CFG 8, LUFS -16 normalized. Selected from 3 candidates (richest texture). | S13 #532 |
## Detailed Entries
@@ -25,6 +26,17 @@ Mixed methods depending on duration and character:
- **Integration:** Triggered by player movement tick. Positional (AudioStreamPlayer2D at player position) — maps to camera-relative stereo per D-018.
- **Cross-reference:** D-038 (#5), D-053 (movement stances as acoustic decisions)
### SFX-003: sfx_npc_murmur
- **Sonic family:** organic (physical world — human warmth, natural decay)
- **Bus rationale:** World SFX — this is information about other people in the world, not player feedback. Positional (AudioStreamPlayer2D at NPC cluster position).
- **Design intent (D-072):** Universal asset — single murmur plays at all NPC proximity events. Zone ambient determines conspicuousness naturally: bar's dense ambient buries it, corridor's sparse ambient exposes it. No zone-specific variants needed.
- **Aesthetic (D-074):** Organic family. Warm, lived-in, no lattice processing. Mid-freq 200-800Hz texture per station palette "functional warmth" (D-074 conversation murmur band).
- **Propagation (D-018):** Close range: intelligible as "voices, nearby." Medium range: muffled. Long range: lost in ambient floor.
- **Eavesdrop interaction (D-071):** Player ListeningFocus boost increases effective reception range. Same asset, confidence varies with proximity and skill.
- **Generation:** SAO 5s / 100 steps / CFG 8. Prompt: "Warm organic audio, natural sound recording, slight room ambience, lived-in industrial space, realistic acoustic properties, authentic environmental sound. Single isolated sound effect, clean recording, 5 seconds duration, clear attack and natural decay, no background noise, no reverb tail beyond natural. Human voices murmuring through a thin wall or partition, two or three people in low conversation in the adjacent room, words muffled and indistinct, direction and gentle cadence audible, warm mid-frequency vocal texture 200-800Hz, soft intimate conversation murmur, no music, no intelligible words." 3 candidates generated (5s/CFG8, 6s/CFG7, 4s/CFG9). Candidate 1 selected (richest OGG texture, clearest spatial framing). Post-processed: trim, LUFS -16, OGG.
- **Integration:** Event-driven — fires when player enters NPC cluster proximity range. Not looped continuously; retriggered on proximity events. Loop-capable (5s has natural loop structure) if client opts to sustain during eavesdrop.
- **Cross-reference:** D-072 (universal murmur decision), D-071 (ListeningFocus eavesdrop), D-074 (organic aesthetic), D-018 (sound propagation model)
## Future Expansion
SFX will grow significantly with:
@@ -32,7 +44,6 @@ SFX will grow significantly with:
- Door open/close, machinery activation → World SFX bus
- Combat impacts, weapon sounds → Player Actions bus
- Environmental events per planet/biome
- NPC conversation murmur (event-driven, point-source) → World SFX bus
When this file exceeds ~50 entries, split by domain:
- `sfx-movement.md` (footsteps, stance variants)
Binary file not shown.
+573
View File
@@ -0,0 +1,573 @@
# Access Tier Shift Design Document
**Ticket:** #328 | **Feeds:** #169 (Layer 1 access tier filtering, S15)
**Authors:** Paula (narrative design), Mellanie (line content, to be co-authored)
**Date:** 2026-02-20
**Status:** Draft
**Decision cross-references:** D-028 (dialogue architecture — tagged line pools, four relational layers), D-033 (entity color = relationship to player), D-035 (converged tag taxonomy), D-062 (invisible locked dialogue options), D-063 (confrontation mechanics), D-064 (walk-away consequences), D-075 (dialogue filtering — layered confidence gate)
**Fact ID source:** `docs/design/knowledge-vocabulary-v01.md` (ticket #368)
---
## What This Document Is
This document specifies when and how a player character's access tier changes with a given social site or NPC cluster during v0.1 play. Access tiers (D-028 Layer 1) are hard filters on dialogue line eligibility — they determine *which categories of conversation* the player can have, not which specific lines they get.
**Access tier map:**
| Tier | Who Has It | Relationship State | Social Position |
|------|-----------|-------------------|----------------|
| `public` | Anyone | Unknown or any | Stranger or authority |
| `peer` | Known or Friendly | Known · Friendly | Recognized colleague, social equal |
| `insider` | Friendly only | Friendly | Trusted member, community in-group |
| `authority` | Detective only | Unknown through PersonOfInterest | Institutional leverage operative |
| `hostile` | Post-relationship-break | Hostile | Enemy, threat, someone actively managing you out |
**Key design axiom from D-062:** Players don't know what they're missing. When access tier shifts downward, locked-out content disappears silently. No "you've lost Kael's trust" notification. The absence *is* the signal. Monologue is the only permitted narrator of shift events.
---
## How Access Tier Shifts Work (Engine Model)
Access tier is derived from `RelationshipState` (per D-033) — the engine looks up the player's relationship state with the target NPC at dialogue selection time and applies the tier filter automatically. Content authors do not tag shift events; they author the content that exists on both sides of the transition.
**RelationshipState → AccessTier mapping:**
| RelationshipState | Available Tiers |
|-------------------|----------------|
| `Unknown` | `public`; `authority` (detective) |
| `Known` | `public` · `peer`; `authority` (detective) |
| `Friendly` | `public` · `peer` · `insider` |
| `PersonOfInterest` | `public`; `authority` (detective); `hostile` (if escalated) |
| `Hostile` | `hostile` only |
**What triggers a RelationshipState change** is simulation-side: NPC reactions to player behavior, witnessed events, conversation outcomes, KG fact accumulation. This document specifies the *content conditions* — the game state conditions that must be true for a transition to be meaningful and authoritatively triggered. These map to FactIds and entity attribute checks in the prerequisite system (D-035).
---
## Social Site 1: The Terminal (Logistics Hub)
### Smuggler at The Terminal
**Starting state:** `insider` + `peer` with ring members (Kael, Nils, Voss, Renn); `peer` with non-ring colleagues (Maret, Drin, Harek); `public` with strangers
The Terminal is the smuggler's home ground. They've worked here for two years. The warm `insider` access with ring colleagues is the baseline — ring operational talk, corridor scheduling, quiet coordination between shift tasks. The smuggler doesn't know what it feels like to be `public` here. They will, if things go wrong.
---
#### Transition 1-S-T: `insider → hostile` (ring turns on smuggler; cover blown)
**Direction:** Downward. The most severe possible transition.
**Narrative context:** The ring has concluded the smuggler is a liability — either actively cooperating with the Commission investigation, or so visibly compromised that they're a risk to operations. Once this threshold is crossed, ring members stop sharing operational information and begin actively managing the smuggler's exposure: giving false scheduling, monitoring their movements, possibly preparing to eject them from the ring entirely.
**Observable event that triggers transition:**
One of the following event chains completes:
1. Smuggler is observed in direct sustained conversation with the detective in a non-public context (e.g., maintenance corridor, isolated terminal bay) by a ring operative (Voss, Nils, or Renn)
2. Smuggler's cover story fails under questioning by Nils — Nils determines the smuggler knew about the detective's investigation and didn't report it
3. Ring discovers the smuggler has been observed by Commission surveillance (Sera Venn's unofficial compliance scan results surface)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.detective_presence` | `knows_of` | Ring knows a Commission detective is on station |
| `awareness.detective_investigating_ring` | `suspects` | Ring suspects (or knows) the investigation targets them specifically |
| `awareness.ring_route_compromise` | `suspects` | The current route is perceived as compromised |
| `investigation.ring_existence` | `knows_of` | (Detective's version; smuggler doesn't need this, but the simulation uses it to assess ring panic level) |
**Entity state required:**
- Ring operative (Nils or Voss) RelationshipState to smuggler: shifts from `Friendly``PersonOfInterest``Hostile`
- This chain typically passes through `PersonOfInterest` first (suspicion phase) before completing to `Hostile`
**Content requirement:**
The dialogue pool must include `hostile`-tier lines that feel like community closure, not just interpersonal conflict. Voss gives clipped operational answers. Nils stops acknowledging the smuggler in shared spaces. Renn averts eye contact. These are `hostile`-access lines tagged `situation: [shift_encounter, workplace]` that communicate ostracism through normalcy-performance rather than explicit threat.
**Monologue requirement:**
Smuggler monologue at this transition must do what D-063's pre-delivery beat does for confrontation: surface the weight before the player feels it mechanically. Suggested trigger: `observe_npc` when the smuggler sees a ring member at The Terminal post-transition.
```
trigger: observe_npc
character: smuggler
prerequisite:
relationship:
target: npc:voss # or nils / renn
state: hostile
text: "Voss walked past me without nodding. Two years on the same shift. He just... walked past."
mood: [shocked]
```
**Reversibility:** Near-irreversible within v0.1. The ring operates on operational security logic, not personal forgiveness. To reverse this, the smuggler would need to demonstrate the detective has been misled or has left the station — a scenario beyond the v0.1 scope. Flag as permanent-for-sprint for content authoring purposes.
---
#### Transition 2-S-T: `insider → peer` (ring caution mode; Kael situation escalates ring tension)
**Direction:** Partial downward. `insider` access to ring-specific operational dialogue narrows; `peer` access to social/colleague dialogue remains.
**Narrative context:** This is not a trust collapse — it's a trust contraction. As the ring senses internal pressure (Kael's unauthorized contacts, ring tension escalation), Voss and Nils begin compartmentalizing operational information. The smuggler still belongs, still gets treated like a colleague, but the operational coordination talk goes quiet. Ring members are self-protective, not hostile. They're not excluding the smuggler — they're excluding *everyone* from the sensitive operational layer while they assess.
**Observable event that triggers transition:**
- Nils or Voss observes or suspects Kael's unauthorized meeting (the same event the smuggler may discover independently in THE FRIEND arc — different observer, same event)
- Ring tension escalation manifests as shortened coordination exchanges at The Terminal
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.ring_tension_escalation` | `suspects` | Ring senses internal stress; caution spreading |
| `investigation.kael_corridor_meeting` | `suspects` | Kael seen with unknown contact (ring operative observed this, not necessarily the player) |
| `investigation.kael_unknown_contact` | `suspects` | The contact is unrecognized — potential exposure |
**Entity state required:**
- Ring operative RelationshipState to smuggler: remains `Friendly` (no state change), but `insider`-tagged dialogue pool for ring operations is gated by a ring-internal caution flag
- This is a content-authored partial exclusion, not a RelationshipState change — implemented by adding a runtime flag to the selection system that suppresses `insider, trust: real` ring-coordination lines even for Friendly NPCs
> **Implementation note for #169:** This transition requires a ring-internal state variable (`ring_on_caution`) that the content team will need to reference in prerequisite format. Proposed fact: `awareness.ring_tension_escalation` at `knows_of` triggers this suppression server-side. Content authors should tag ring-operational insider lines with `tags: [ring-coordination]` and the server system can suppress that tag cluster when the caution flag is active.
**Content requirement:**
Two parallel dialogue pools needed: ring-coordination pool (suppressed when `ring_on_caution` active) and social-colleague pool (always available while Friendly). The player notices that Voss stops mentioning shift windows. Kael stops asking about container routing. The conversation continues, but operational content disappears.
**Reversibility:** Yes. If ring tension resolves (e.g., Kael's situation normalizes, the threat passes), operational talk resumes. Reversible via `awareness.ring_tension_escalation` falling below `suspects` threshold — which in practice means the ring stops tracking the anomaly.
---
#### Transition 3-S-T: `peer → peer` to `hostile` (non-ring colleagues react to cover exposure)
**Direction:** Downward, distinct pathway from Transition 1-S-T.
**Narrative context:** Maret Korr, Harek, and legitimate dock workers don't know about the ring. Their hostility toward the smuggler comes not from ring logic but from community logic: they discover (via detective's investigation becoming public, or visible confrontation at The Terminal) that someone they trusted was running contraband operations through their workspace. Maret, who's been a scheduling colleague for two years, feels used.
**Observable event that triggers transition:**
- Detective's investigation becomes visible enough at The Terminal that legitimate workers understand what's been happening
- Required: detective has interrogated Maret or Drin visibly (witnessed by other dock workers)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.ring_existence` | `knows_of` | Legitimate workers now know a ring was operating |
| `investigation.manifest_discrepancy` | `knows_of` | The discrepancies are understood as intentional, not clerical |
**Reversibility:** Moderate difficulty. Unlike ring-member hostility (operational logic), this hostility is personal-moral. If the smuggler demonstrates they weren't a core operator (partial truth), some colleagues might return to neutral `public` tier.
---
### Detective at The Terminal
**Starting state:** `authority` with Voss, Maret, Drin; `public` with most hub workers; no `insider` access (investigator is always an outsider to the ring's in-group)
The Terminal is adversarial ground for the detective. Authority access gives institutional leverage — the detective can ask hard questions, invoke Commission standing, access records — but it creates social friction. Every exercise of authority makes the `peer` path harder.
---
#### Transition 1-D-T: `authority → peer` (collaboration event; institutional authority softens to personal rapport)
**Direction:** Sideways (not strictly up or down — `authority` and `peer` provide different content, not hierarchically ranked).
**Narrative context:** This transition is the detective learning to work *with* the community rather than on top of it. A specific collaboration event — helping Maret with a non-smuggling problem, or letting a minor infraction go — signals to a hub worker that the detective is operating in good faith. The hub worker begins treating the detective as a person, not a badge. This unlocks `peer`-tier content from that NPC: honest opinions, unguarded speech, actual feelings. These are the lines that contain the most useful investigative texture.
**Observable event that triggers transition:**
- Detective witnesses or intercepts a minor infraction unrelated to the ring investigation (dock worker running personal cargo through, Harek's unofficial equipment borrowing) and explicitly does not file it
- OR: Detective helps Maret resolve a scheduling conflict caused by simulation-generated NPC behavior (non-ring-related problem), giving Maret reason to feel reciprocal goodwill
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.ring_existence` | `suspects` | Detective is on station for a reason; Maret knows this |
| `social.bar_regular_status` | `suspects` | Detective is becoming a known presence on Sova (not bar-specific, but signals integration) |
**Entity state required:**
- Target NPC (Maret, most likely): RelationshipState transitions `Known``Friendly`
- Once `Friendly`, `peer`-tier dialogue becomes available; `authority`-tier remains available simultaneously (detective can shift registers within a conversation)
**Content requirement:**
`peer`-tier Maret dialogue should feel warmer, more candid, and more useful than `authority`-tier Maret dialogue — but for different reasons. `authority` lines give formal, procedurally correct answers. `peer` lines give informal reads: "Voss has been different lately. Can't say why. Just tighter." That line is `trust: real`, `access: [peer]`.
**Reversibility:** Yes, but fragile. If detective subsequently uses institutional leverage against Maret (authority interrogation, official filing of any minor infraction), RelationshipState may revert to `Known` and `peer` access narrows back to `authority`.
---
#### Transition 2-D-T: `authority → hostile` (confrontation fails; community closes ranks)
**Direction:** Downward. The most damaging investigative outcome — hub workers who were cooperating under authority pressure now actively stonewall.
**Narrative context:** If the detective uses institutional leverage badly — pressing too hard in a way that's visible to other workers, making an accusation that doesn't stick, or invoking Commission authority in a situation where the community reads it as overreach — the hub shifts from grudging cooperation to collective non-cooperation. This isn't ring coordination; it's community immune response. Dock workers closing ranks around their own.
**Observable event that triggers transition:**
- Detective conducts a visible confrontation (D-063) with Voss or Maret in a public area of The Terminal, the confrontation fails (NPC successfully deflects, detective doesn't have sufficient evidence), and other workers witness the exchange
- OR: Detective formally reports a minor infraction that hub workers considered a normal part of life — the report reads as persecution, not investigation
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.oversight_gap_pattern` | `suspects` | Detective has been probing, not finding clean answers |
| `awareness.commission_audit_scheduled` | `knows_of` | Hub workers know a formal audit is coming; fear is already elevated |
**Key design tension — confrontation + walkaway (D-063/D-064):**
A confrontation that the detective walks away from (WASD during exchange) doesn't just end the confrontation — the KG records incompleteness. If the detective starts a confrontation with Voss and then leaves mid-exchange, Voss's `contradiction_flagged` attribute gets set, and the ring interprets this as: the detective has partial evidence and couldn't follow through. This triggers ring defensiveness *faster* than a completed confrontation. Content authors should note: `investigation.shift_mismatch` at `knows_of` + an incomplete confrontation event = ring caution acceleration.
**Content requirement:**
Post-hostile Terminal NPCs need `hostile`-tier dialogue that sounds like bureaucratic compliance, not aggression. "Shifts are logged in the system." "You'll want to check with oversight." "I don't have anything more for you." These are lines that technically cooperate while providing nothing — the institutional version of closed doors.
**Reversibility:** Moderate difficulty. The detective would need to either produce evidence that justifies the confrontation retrospectively (making the community feel the investigation was warranted) or wait long enough for the community's defensive posture to relax. Within v0.1 timeline: effectively permanent once triggered.
---
## Social Site 2: The Last Shift (Bar)
### Smuggler at The Last Shift
**Starting state:** The smuggler's starting tier at The Last Shift depends on their history with the bar. If played as a regular (the intended interpretation), they begin at `peer` with most bar regulars and `insider` with Lera (ring-aware, bar owner who runs quiet coordination). With strangers or recent arrivals (Sera Venn, Commission-adjacent individuals), they begin at `public`.
The bar is a social pressure valve. It's where the district's people become people. The smuggler is comfortable here — but comfort makes the downward transition more disorienting.
---
#### Transition 1-S-B: `public → insider` (Kael introduction; bar community adopts smuggler)
**Direction:** Upward. Unusual in the design — most documented transitions are downward. This one represents the smuggler extending their community standing into the bar's inner circle.
**Narrative context:** The smuggler knows Lera and the ring-aware bar regulars already. But Kael's social circle at the bar — Naia, her friends, some bar regulars who are ring-adjacent but not ring members — is a separate network. Kael making a formal introduction signals to this circle that the smuggler belongs. This unlocks insider-tier content from Naia and her social cluster: actual feelings, honest opinions, personal context. Naia's worry about Kael surfaces here.
This transition is the mechanism by which the smuggler gains access to the warning signs of Kael's situation before the corridor B-7 contradiction. Naia is visibly anxious. Her concern is `insider`-tier content — she wouldn't share it with a stranger.
**Observable event that triggers transition:**
- Kael and the smuggler are both present at The Last Shift; Kael initiates introduction exchange ("She's on my shift, been here as long as I have") with Naia or her immediate social circle
- This is a simulation-generated interaction — Kael's routine includes this social gesture if his relationship with the smuggler is `Friendly` and his relationship with Naia is `Friendly`
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `social.bar_regular_status` | `suspects` | Smuggler is becoming a regular; the introduction lands as socially coherent |
| `relationship.kael_naia_connection` | `knows_of` | Smuggler knows Kael and Naia are connected; the introduction isn't out of nowhere |
**Entity state required:**
- Kael RelationshipState to smuggler: `Friendly`
- Naia RelationshipState to smuggler: `Unknown``Known` (minimum for transition to trigger)
- Full `insider` access to Naia unlocks at `Known``Friendly` (second threshold, requires sustained positive interaction)
**Content requirement:**
Naia's `insider`-tier content at The Last Shift should surface her anxiety about Kael in a way that doesn't explain it — it creates the question without the answer. "He's been working late. I don't... he gets like this sometimes." Tagged `trust: real`, `access: [insider]`. This is the content the smuggler can't get as a stranger, and it's the warning they carry into the corridor B-7 observation.
**Reversibility:** Yes. If the smuggler stops visiting the bar, Naia's RelationshipState drifts back to `Known` over time. The `insider` access requires sustained interaction.
---
#### Transition 2-S-B: `insider → hostile` (social contamination from ring crisis)
**Direction:** Downward. Social contamination spreads from ring context to bar context.
**Narrative context:** The bar is where ring community and legitimate community overlap. When the ring turns on the smuggler, the contamination spreads through this overlap. Lera stops holding the smuggler's usual seat. Torek stops buying rounds. Bar regulars who are ring-adjacent start looking uncomfortable when the smuggler arrives. This isn't organized — it's social contagion. Lera doesn't want trouble at her bar. The regulars follow her lead.
**Observable event that triggers transition:**
- Ring has already moved to `hostile` tier with the smuggler at The Terminal (Transition 1-S-T has completed)
- A ring member (Renn or Torek) is present at the bar when the smuggler arrives and visibly changes behavior — leaves, signals to Lera, or gives the smuggler a warning look
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.ring_tension_escalation` | `knows_of` | Bar regulars who are ring-adjacent have sensed something's wrong |
| `awareness.ring_route_compromise` | `suspects` | The ring is protecting its operational footprint |
**Entity state required:**
- At least one ring-adjacent bar regular (Torek or Renn): RelationshipState to smuggler has moved to `PersonOfInterest` or `Hostile`
- Lera: RelationshipState shifts from `Friendly``Known` (bar owner withdraws warmth without active hostility; bar is still `public` accessible, but the home feeling is gone)
**Secondary contamination — Naia:**
If Naia has learned (through Kael or indirect observation) that the smuggler is suspected by the ring, her RelationshipState also shifts. Naia's hostility is different from the ring's — it's protective fear, not operational defense. She doesn't want to lose Kael to whatever the smuggler has gotten caught up in. Content for Naia at this stage should feel like hurt and worry compressed into withdrawal, not anger.
**Content requirement:**
The `hostile`-tier bar content is some of the most important emotional writing in the game. The bar was comfort. The bar is now hostile territory with familiar faces. Lera's lines should feel like professional neutral — no warmth, no cold, just a bartender doing her job. Torek's lines (if present) should feel like he's trying to warn the smuggler while staying deniable. This requires careful access tagging: Torek's warning is `access: [peer]`, `trust: real` — he'll only deliver it if the player is still `Known` with him, not yet `Hostile`.
**Reversibility:** Hard. Requires terminal-level crisis to resolve first. The bar follows the ring's social signal.
---
#### Transition 3-S-B: `insider → hostile` (contraband conversation overheard by hostile observer)
**Direction:** Downward, distinct from Transition 2-S-B. This is an in-bar event, not contamination from outside.
**Narrative context:** The bar creates a specific risk: ring-relevant conversation in a space with poor sound isolation. D-078 established that NPC-to-NPC conversations are Voice events the player can overhear — the same applies in reverse. If the smuggler and a ring member have an `insider`-tier conversation at The Last Shift within earshot of an unfriendly observer (the detective, a Commission-tagged NPC, or a bar regular who's been marked as informant), the result is catastrophic.
**Observable event that triggers transition:**
- Smuggler and ring member (Kael, Voss, or Renn) are in `insider`-tier dialogue at The Last Shift
- A third party with `PersonOfInterest` or `Hostile` relationship to the ring is within Voice range (D-018 three-range sound model)
- The overheard content triggers a KG fact update for the observer
**Required KG facts at transition point (observer's perspective):**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.ring_existence` | `suspects` | Observer already suspected a ring; the conversation confirms it |
| `contraband.ring_lattice_components` | `suspects` | Observer now understands what's being moved |
**The asymmetry:** From D-018's three-range model, the player controls whether they're within Voice range of overheard conversations — but NPCs don't know they're being overheard. If the detective is nearby and the player (as smuggler) initiates ring-coordination dialogue, this is a player error with consequence. The simulation shouldn't make this easy to do accidentally, but it should make the consequence clear and immediate.
**Reversibility:** None. Fact knowledge is retained by the observer. Once the detective has `contraband.ring_lattice_components` at `knows_of`, that confidence doesn't decay to zero.
---
### Detective at The Last Shift
**Starting state:** `public` with bar regulars; `peer` with Sera Venn (pre-existing contact, established friendship); `authority` over Torek (ring-adjacent, financial tells create leverage potential)
The bar is the detective's only social toehold. Sera is the social anchor. Every other transition at the bar depends on either sustaining Sera's goodwill or making it on personal terms.
---
#### Transition 1-D-B: `public → peer` via Sera Venn (social introduction; district integration)
**Direction:** Upward.
**Narrative context:** Sera is the detective's guide to the bar's social geography. She knows the regulars, knows the history, knows who's approachable and who isn't. When she introduces the detective to bar regulars — "We work the same beat, sort of" — she's vouching for the detective as a person, not a badge. This shifts the detective from institutional stranger to known face. `peer`-tier bar content (gossip, complaints, personal context) unlocks.
This is the primary path to bar integration. The buy-rounds path (Transition 2-D-B) is secondary and slower.
**Observable event that triggers transition:**
- Detective has visited The Last Shift 2+ times with Sera present
- Sera is in `Friendly` RelationshipState with detective
- On the third or later visit, Sera initiates an introduction event with a bar regular (Lera, most likely, or a mundane-triangle bar regular)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `social.bar_regular_status` | `suspects` | Detective is becoming a recognized face |
| `behavioral.sera_kiosk_pattern` | `knows_of` | Detective knows Sera's habits — signals the relationship has texture |
**Entity state required:**
- Sera RelationshipState to detective: `Friendly`
- Target bar regular RelationshipState to detective: `Unknown``Known` (introduction creates Known; Friendly requires follow-up interaction)
**Sera dependency risk:** If Sera moves to `PersonOfInterest` (Phase 3 of THE FRIEND arc), this introduction path is compromised. Sera still may be physically present, but her social vouching becomes ambiguous — she might introduce the detective, but bar regulars who are sensitive to institutional dynamics may read the introduction differently. This creates a content authoring requirement: bar regular reactions to Sera-mediated introductions should have two variants — pre-contradiction and post-contradiction.
**Content requirement:**
`peer`-tier bar content for the detective should feel like the community briefly letting the badge off the hook. Lera's peer lines are informal and dry: "You're still here." "Thought Commission people didn't do grain spirit." This is trust-as-tolerance, which is honest to the bar's relationship with institutions.
**Reversibility:** Yes, but depends entirely on Sera's RelationshipState. If Sera's contradiction is confronted badly and she moves to hostile tier, the detective loses the introduction pipeline and must earn bar integration directly.
---
#### Transition 2-D-B: `authority → peer` after buying rounds (social gesture; institutionality dropped)
**Direction:** Sideways, same dynamic as Transition 1-D-T but in the bar context.
**Narrative context:** The detective has authority access to Torek (ring-adjacent, financial tells create leverage). But authority access at a bar is uncomfortable — for everyone, including the detective. At some point the detective can choose a different register: stop invoking institutional leverage and just buy a round. This social gesture — explicitly off-the-record, no questions, just drinks — shifts a specific NPC from authority-tier to peer-tier for that interaction and potentially permanently if sustained.
This is the slower path to bar integration — not mediated by Sera, but earned directly through social investment.
**Observable event that triggers transition:**
- Detective is at The Last Shift with Torek present
- Detective initiates a non-investigative social exchange (no use of `authority`-tagged dialogue options for 2+ consecutive conversations)
- Buys drinks as a social action (if implemented as an interaction verb in the v0.1 interaction set)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `social.bar_regular_status` | `knows_of` | Detective is now recognized as a regular, not a visitor |
**Entity state required:**
- Torek RelationshipState to detective: `Known``Friendly`
- Once `Friendly`, `peer`-tier content unlocks; `authority`-tier remains available but the player must actively choose it
**The buy-rounds mechanism:** The interaction system (D-interaction-verbs) needs a "buy drinks" verb that registers as a positive social action toward present NPCs without triggering dialogue. This is a goodwill investment that accumulates toward RelationshipState advancement. For #169 (S15 implementation): this should be a flagged interaction event that the simulation picks up and applies as relationship credit to all `Known`+ NPCs in proximity.
**Torek content note:** Torek is ring-adjacent and has financial tells (`investigation.torek_spending_pattern`). Peer-tier Torek content is valuable for the investigation — he's more likely to let something slip in casual conversation than under authority questioning. The buy-rounds path is a legitimate investigative technique, not just social filler. Torek's `peer, trust: real` lines should carry genuine investigative texture.
**Reversibility:** Yes. If the detective subsequently invokes authority leverage against Torek (raises `investigation.torek_spending_pattern` as direct confrontation material), Torek's RelationshipState reverts. The trust investment is not permanent.
---
#### Transition 3-D-B: `peer → authority` (Sera arc activates; trust contaminated)
**Direction:** Sideways. The detective's relationship with Sera shifts from warm peer-tier to analytically-charged authority-tier as THE FRIEND arc progresses.
**Narrative context:** Per D-063 and the FRIEND arc specification, when Sera moves to `PersonOfInterest` (Phase 3 of the detective's FRIEND arc), the detective gains access to `authority`-tier dialogue with Sera — institutional leverage questions, direct evidence-seeking. This is distinct from standard authority access (the detective doesn't arrest Sera, doesn't invoke formal Commission processes). The authority content here is personal authority: "I know you know something. I'm asking you." The formality is in the weight of the question, not the institutional mechanism.
**Observable event that triggers transition:**
- Detective observes Sera leave when Torek arrives for the third time (or any two confirmed avoidance events matching the same pattern)
- `behavioral.sera_avoidance_pattern` reaches `knows_of`
- Monologue triggers Phase 3 recognition: "That's the third time."
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `behavioral.sera_avoidance_pattern` | `knows_of` | Pattern is confirmed, not suspected |
| `behavioral.sera_topic_deflection` | `suspects` | Sera has deflected at least one sensitive topic |
| `investigation.sera_evidence_held` | `suspects` | Detective senses Sera knows something she hasn't shared |
**Entity state required:**
- Sera RelationshipState to detective: `Friendly``PersonOfInterest`
- `peer`-tier Sera content remains available (it doesn't vanish), but `authority`-tier content unlocks alongside it
- The player can choose which register to use — and the choice has consequences (D-064 walk-away logic applies: if detective initiates authority-tier inquiry and then drops it, the KG logs the incompleteness)
**Content requirement:**
The `authority`-tier Sera dialogue should feel qualitatively different from authority-tier dialogue with Voss or Maret. Voss authority lines are evasive, procedural. Sera authority lines are emotionally charged — she knows the detective knows, and she's working to contain that. Her `authority, trust: surface` lines are careful, calibrated. Her `authority, trust: real` lines (if unlocked) are the closest she gets to disclosure: "Some things aren't mine to report. You know how it is." This is the line that changes everything.
**Reversibility:** Conditional. If the detective chooses not to press (avoids authority-tier options with Sera across 2+ subsequent interactions), Sera's RelationshipState may re-stabilize at `Friendly`. But `investigation.sera_evidence_held` remains in the KG at `suspects` — the detective knows something is wrong, even if the relationship surface normalizes.
---
## Social Site 3: Maintenance Corridors (Smuggling Spaces)
### Smuggler at Maintenance Corridors
**Starting state:** `insider` with all ring members (Kael, Nils, Renn); this is the ring's operational space. The smuggler knows the layout, the camera gaps, the timing windows.
---
#### Transition 1-S-M: `insider → hostile` (corridor operations suspended; smuggler shut out)
**Direction:** Downward.
**Narrative context:** If ring crisis reaches operational shutdown level (investigation too hot, Kael situation destabilizing the ring's trust structure), Nils may suspend corridor operations entirely or change the access protocol. The smuggler's knowledge of the old layout becomes liability — they know where the blind spots are, but those blind spots may have been deliberately exposed to flush out threats. The smuggler navigating the corridors under these conditions is walking into a trap.
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.ring_tension_escalation` | `knows_of` | Operations are suspended or restructuring |
| `awareness.surveillance_change` | `suspects` | The surveillance pattern has changed |
| `investigation.kael_ring_membership` | `knows_details` | Kael's exit attempt is now broadly known inside the ring |
**Reversibility:** No. If the ring has suspended the smuggler's access, that's a terminal state for their ring membership in v0.1.
---
#### Transition 2-S-M: `insider → isolated` (Kael not present; smuggler has operational access but no coordination)
**Direction:** Partial downward. Technical access to the corridors remains, but social coordination disappears.
**Narrative context:** If Kael is the smuggler's primary coordination contact in the corridors and Kael has shifted to `PersonOfInterest` (Phase 3-4 of THE FRIEND arc), the smuggler navigates the corridors without their usual partner. They have access. They don't have backup. Monologue in this state should reflect operational vulnerability without dialogue partner.
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.kael_corridor_meeting` | `knows_of` | Smuggler has discovered Kael's unauthorized contact |
**Entity state required:**
- Kael RelationshipState to smuggler: `PersonOfInterest`
**Content requirement:**
No `insider` Kael dialogue in corridors post-transition. Instead: monologue lines triggered by locations where Kael would normally have been present. The absence of a colleague, not a confrontation.
**Reversibility:** Yes, if THE FRIEND arc resolves toward reconciliation.
---
### Detective at Maintenance Corridors
**Starting state:** `public`; the corridors are officially restricted (`location.corridor_b7_restricted` is `knows_of`), but the detective has institutional basis to enter with credentials or cause.
---
#### Transition 1-D-M: `public → peer` (legitimate investigative access established)
**Direction:** Upward.
**Narrative context:** The detective earns corridor access not through breaking in but through institutional process — flagging the surveillance gaps, getting Maret's cooperation, or invoking Commission authority to access restricted zones. Once inside legitimately, NPCs who are present (maintenance workers, off-duty dock workers moving through) treat the detective as a known investigator rather than a suspicious intruder.
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `location.corridor_b7_restricted` | `knows_of` | Detective knows the official access structure |
| `investigation.surveillance_gaps` | `knows_of` | Detective has identified the pattern — not just noticed one gap |
| `investigation.oversight_gap_pattern` | `suspects` | Investigation gives the detective standing to be in the corridors |
**Reversibility:** Yes, conditional on investigation standing.
---
#### Transition 2-D-M: `public → hostile` (trespassing observed; ring response)
**Direction:** Downward. The most immediate downward transition in the entire tier system — ring operatives in the corridors are not subtle.
**Narrative context:** If the detective enters the corridors without establishing legitimate access, and a ring operative (Renn is the most likely; Nils if the drop is tonight) observes the intrusion, the ring defensive response is immediate. They don't confront the detective physically in v0.1 — they evacuate, alert, and begin managing their exposure. The detective has triggered ring paranoia, which both accelerates the investigation (the ring is now visible through its defensive behavior) and destroys any chance of observation before the ring goes dark.
**Required KG facts at trigger point:**
| Fact ID | Min Confidence | Role |
|---------|---------------|------|
| `location.maintenance_corridors_access` | `suspects` (detective) | Detective knows approximately where the corridors are |
| `contraband.drop_tonight` | `suspects` (smuggler/ring) | If a drop is scheduled, ring presence in corridors is high |
**The observation paradox:** The most investigatively valuable time to enter the corridors is when a drop is scheduled (`contraband.drop_tonight` at `knows_details` for the smuggler). The most dangerous time is the same. Content for this transition should reflect that the detective's discovery is genuinely useful (they may observe the drop in progress, gain `investigation.smuggling_route` at `knows_of`) even as it triggers hostile ring response.
**Reversibility:** None within v0.1. The ring will not forget an investigator in the corridors.
---
## Reversibility Summary
| Transition | Reversible? | Reversal Condition |
|-----------|------------|-------------------|
| 1-S-T: `insider → hostile` (ring burns smuggler) | No | Ring trust requires full departure from investigation pressure — out of scope v0.1 |
| 2-S-T: `insider → peer` (ring caution mode) | Yes | `awareness.ring_tension_escalation` falls below `suspects`; ring stabilizes |
| 3-S-T: `peer → hostile` (legitimate colleagues react) | Moderate | Smuggler demonstrates non-core role; social repair over time |
| 1-D-T: `authority → peer` (collaboration at Terminal) | Yes, fragile | Detective re-invokes authority leverage; trust reverts |
| 2-D-T: `authority → hostile` (confrontation fails) | Moderate | Retrospective evidence justifies the confrontation; community accepts it |
| 1-S-B: `public → insider` (Kael introduces smuggler at bar) | Yes | Requires sustained bar attendance; decays if absent |
| 2-S-B: `insider → hostile` (social contamination from ring) | Hard | Requires ring crisis to resolve first |
| 3-S-B: `insider → hostile` (overheard contraband talk) | None | Fact knowledge is permanent in detective's KG |
| 1-D-B: `public → peer` via Sera (bar integration) | Yes | Depends on Sera RelationshipState; breaks if Sera moves hostile |
| 2-D-B: `authority → peer` (buy-rounds path) | Yes | Detective reinvokes authority; trust reverts |
| 3-D-B: `peer → authority` (Sera arc activates) | Conditional | Detective avoids pressing; surface normalizes, but KG suspicion persists |
| 1-S-M: `insider → hostile` (corridors suspended) | No | Terminal state for ring membership |
| 2-S-M: `insider → isolated` (Kael absent) | Yes | Kael arc resolves toward reconciliation |
| 1-D-M: `public → peer` (investigative access) | Yes | Investigation standing maintained |
| 2-D-M: `public → hostile` (trespassing) | None | Ring defensive response is permanent |
---
## Content Authoring Implications
### Rule: Every social site needs three pools per character
For each of the three social sites, content authors must produce:
1. **Starting-state pool** — the baseline access tier, representing the first hour of play. Heavy on `public` and `peer` for the detective; heavy on `insider` and `peer` for the smuggler. This is the content that makes the world feel normal.
2. **Shifted-state pool** — dialogue for the 1-2 most likely tier transition per character per site. The hostile-tier ring dialogue. The peer-tier bar content after Sera's introduction. These pools are smaller but carry heavier narrative weight.
3. **Monologue tracking pool** — internal voice lines that run parallel to access tier changes. The smuggler noticing that Voss didn't nod. The detective noticing that bar regulars are warmer. Monologue is the only system that communicates access tier shift to the player (per D-062 — no UI signals, no notifications).
### The hostile-tier authoring challenge
`hostile`-tier dialogue is the hardest to write. The temptation is to write it as confrontational, explicit. But most hostile-tier dialogue in this setting is *bureaucratic hostility* — people performing cooperation while providing nothing. Maret gives the detective technically accurate answers that advance nothing. Voss gives the smuggler shift updates that contain no ring coordination. Lera pours drinks without warmth.
The exception: confrontation-specific lines (D-063). These are the lines written in the character's internal voice, italicized, first-person. *"I saw you in corridor B-7."* These require their own pool: `access: [hostile], trust: real, situation: [confrontation]`.
### The transition-moment authoring challenge
There is a brief, critical window around tier transitions where monologue should surface what the system cannot say. When Kael's `insider` content locks out, the first post-transition observation of Kael should trigger a monologue line that acknowledges the absence without naming the mechanism. The player shouldn't think "I've lost insider access to Kael." They should think "something is different."
Suggested approach: author a `post_conversation` monologue line for each major downward transition that fires once on the first post-transition interaction. These are `priority: 9` lines (high priority, likely to be selected) that serve as narrative transition markers.
---
## Implementation Notes for #169 (S15 Dependency)
This document feeds the Layer 1 access tier filtering implementation (deferred to Sprint 15). The following architectural notes are for Dudley/Tyre when implementing:
1. **The ring-caution suppression** (Transition 2-S-T) requires a runtime flag — not a RelationshipState change, but a content-tag suppression. The proposed mechanism: a `ring_caution_active` boolean in the simulation state that the line selection system checks before returning `insider, tags: [ring-coordination]` lines. FactId gate: `awareness.ring_tension_escalation` at `knows_of`.
2. **The buy-rounds interaction** (Transition 2-D-B) requires a non-dialogue interaction verb that registers social goodwill. The v0.1 interaction verb set (D-interaction-verbs) should include "buy drinks" as a social investment action. Simulation should apply a small positive relationship_state weight to all `Known`+ NPCs in Voice range.
3. **The `authority`-to-`peer` coexistence** (Transitions 1-D-T, 1-D-B, 2-D-B) is an important design point: these are not mutually exclusive tiers. When a detective NPC relationship reaches `Friendly`, both `authority` and `peer` content should be available. The selection system should prefer `peer` in casual contexts and `authority` when the player selects investigative dialogue options. The selection algorithm needs a context signal for this (conversation-type flag, or topic tag check).
4. **The overheard-conversation hostile trigger** (Transition 3-S-B) is the only tier shift triggered by the player's presence in Voice range rather than by direct interaction. This requires the D-018 three-range sound model implementation to cross-reference the player's current relationship states with nearby NPCs. If the player is in Voice range of ring-coordination NPC dialogue and the NPC is in `PersonOfInterest`-or-better relationship with an observer also in range, the observer's KG should be updated.
---
*Document current as of 2026-02-20. All FactIds sourced from `docs/design/knowledge-vocabulary-v01.md` (#368). Implementation dependency: #169 (S15). Content dependency: #120 (line pool files), #121 (voice variation guide).*
+461
View File
@@ -0,0 +1,461 @@
# Character Build: The Detective
**Ticket:** #181 | **Priority:** Critical | **Sprint:** 12
**Status:** v0.1 Draft
**Blocked by:** #179 (Character definition schema)
**Blocks:** #182 (Divergent starting knowledge), #183 (Divergent relationships)
**Decisions referenced:** D-005, D-016, D-027, D-028, D-032, D-034, D-035, D-037, D-041, D-053, D-065, D-075
---
## identity
```yaml
identity:
canonical_id: "pc:detective"
display_name: "[Player-set name]"
archetype: "detective"
occupation: "Commission Liaison Officer, Manifest Irregularity Investigation"
posting_duration: "3 days (newly arrived)"
faction: "Commission"
lattice_tier: "professional"
description: >
Commission Liaison Officer posted to Sova Transit District three days ago.
Official mandate: routine compliance review. Actual mandate: investigate
manifest irregularities suggesting organized freight smuggling. The distinction
between "official" and "actual" is itself the first lesson about how
Sova works.
Trained investigator. Professional analytical lattice. Institutional
authority that opens doors and closes conversations. An outsider in a
community that has learned, over forty years of prefab-modular life,
to handle outsiders with polite minimal disclosure.
Stands to lose: professional credibility (if the case goes cold), Sera
(the investigation, if pressed too hard, could compromise the only
trusted relationship in the district), and the clean institutional
clarity that makes the job feel justified — which the community's
moral complexity is already beginning to erode.
```
---
## starting_knowledge
```yaml
starting_knowledge:
confidence_floor: "KnowsOf"
known_facts:
# Investigation data
- key: "investigation.manifest_discrepancies"
confidence: "KnowsDetails"
source: "institutional"
notes: >
Case file from Commission records. Containers logged but not reconciled
with incoming manifests. Pattern suggests systematic manipulation, not
clerical error. Three clusters of anomalies over the past six months.
- key: "investigation.freight_pattern"
confidence: "KnowsDetails"
source: "institutional"
notes: >
Anomalies cluster around shift transition windows. Same container series
recurring across discrepant manifests. Pattern analysis flagged this
posting as warranting investigation.
# Persons of interest (case file, not personal knowledge)
- key: "person.voss_poi"
confidence: "KnowsOf"
source: "institutional"
notes: >
Voss, shift supervisor. Scheduling authority. In position to create the
oversight gaps that explain the manifest anomalies. Name from case file.
Not met yet.
- key: "person.maret_poi"
confidence: "KnowsOf"
source: "institutional"
notes: >
Maret Korr, freight scheduler. Access to manifest data. Could be source
of the falsification or could be covering for someone else. Name from
case file.
- key: "person.drin_poi"
confidence: "KnowsOf"
source: "institutional"
notes: >
Drin Rosta, dock inspector. Responsible for container verification.
Failure to flag discrepancies suggests either incompetence or complicity.
Name from case file.
# Institutional context
- key: "world.previous_liaison"
confidence: "KnowsDetails"
source: "institutional"
notes: >
The previous Commission liaison rotated out 3 months ago. Rotation
was described as routine. The detective's case file notes that the previous
liaison filed no irregularity reports despite the same pattern being
detectable in their tenure. This warrants investigation.
- key: "world.commission_presence"
confidence: "KnowsDetails"
source: "institutional"
notes: >
Sera Venn, Commission field tech. Not an investigator. Has been on Sova
for 1 year. Pre-existing relationship — same institutional background,
different postings. Reliable. Social anchor in unfamiliar district.
- key: "world.district_geography"
confidence: "KnowsOf"
source: "institutional"
notes: >
The Terminal (logistics hub), The Last Shift (bar, social nexus),
maintenance corridors (off-books access routes noted in district layout
documentation). Three days in — still orienting to physical space.
- key: "world.torek_compliance_connection"
confidence: "KnowsOf"
source: "institutional"
notes: >
Torek Lintar is a Commission compliance officer on Sova. Known from
institutional channels. Not a friend, but a professional known name.
# Sera knowledge
- key: "person.sera_background"
confidence: "KnowsDetails"
source: "direct"
notes: >
Sera Venn: same career path, different posting. Reliable under pressure.
Transferred to Sova after a stressful posting elsewhere. Has been the
district's Commission presence for a year. The detective's one genuinely
friendly face in Sova.
unknown_facts:
- key: "contraband.ring_exists"
notes: >
Does not know a ring exists. Suspects organized smuggling from the manifest
pattern, but "ring" implies structure, membership, coordination — the
detective doesn't know that yet.
- key: "contraband.ring_members"
notes: >
Does not know any ring members by their ring membership. Kael Davan
is not in the case file. The smuggler is not in the case file.
- key: "contraband.cargo_type"
notes: >
Does not know what the cargo is. The manifests show discrepancies;
they don't reveal what's being moved. The "what" is a layer below
the "that something is wrong."
- key: "person.kael_exit_attempt"
notes: >
Completely unknown. Kael's unauthorized contact and exit attempt are
not visible to any institutional data source.
- key: "person.sera_unreported_evidence"
notes: >
CRITICAL unknown. Sera has run an unofficial compliance check on Kael's
work patterns and is sitting on unreported evidence. The detective
trusts Sera completely. This gap is the structural basis for THE FRIEND arc.
- key: "person.naia_confided_in_sera"
notes: >
Naia Tamm's worry about Kael, confided to Sera, is the emotional engine
behind Sera's concealment. The detective doesn't know Naia exists as
anything except a district resident.
- key: "relationship.ring_membership"
notes: >
Who is in the ring, who is complicit, who is protecting whom. The detective
has names (Voss, Maret, Drin) without understanding the social web that
holds them together.
- key: "person.smuggler_identity"
notes: >
Does not know the smuggler is in the ring. If the smuggler is in the case
file at all, it's as a name on a shift roster — no significance attached.
- key: "world.previous_liaison_arranged"
notes: >
The detective wonders whether the previous Commission liaison's rotation
was routine or arranged. This suspicion is in the case file as a note;
it remains unconfirmed. (Canonical answer: arranged. But the detective
doesn't know this yet.)
```
---
## starting_relationships
```yaml
starting_relationships:
- target: "npc:sera-venn"
relationship_state: "Friendly"
access_tier: [peer]
trust_level: "trusted"
relationship_type: "friend"
known_attributes:
name: "Sera Venn"
role: "Commission field technician"
faction: "Commission"
notes: >
Pre-existing friend. Same institutional background, different posting.
Transferred to Sova a year ago. Social anchor. The detective's guide
to an unfamiliar place — Sera fills in background on the district,
introduces the detective to regulars. Completely trusted at session start.
- target: "npc:voss"
relationship_state: "Known"
access_tier: [authority]
trust_level: "uncertain"
relationship_type: "contact"
known_attributes:
name: "Voss"
role: "shift supervisor"
faction: "civilian"
notes: >
Person of interest. Name from case file, scheduling authority. Has
met once for credentials review. Stiffens when the detective enters
the hub. Professional, terse. No warmth. Institutional interaction only.
- target: "npc:maret-korr"
relationship_state: "Known"
access_tier: [authority]
trust_level: "uncertain"
relationship_type: "contact"
known_attributes:
name: "Maret Korr"
role: "freight scheduler"
faction: "civilian"
notes: >
Person of interest. Manifest access. Has cooperated professionally with
data requests — provided clean set of schedules. Slightly nervous.
Cooperation is compliance, not friendliness.
- target: "npc:drin"
relationship_state: "Known"
access_tier: [authority]
trust_level: "uncertain"
relationship_type: "contact"
known_attributes:
name: "Drin Rosta"
role: "dock inspector"
faction: "civilian"
notes: >
Person of interest. Responsible for container verification. Has met
once. More evasive than Voss or Maret. The detective notes the evasion
without yet understanding the cause.
- target: "npc:torek-lintar"
relationship_state: "Known"
access_tier: [public]
trust_level: "uncertain"
relationship_type: "colleague"
known_attributes:
name: "Torek Lintar"
role: "Commission compliance officer"
faction: "Commission"
notes: >
Commission compliance officer on Sova — professional known name, not a
friend. Has seen him at the bar with Sera. Slightly off in a way the
detective can't yet articulate.
- target: "npc:lera-sessik"
relationship_state: "Unknown"
access_tier: [public]
trust_level: "uncertain"
relationship_type: null
known_attributes:
name: "Lera Sessik"
role: "bar owner"
faction: null
notes: >
Sera mentioned The Last Shift. The detective has visited once or twice.
Lera is polite, guarded, watching. Not hostile. Not warm.
- target: "npc:kael-davan"
relationship_state: "Unknown"
access_tier: [public]
trust_level: "uncertain"
relationship_type: null
known_attributes:
name: null
role: "dock worker"
faction: null
notes: >
Unknown at session start. Name might appear on a shift roster without
significance. No personal knowledge. No case file entry. The detective
has no reason to flag Kael — yet.
- target: "npc:naia-tamm"
relationship_state: "Unknown"
access_tier: [public]
trust_level: "uncertain"
relationship_type: null
known_attributes:
name: null
role: null
faction: null
notes: "No connection at session start. Exists as a district resident without individual identity."
- target: "npc:pc-smuggler"
relationship_state: "Unknown"
access_tier: [public]
trust_level: "uncertain"
relationship_type: null
known_attributes:
name: null
role: "dock worker"
faction: null
notes: >
One of many dock workers. No individual significance at session start.
Knowledge accumulates through observation and tell accumulation.
May shift to PersonOfInterest if tells compound and manifest
discrepancy connection is made.
```
---
## access_permissions
```yaml
access_permissions:
can_unlock:
- tier: "public"
condition: "Always available"
max_npcs: "unlimited"
- tier: "authority"
condition: "RelationshipState is Unknown, Known, or PersonOfInterest — credential presentation"
max_npcs: "unlimited for institutional NPCs; does not work with hostile or fully closed NPCs"
- tier: "peer"
condition: "RelationshipState reaches Friendly"
max_npcs: "limited — Sera at session start; others require sustained positive interaction"
structurally_locked:
- tier: "insider"
reason: >
The detective can never access insider-tier ring dialogue. Not because
institutional authority is insufficient — authority and insider are
different axes. Insider requires social belonging within the ring network.
The ring recognizes the detective as Commission. The ring closes, not opens,
when institutional authority is applied. Pressing harder does not unlock
insider content; it triggers defensive behavior and hostile tone. This is
architecturally fundamental to the two-keyhole design.
- tier: "authority (failed)"
reason: >
Authority access fails when RelationshipState is Hostile. Once an NPC
has turned against the detective's investigation, credential presentation
produces refusal or evasion, not compliance. The detective cannot
authority-tier their way through a hostile source.
```
---
## skills
```yaml
skills:
lattice_tier: "professional"
movement_default: "walk" # D-053 MovementProfile
inventory_slots: 2 # D-065
evidence_display: "case_file"
capability_flags:
- "authority_credentials" # Badge/ID; unlocks authority tier with Unknown/Known NPCs
- "manifest_access" # Can formally request freight manifests from institutional NPCs
- "analytical_lattice" # Pattern recognition overlay; flags behavioral anomalies at Careful stance
starting_items:
- item_id: "detective_case_file"
description: "Institutional case file: manifest discrepancy analysis, persons of interest, previous liaison notes."
leverage_type: "proof"
notes: >
The detective's starting information in physical form. Can be referenced
in dialogue to trigger authority-tier responses — "according to case file X."
Contains the manifest discrepancy data that links to Kael's work patterns,
though the detective doesn't know this yet.
- item_id: "detective_commission_credentials"
description: "Commission identification and liaison officer authorization."
leverage_type: "access"
notes: >
Credential presentation triggers authority tier access with Unknown/Known NPCs.
Does not work with ring-network NPCs in insider mode — they recognize
Commission credentials as a threat, not an access grant. Carried on person;
visible as a status marker to any NPC with institutional awareness.
```
---
## presentation
```yaml
presentation:
monologue_pool: "detective"
evidence_display: "case_file"
voice_register:
sentence_length: "complete sentences, occasional fragments for emphasis"
vocabulary: "institutional layer over plain speech — investigative habit without jargon"
emotional_mode: "analysis-first, personal second"
relationship_mode: "surname-first for targets, first-name for trusted"
emotional_baseline:
state: "professional-cautious"
description: >
Trained to observe, analyze, withhold judgment until evidence accumulates.
But also human — isolated in an unfamiliar community, reliant on Sera for
social grounding, aware that non-cooperation is the district default.
The analytical habit runs in the background like an operating system.
Counts things. Tracks frequencies. Flags inconsistencies. Internal bookkeeping
is automatic: "filing it," "noted," "coincidence?" The institutional training
doesn't turn off; sometimes the human overrides it. When it does, the crack
in the professional surface IS the emotional payload.
Believes in the work. Smuggling isn't victimless — unlicensed lattice
components can cause neural degradation, medical replacements diverted from
Syndic supply chains mean someone else doesn't get what they need. The
investigation is justified. But the moral clarity is starting to erode —
the community is beginning to read as more complex than "crime scene."
opening_monologue:
lines:
- "Sova Transit District. Third day on station. The briefing said routine compliance review."
- "The briefing is always wrong about something."
- "Manifest discrepancies clustered around the freight hub. Start there. Watch the shift change."
- "Good to know Sera's here. At least someone in this district speaks my language."
emotional_payload: >
Isolation, professional caution, guarded optimism about Sera. The detective
is an outsider. The district doesn't want them here. The player should feel
the gap between institutional authority and social belonging.
```
---
## Authoring Notes
**What makes the detective's experience distinct:**
- The district is a CRIME SCENE. Every NPC is a potential witness, informant, or subject. The detective categorizes before connecting. Kael is "dock worker, Davan, K." before he's Kael. The shift to first names — if it happens — is itself a relationship marker.
- The monologue is ANALYTICAL and complete-sentence. Counts things. Notices deviations. The emotional register leaks through the analytical frame rather than leading it.
- The emotional journey of the vertical slice moves from professional isolation to specific, personal betrayal — but the betrayal is different: not "Kael lied to me," but "Sera, who I trusted completely, is compromised by something she did to protect someone I've never met."
**What the detective's authority access buys:** Conversations happen faster. NPCs answer direct questions. But answers are framed for compliance, not candor. The detective gets facts from people who don't want to give them — which means incomplete facts, deflected facts, facts that look complete but aren't. Authority access is a key that opens the door. It doesn't make what's inside visible.
**Voice anchors (mandatory reference for all detective monologue):**
1. *"Logistics hub. Standard prefab, heavy foot traffic. Let's see what the shift change tells me."*
2. *"That's the third time. Sera leaves when Torek arrives. Three different excuses, same result. That's not coincidence."*
3. *"Good to see Sera. She always was the reliable one. At least someone in this district speaks my language."*
4. *"Same booth. Same warm smile. Same offer to buy me a drink. ...Everything except whatever she's not saying."*
5. *"Grain spirit and low conversation. Only place in this district that doesn't smell like freight lubricant."*
---
*Ticket #181 — Detective character build. v0.1 scope.*
+490
View File
@@ -0,0 +1,490 @@
# Character Build: The Smuggler
**Ticket:** #180 | **Priority:** Critical | **Sprint:** 12
**Status:** v0.1 Draft
**Blocked by:** #179 (Character definition schema)
**Blocks:** #182 (Divergent starting knowledge), #183 (Divergent relationships)
**Decisions referenced:** D-005, D-016, D-027, D-028, D-032, D-034, D-035, D-037, D-041, D-053, D-065
---
## identity
```yaml
identity:
canonical_id: "pc:smuggler"
display_name: "[Player-set name]"
archetype: "smuggler"
occupation: "Freight Logistics Operator, Sova Transit District"
posting_duration: "~3 years (settled resident of Sova)"
faction: null # No institutional affiliation. Informal: ring member.
lattice_tier: "civilian"
description: >
Logistics worker at Sova Terminal Freight Hub. Three years on the station.
This is home in the way a place becomes home when you've stopped thinking
about leaving — comfortable enough to feel permanent, not comfortable enough
to feel easy. The ring started as extra income. It stayed because the people
in it became the people worth protecting.
Manages cargo routing. Knows the shift schedules, the corridor access windows,
the containers that move in ways they shouldn't. Has lived inside the operation
long enough that the criminal mechanics and the legitimate mechanics feel
the same — two halves of a single job. The distinction between "dock worker"
and "ring operative" dissolved somewhere around the 18-month mark.
Stands to lose: freedom (Commission arrest), the people (Kael, Lera, the
whole network), and the only version of stability they've built.
```
---
## starting_knowledge
```yaml
starting_knowledge:
confidence_floor: "KnowsOf"
known_facts:
# Ring operational knowledge
- key: "contraband.ring_exists"
confidence: "KnowsEverything"
source: "direct"
notes: "Member. Not a surprise. Not abstract. The daily operational reality."
- key: "contraband.lattice_components"
confidence: "KnowsDetails"
source: "direct"
notes: >
Handles the containers. Knows the cargo is unlicensed lattice components
and medical-grade neural replacements. Doesn't look too closely at the
sealed containers — enough to know, not enough to be haunted by specifics.
- key: "contraband.supply_chain_partial"
confidence: "KnowsOf"
source: "secondhand"
notes: >
Knows cargo comes from off-station contact (Sabel). Does not know the
full upstream supply chain or Syndic connections. Deliberately incurious.
- key: "contraband.ring_members"
confidence: "KnowsDetails"
source: "direct"
notes: >
Nils (coordinator), Kael (closest colleague), Voss (shift supervisor, takes
a cut), Renn (courier), Devra (handler, mostly off-station). Resha and other
minor members known by face if not always by name.
- key: "contraband.operation_schedule"
confidence: "KnowsEverything"
source: "direct"
notes: >
Shift transitions create 20-minute oversight gaps. Runs know during these
windows. Freight bay → service corridor → temp storage → bar back room.
Has run this route dozens of times.
# Location knowledge
- key: "location.corridor_b7_access"
confidence: "KnowsEverything"
source: "direct"
notes: "Knows B-7 is restricted. Knows how to enter without triggering flags."
- key: "location.smuggling_route"
confidence: "KnowsEverything"
source: "direct"
notes: "Full route map. Knows which corridors have surveillance gaps."
- key: "location.dead_drop_location"
confidence: "KnowsDetails"
source: "direct"
notes: "Temp storage bay 4 is the handoff point. Container 4471 is a recurring flag."
- key: "location.sova_district_full"
confidence: "KnowsEverything"
source: "direct"
notes: "Three years here. Knows every social site, shortcut, and NPC face."
# Relationship knowledge
- key: "relationship.ring_membership_full"
confidence: "KnowsEverything"
source: "direct"
notes: "Knows who's in. Knows who knows. The map of the network is second nature."
- key: "relationship.voss_compromised"
confidence: "KnowsDetails"
source: "direct"
notes: "Voss takes a cut. The scheduling cover is explicit. Not a theory."
- key: "relationship.drin_compromised"
confidence: "KnowsDetails"
source: "direct"
notes: "Dock inspector who looks the other way. Known through operational familiarity."
- key: "relationship.naia_connection"
confidence: "KnowsOf"
source: "secondhand"
notes: >
Knows Naia as Kael's partner. Occasional bar encounters. Likes her.
Doesn't know Naia suspects something is wrong with Kael.
# World knowledge
- key: "world.shift_schedule"
confidence: "KnowsEverything"
source: "direct"
notes: "Knows the schedule the way you know your own heartbeat."
- key: "world.commission_presence_minimal"
confidence: "KnowsDetails"
source: "direct"
notes: >
Commission presence on Sova is Sera Venn (field tech) plus occasional
compliance visits. Not a heavy institutional presence. The ring has operated
in this environment comfortably.
- key: "world.torek_spending"
confidence: "KnowsOf"
source: "observation"
notes: >
Torek's visible spending has caught the smuggler's attention as a
potential security risk. Not confirmed dangerous. A worry.
unknown_facts:
- key: "investigation.detective_identity"
notes: >
Does not know the detective exists as an investigator. A Commission liaison
was mentioned (ring warning from Kael or Voss), but not yet identified
as a specific investigative threat.
- key: "person.kael_exit_attempt"
notes: >
CRITICAL unknown. Kael is trying to exit the ring via an unauthorized
external contact. The smuggler trusts Kael completely. This gap is the
structural basis for THE FRIEND arc.
- key: "person.sera_unreported_evidence"
notes: >
Sera Venn has run an unofficial compliance check on Kael's work patterns
and found manifest discrepancies. She has not reported them. The smuggler
knows Sera only as "Commission field tech — stay away."
- key: "person.naia_confided_in_sera"
notes: >
Naia has shared her worries about Kael with Sera. This connection — Naia
to Sera to suppressed evidence — is entirely invisible to the smuggler.
- key: "contraband.supply_chain_full"
notes: >
The full Syndic connection and upstream supply chain beyond Sabel.
Deliberately kept out of operational knowledge by Nils.
- key: "investigation.manifest_discrepancies_flagged"
notes: >
The detective's case file includes specific manifest discrepancy data.
The smuggler doesn't know the investigation is that far along.
```
---
## starting_relationships
```yaml
starting_relationships:
- target: "npc:kael-davan"
relationship_state: "Friendly"
access_tier: [insider, peer]
trust_level: "trusted"
relationship_type: "friend"
known_attributes:
name: "Kael Davan"
role: "dock worker, ring member"
faction: "ring"
notes: >
Closest colleague. Two years of shared risk. Works together on both
legitimate shifts and ring operations. Lunch every shift, drinks after.
Greet by first name. The smuggler knows Kael's personal tell
(looks left when lying) — this detail is critical for Phase 4.
- target: "npc:voss"
relationship_state: "Known"
access_tier: [insider, peer]
trust_level: "reliable"
relationship_type: "superior"
known_attributes:
name: "Voss"
role: "shift supervisor, ring-aware"
faction: "ring (passive)"
notes: >
Professional relationship. Low warmth. Voss takes a cut — the smuggler
knows this. Trusts Voss to provide scheduling cover. Would not trust
Voss with anything personal.
- target: "npc:nils-davan"
relationship_state: "Known"
access_tier: [insider]
trust_level: "reliable"
relationship_type: "superior"
known_attributes:
name: "Nils Davan"
role: "ring coordinator"
faction: "ring"
notes: >
Kael's brother. Ring coordinator. The smuggler receives instructions via
Devra, rarely directly. Respects Nils's authority, wary of his escalating
appetite for volume. Knows Nils is Kael's sibling — personal dimension.
- target: "npc:lera-sessik"
relationship_state: "Friendly"
access_tier: [insider, peer]
trust_level: "trusted"
relationship_type: "friend"
known_attributes:
name: "Lera Sessik"
role: "bar owner, ring-aware"
faction: "ring (peripheral)"
notes: >
Regular at The Last Shift for two years. Lera saves the smuggler's usual
seat. Warm, uncomplicated relationship within bar context. Knows Lera is
ring-aware but doesn't press her into operational details.
- target: "npc:renn"
relationship_state: "Known"
access_tier: [insider]
trust_level: "reliable"
relationship_type: "colleague"
known_attributes:
name: "Renn"
role: "courier, ring member"
faction: "ring"
notes: "Operational relationship. Knows Renn's routes. Professional, not warm."
- target: "npc:devra"
relationship_state: "Known"
access_tier: [insider]
trust_level: "reliable"
relationship_type: "colleague"
known_attributes:
name: "Devra"
role: "external contacts manager"
faction: "ring"
notes: "Channel for instructions from Nils. Operational distance maintained."
- target: "npc:maret-korr"
relationship_state: "Known"
access_tier: [peer, insider]
trust_level: "reliable"
relationship_type: "colleague"
known_attributes:
name: "Maret Korr"
role: "freight scheduler"
faction: "ring (aware)"
notes: >
Hub colleague. Handles scheduling. The smuggler knows Maret is aware of the
operation through her scheduling access — part of the cover structure.
- target: "npc:drin"
relationship_state: "Known"
access_tier: [insider]
trust_level: "reliable"
relationship_type: "colleague"
known_attributes:
name: "Drin Rosta"
role: "dock inspector, compromised"
faction: "ring (passive)"
notes: "Looks the other way during container inspections. Known asset."
- target: "npc:naia-tamm"
relationship_state: "Known"
access_tier: [peer]
trust_level: "reliable"
relationship_type: "colleague"
known_attributes:
name: "Naia Tamm"
role: "Kael's partner, teacher"
faction: null
notes: >
Known through Kael. Occasional bar encounters. Genuinely likes her. Would
not want Naia to know about the ring — protective instinct.
- target: "npc:torek-lintar"
relationship_state: "Known"
access_tier: [peer]
trust_level: "uncertain"
relationship_type: "colleague"
known_attributes:
name: "Torek Lintar"
role: "bar regular, ring-adjacent"
faction: "ring (peripheral)"
notes: >
Known from bar. Spending is conspicuous — new clothes, rounds for
strangers, better food than his salary explains. The smuggler keeps
distance. A security concern without being a confirmed problem.
- target: "npc:sera-venn"
relationship_state: "Known"
access_tier: [public]
trust_level: "uncertain"
relationship_type: "colleague"
known_attributes:
name: "Sera Venn"
role: "Commission field technician"
faction: "Commission"
notes: >
Recognized as Commission. Keeps distance reflexively. No personal
knowledge, no social interaction. Categorized as institutional risk,
not personal threat.
- target: "npc:pc-detective"
relationship_state: "Unknown"
access_tier: [public]
trust_level: "uncertain"
relationship_type: null
known_attributes:
name: null
role: "Commission liaison (warned by ring)"
faction: "Commission"
notes: >
May have been warned about a new Commission presence (via Kael or Voss).
Does not know name, face, or specific investigative mandate. Risk
assessment: moderate. Protocol: keep head down, act normal.
```
---
## access_permissions
```yaml
access_permissions:
can_unlock:
- tier: "public"
condition: "Always available"
max_npcs: "unlimited"
- tier: "peer"
condition: "RelationshipState reaches Known or Friendly"
max_npcs: "unlimited"
- tier: "insider"
condition: "Ring membership (pre-existing) or explicit trust invitation"
max_npcs: "ring network NPCs from session start; others via play"
structurally_locked:
- tier: "authority"
reason: >
The smuggler has no institutional credentials. Cannot flash a badge.
Cannot invoke institutional leverage. Even if the smuggler confronted
an NPC with physical proof, the confrontation operates in the peer/
insider register, not the authority register. This is the architectural
asymmetry with the detective.
- tier: "insider (non-ring)"
reason: >
Insider access is specific to the ring network. The smuggler cannot
unlock insider access with NPCs outside the ring's trust structure —
Lera's inner-circle, the Commission's internal channels, institutional
back-channels. The smuggler's world is bounded by the ring's social web.
```
---
## skills
```yaml
skills:
lattice_tier: "civilian"
movement_default: "walk" # D-053 MovementProfile
inventory_slots: 4 # D-065 (3 specific items + 1 spare)
evidence_display: "personal_notebook"
capability_flags:
- "ring_membership" # Treated as insider by ring NPCs from session start
- "ring_routes" # Knows smuggling corridors and timing windows
- "cargo_handling_cert" # Can interact with freight systems without security flags
- "shift_schedule_mastery" # Full knowledge of shift transition windows
starting_items:
- item_id: "smuggler_manifest_copy"
description: "Copy of a falsified manifest showing cargo discrepancy. Proves the ring's operational method."
leverage_type: "proof"
notes: >
Physical proof of cargo routing manipulation. Not useful as leverage
against ring members (they know). Potentially useful if the smuggler
needs to prove involvement to the detective — or use as leverage
against someone outside the ring.
- item_id: "smuggler_corridor_token"
description: "Maintenance corridor access token (Corridor B-7). Standard-issue for ring members."
leverage_type: "access"
notes: >
Physical proof of ring membership. If scanned by security, it reads as
an irregularly issued access credential — not automatically incriminating,
but suspicious. The detective finding this would be significant.
- item_id: "smuggler_comm_log"
description: "Personal comm log with coded operational messages (shift timing, container IDs)."
leverage_type: "leverage"
notes: >
Recorded coded messages between the smuggler and Kael. If decrypted, it
maps the operation. Carried because the smuggler hasn't thought to delete it.
A liability they don't know is a liability.
```
---
## presentation
```yaml
presentation:
monologue_pool: "smuggler"
evidence_display: "personal_notebook"
voice_register:
sentence_length: "fragments"
vocabulary: "concrete/sensory — trade jargon without explanation"
emotional_mode: "feeling-first, analysis second"
relationship_mode: "first-name/relational"
emotional_baseline:
state: "comfortable-tense"
description: >
Not happy, not miserable. The smuggler is managing a life with two faces
and has gotten good at it. The money helps. The team is solid. The risk is
real but felt manageable for two years. Comfort comes from knowing the
people, the routes, the rhythms. Tension lives in the background — ambient,
not acute. The day is better when everything goes as planned. The day turns
when something doesn't.
Protective by default. Thinks about people before systems. First instinct
when something looks wrong: who does this affect, and what can be done for
them? Institutions are backdrop. People are the world.
opening_monologue:
lines:
- "Morning shift. Recycled air and cargo lubricant. Home sweet home."
- "Kael's already at the dock. Good. The day's better when he's on shift."
- "Transition window opens in fifty minutes. Standard run — nothing complicated."
- "Just keep it clean. Keep it quiet. Another day."
emotional_payload: >
Comfort, routine, protectiveness. The smuggler is at home. The world is
manageable. Operational awareness runs in the background like breathing.
The player should feel settled before the first crack appears.
```
---
## Authoring Notes
**What makes the smuggler's experience distinct:**
- The ring is HOME. Every NPC in the ring network is a person they eat lunch with, drink with, cover for. The investigation, when it comes, doesn't feel like "a crime is being exposed" — it feels like "my people are in danger."
- The monologue is TERSE and relational. Two-word assessments. "Kael's good." "Voss is tense." No analysis of systems. Feeling first, named in physical terms.
- The emotional journey of the vertical slice moves from settled comfort to specific, personal betrayal. Not "the ring is threatened." "KAEL is lying to me."
**Voice anchors (mandatory reference for all smuggler monologue):**
1. *"Kael's already at the dock. Good. The day's better when he's on shift."*
2. *"Shift transition in thirty minutes. If Voss kept the schedule thin, the route's clear."*
3. *"Kael keeps checking his lattice. Waiting for a message? Not like him to be jumpy."*
4. *"He looked left. He always looks left when he's making something up. Two years I've known that tell. Kael is lying to me."*
5. *"Morning shift. Recycled air and cargo lubricant. Home sweet home."*
---
*Ticket #180 — Smuggler character build. v0.1 scope.*
+245
View File
@@ -0,0 +1,245 @@
# Character Definition Schema
**Ticket:** #179 | **Priority:** Critical | **Sprint:** 12
**Status:** v0.1 Draft
**Blocks:** #180 (Smuggler character build), #181 (Detective character build)
**Decisions referenced:** D-005 (single character, asymmetric information), D-016 (internal monologue), D-027 (vertical slice), D-028 (dialogue layers), D-032 (separate monologue pools), D-034 (THE FRIEND), D-035 (tag taxonomy), D-041 (knowledge graph), D-065 (smuggler inventory)
---
## 1. Purpose
This document defines the canonical schema for a playable character in v0.1. A playable character is **not just a perspective** — it is a complete starting state that determines what the player sees, who they know, what they understand, and what they can do. Every field in this schema has downstream content implications.
Two characters share the same world. Two different schemas produce two different games.
---
## 2. Schema Overview
A playable character definition has six top-level sections:
| Section | Purpose |
|---------|---------|
| `identity` | Who this character is in the world |
| `starting_knowledge` | What the character knows at session start (KG seed) |
| `starting_relationships` | Pre-existing relationships and their initial state |
| `access_permissions` | Which dialogue tiers are available at session start |
| `skills` | Capability flags affecting gameplay options |
| `presentation` | Voice, monologue register, and diegetic evidence display |
---
## 3. Identity
```yaml
identity:
canonical_id: "pc:{slug}" # e.g., "pc:smuggler", "pc:detective"
display_name: "{Name}" # Player-visible name (stub until #180/#181)
archetype: "{archetype}" # "smuggler" | "detective"
occupation: "{occupation}" # Diegetic job title
posting_duration: "{duration}" # How long they've been in Sova Transit District
faction: "{faction}" # Institutional affiliation if any
lattice_tier: "{tier}" # "civilian" | "professional" | "investigative"
description: >
1-2 paragraph description of who this character is in the world, from
a fictional universe perspective — not mechanical. Establishes emotional
baseline, social position, and what they stand to lose.
```
**Field notes:**
- `archetype` is used internally by the engine for monologue pool selection (D-032 hard partition). It is NOT exposed in the UI. Character feels like a person, not a game mechanic.
- `lattice_tier` determines which perception overlays are available (D-017). Civilian: no enhanced perception. Professional: diagnostic tools, analytical overlays. Investigative: full analytical suite.
- `posting_duration` affects which starting knowledge entries are plausible. A character who arrived 3 days ago cannot know the bar regulars by name.
---
## 4. Starting Knowledge
The starting knowledge section defines the Knowledge Graph seed (D-041) — the fact-set the character enters the session with. Facts not listed here are genuinely unknown at session start.
```yaml
starting_knowledge:
confidence_floor: "{tier}" # Minimum confidence level; see D-041
known_facts:
- key: "{fact.key}" # Namespaced fact key (e.g., "contraband.ring_exists")
confidence: "{tier}" # KnowsOf | KnowsDetails | KnowsEverything
source: "{source}" # "direct" | "secondhand" | "institutional" | "rumor"
notes: >
Optional. Authoring note on how the character holds this knowledge.
unknown_facts:
- key: "{fact.key}"
notes: >
Why this gap exists — what would they need to do to discover it?
```
**Fact key namespaces:**
| Namespace | Covers |
|-----------|--------|
| `contraband.*` | Ring existence, cargo types, supply chain, operational methods |
| `location.*` | Specific places, access routes, surveillance gaps |
| `investigation.*` | Manifest discrepancies, evidence, case file data |
| `relationship.*` | Who knows whom, trust levels, ring membership |
| `world.*` | Station geography, shift schedules, institutional structures |
| `person.*` | Specific facts about named individuals |
**Critical rule:** The unknown_facts section is as important as known_facts. It defines what the character CANNOT react to at session start and bounds what monologue triggers are legal.
---
## 5. Starting Relationships
Pre-existing relationships that determine initial RelationshipState (D-075) and AccessTier for every named NPC in the district. NPCs not listed here start at `Unknown` / `public` access.
```yaml
starting_relationships:
- target: "npc:{canonical_id}"
relationship_state: "{state}" # Unknown | Known | Friendly | PersonOfInterest | Hostile
access_tier: ["{tiers}"] # public | peer | insider | authority | hostile
trust_level: "{level}" # uncertain | reliable | trusted | hostile
relationship_type: "{type}" # colleague | friend | superior | contact | adversary
known_attributes:
name: "{name}" # Whether the character knows the NPC's name
role: "{role}" # What role the character assigns to this NPC
faction: "{faction}" # Whether the character knows the NPC's affiliation
notes: >
Short description of the nature of this relationship and how it was established.
```
**Relationship state → access tier mapping (default):**
| RelationshipState | Default Access Tier | Override possible? |
|-------------------|--------------------|--------------------|
| Unknown | public | Yes (authority overrides via badge/credentials) |
| Known | public, peer | Yes |
| Friendly | peer, insider | No insider without explicit history |
| PersonOfInterest | authority (detective) | Yes |
| Hostile | hostile | No |
**Character-specific overrides:** The smuggler has insider access to ring-member NPCs from session start (shared criminal history). The detective has authority access to institutional NPCs from session start (institutional credentials). These overrides are specified per-relationship, not as a global archetype flag (D-075).
---
## 6. Access Permissions
Global access tier capabilities at session start — what the character can unlock through gameplay versus what is structurally inaccessible.
```yaml
access_permissions:
can_unlock:
- tier: "{tier}"
condition: "{condition}" # When this tier becomes accessible
max_npcs: "{n | unlimited}" # How many NPCs can reach this tier
structurally_locked:
- tier: "{tier}"
reason: >
Why this tier is never accessible to this character across the
entire v0.1 vertical slice.
```
**v0.1 constraints:**
- **Smuggler** can unlock: public (all), peer (Known→Friendly), insider (ring-member NPCs from start; others locked). Cannot unlock: authority (no institutional credentials).
- **Detective** can unlock: public (all), authority (any Unknown/Known NPC via credentials), peer (Friendly NPCs only — currently Sera at session start). Cannot unlock: insider (ring dialogue never becomes available, even with investigation progress — the ring locks tighter, not looser, when the detective gets close).
This asymmetry is structural, not a content choice. It defines the two-keyhole architecture (D-027).
---
## 7. Skills
Capability flags that affect which game verbs are available and how gameplay systems respond.
```yaml
skills:
lattice_tier: "{tier}" # Copied from identity; determines perception modes (D-017)
movement_default: "{stance}" # Default stance from D-053 MovementProfile
inventory_slots: {n} # Physical inventory capacity (D-065)
evidence_display: "{format}" # "case_file" | "personal_notebook" (D-065)
capability_flags:
- "{flag}" # e.g., "manifest_access", "authority_credentials", "ring_routes"
starting_items:
- item_id: "{id}"
description: "{description}"
leverage_type: "{type}" # "proof" | "access" | "leverage"
notes: >
What this item proves, enables, or demonstrates.
```
**Capability flags vocabulary:**
| Flag | Effect |
|------|--------|
| `manifest_access` | Can request freight manifests from institutional NPCs |
| `authority_credentials` | Badge/identification; unlocks authority tier with Unknown/Known NPCs |
| `ring_routes` | Knows smuggling corridors and timing windows |
| `ring_membership` | Treated as insider by ring-member NPCs from session start |
| `analytical_lattice` | Pattern recognition overlay; flags behavioral anomalies at Careful stance |
| `cargo_handling_cert` | Can interact with freight systems without triggering security flags |
---
## 8. Presentation
How this character's internal voice, diegetic evidence display, and monologue pool are configured.
```yaml
presentation:
monologue_pool: "{archetype}" # Hard partition per D-032 ("smuggler" | "detective")
evidence_display: "{format}" # Inherited from skills; documented here for content authors
voice_register:
sentence_length: "{pattern}" # "fragments" | "complete" | "mixed"
vocabulary: "{style}" # "concrete/sensory" | "institutional/analytical"
emotional_mode: "{mode}" # "feeling-first" | "analysis-first"
relationship_mode: "{mode}" # "first-name/relational" | "surname-first/categorical"
emotional_baseline:
state: "{state}" # One-word summary of starting emotional state
description: >
3-5 sentences on the character's psychological starting position. This
is the foundation that monologue lines build from. Lines that contradict
this baseline are wrong.
opening_monologue:
lines:
- "{line}" # 3-5 authored lines; see D-039 wow moment #1
emotional_payload: >
What the player should feel in the first 60 seconds.
```
**Voice register is non-negotiable.** The smuggler thinks in fragments. The detective thinks in complete sentences. The moment an author writes a complete-sentence analysis for the smuggler or a terse fragment for the detective, they've broken the dual-lens contract. The voice register fields in this schema exist so downstream authors never have to re-read the authoring guide — the character build IS the authoring guide.
---
## 9. Schema Validation Rules
Before finalizing a character build, validate against these rules:
| Rule | Check |
|------|-------|
| **Knowledge symmetry** | For every `known_fact` about an NPC, that NPC file must have a corresponding `known_attributes` entry in the character's relationship |
| **Access tier consistency** | No `insider` access without `ring_membership` or equivalent history |
| **Monologue pool** | All authored monologue lines tagged with this character's `archetype` (hard partition) |
| **Unknown facts are real** | Every fact in `unknown_facts` must NOT appear in any authored monologue line at session start |
| **Opening monologue** | Opening lines must NOT reference facts the character doesn't yet know |
| **Inventory items** | All starting items must have corresponding world entities in content files |
| **Emotional baseline** | Opening monologue lines must match the character's `emotional_baseline.state` |
---
## 10. Cross-Reference: Downstream Documents
| Document | How This Schema Feeds It |
|----------|------------------------|
| `character-build-smuggler.md` (#180) | Full instantiation of this schema for the smuggler |
| `character-build-detective.md` (#181) | Full instantiation of this schema for the detective |
| `divergent-starting-knowledge.md` (#182) | Comparison table derived from both builds' `starting_knowledge` sections |
| `divergent-relationships.md` (#183) | Comparison table derived from both builds' `starting_relationships` sections |
| `npcs/pc-smuggler.yaml` | NPC mode representation when detective is the active player |
| `npcs/pc-detective.yaml` | NPC mode representation when smuggler is the active player |
| Content packs (#190, #191, #192) | Access tier decisions in this schema determine which content each character sees |
| Opening hooks (#299, #300) | Opening monologue section provides the first lines |
---
*Ticket #179 — Character definition schema. v0.1 scope. Blocks #180, #181.*
+247
View File
@@ -0,0 +1,247 @@
# Contraband Specification — Lattice Components & Supply Chain
**Ticket:** #321
**Author:** Miri
**Status:** canonical
**Last updated:** 2026-02-19
Reference: D-037 (contraband decision), D-036 (Sova setting), D-017 (perception modes)
---
## Purpose
This document defines the specific contraband being smuggled in the Sova Transit District scenario. It provides the supply chain, street terminology, moral dimensions, and detection/evasion methods that NPC dialogue and investigation mechanics depend on.
**Why this exists:** Mellanie cannot write ring NPC dialogue without knowing what the ring is smuggling and why people want it. Paula cannot design NPC motivations without knowing the moral stakes. The detective cannot investigate without knowing what they're looking for. This document is the shared reference.
---
## Part 1: Primary Contraband — Unlicensed Lattice Components
### What They Are
**Neural lattice components** are the hardware and firmware elements that make up the neural lattice implant — the Reach's universal human-machine interface. Every citizen with a lattice (i.e., almost everyone) has a configuration of components that determines their lattice tier and capability.
**Unlicensed lattice components** are lattice hardware and firmware obtained outside of Lattice Commission certification channels. This includes:
- **Enhancement modules:** Hardware add-ons that expand a Baseline lattice toward Augmented-tier capability. Improved sensory processing, faster Meridian query response, expanded information overlay capacity.
- **Aftermarket firmware:** Software modifications that unlock capability built into the hardware but blocked by Commission certification tiers. The hardware is the same; the firmware determines what it can do.
- **Capability-specific mods:** Targeted modifications for specific use cases — enhanced audio discrimination, improved spatial mapping, pattern recognition support. Often developed for occupational purposes in systems where Commission access is expensive.
- **Medical-grade neural replacements:** Certified neural lattice replacement components diverted from legitimate Syndic supply chains. Priced far below standard Syndic channels because they're off-manifest.
### Commission Regulatory Framework
The Lattice Commission regulates neural lattice technology under Reach-wide mandate. The certification system works as follows:
- **Baseline tier:** Standard-issue. Commission-certified, universally available, low-cost. Factory default capabilities only.
- **Augmented tier:** Institutional or premium. Requires Commission recertification, documented justification (occupational need, institutional role), and ongoing compliance monitoring. Significantly more expensive.
- **Enhanced tier:** Specialist. Requires extensive justification, Commission review board approval, and continuous monitoring. Rare.
- **Transcendent tier:** Theoretical or classified. Not available through any public channel.
The regulation exists for stated reasons: safety (unregulated hardware poses health risks), security (enhanced lattice capability creates surveillance and data-access concerns), and economic stability (Commission certification fees are a significant revenue stream).
The regulation also exists for unstated reasons: the Commission's tier pricing structure benefits Syndic manufacturers who hold Commission approval. Aftermarket components break the Commission's market control.
### Why People Want Unlicensed Components
The demand is not criminal in motivation. Most buyers want lattice components for ordinary reasons:
**Occupational capability:** A freight worker whose job involves spatial reasoning and pattern detection can do that job better with Augmented-tier pattern recognition support. Commission-certified upgrade: expensive, requires institutional sponsorship. Aftermarket equivalent: costs a month's wages, no sponsorship required, available through the ring.
**Medical necessity:** Neural lattices degrade over time, especially in older models and in people who've done intensive lattice work for years. Replacement components are essential healthcare. Syndic pricing for lattice replacements is calibrated to institutional buyers (hospitals, Commission offices). For individuals — particularly working-class individuals without employer-sponsored medical plans — the legitimate price is prohibitive. Aftermarket replacements work the same way for a fraction of the cost.
**Personal autonomy:** Some people simply believe they have the right to determine their own lattice configuration without Commission approval. The Guardians of Severance take this position philosophically. The ring's customers mostly take it practically.
**Access inequality:** The Commission's tier system means that institutional workers (investigators, administrators, Syndic employees) get enhanced lattice capability through their employer. Workers outside institutional employment don't. Aftermarket components are one way the non-institutional population accesses capabilities the institutional population considers routine.
### The Moral Dimension
The ring is smuggling **access**, not weapons.
No one is importing military-grade targeting systems or hostile surveillance technology. The contraband is the same components that the detective uses, bought through a different channel, without Commission paperwork.
The moral question the player faces is not "is it wrong to trade in harmful things?" It is "is it wrong to provide people with things the Commission has decided they're not allowed to have, for reasons that primarily serve the Commission's business interests?"
This question does not have a clean answer. The ring knows it doesn't have a clean answer. The ring's members have made their peace with working in the gray zone. Some of them use their own products.
**Key moral detail:** Naia (Kael's partner) uses a diverted medical-grade lattice replacement. Her original lattice degraded faster than expected — occupational wear. The legitimate replacement was priced at 14 months of combined wages. The aftermarket replacement worked. This is one of the ring's own. This is, in part, why Kael is in the ring at all. The detective doesn't learn this until they have Kael's trust.
---
## Part 2: Secondary Contraband
### Severance Counter-Surveillance Technology
**What it is:** Hardware and software tools designed to evade, disrupt, or spoof Meridian monitoring. Produced and distributed by the Guardians of Severance (the counter-surveillance movement) but also trafficked commercially to buyers who have no ideological alignment — they just want privacy.
**Why it's in the ring:** The ring uses Severance tech for operational purposes. They also move product for Severance-adjacent customers. It's not their primary business, and they don't advertise it, but if someone in their network needs a comm scrambler or a lattice signature mask, the ring can provide.
**Moral dimension:** Severance tech protects privacy, which is a legitimate interest. It also enables the ring's operations, which is less sympathetic. And it undermines Commission surveillance, which is either a feature or a bug depending on your perspective.
**Commission classification:** Category 2 violation — possession of surveillance countermeasure technology. More serious than unlicensed lattice components. The ring is careful with this part of their inventory.
**Detection note:** Severance tech is harder to scan for than lattice components — it's designed not to be found. The detective's augmented lattice can detect the signature of active Severance scrambling, but passive Severance hardware (not currently running) doesn't trigger. The smuggler knows this.
### Off-Manifest Biologicals
**What they are:** Medical supplies, pharmaceutical compounds, biological cultures, and occasionally re-embodiment materials that are diverted from official Syndic supply chains and moved through informal channels.
**Why they're off-manifest:** Multiple reasons:
- Syndic pricing for medical supplies creates a gray market, same as lattice components
- Some biological materials are classified as controlled under Commission or Concord Assembly pharmaceutical regulations — legitimate reasons for control, but regulations calibrated for Syndic-scale distribution, not individual access
- Re-embodiment backup materials are tightly controlled — owning a personal backup of your own cognitive state is technically legal but creating and moving the physical medium outside certified facilities is not
**Why the ring handles them:** The same logistics infrastructure that moves unlicensed lattice components can move biologicals. The margin is smaller, the risk is comparable, and some of the same customers want both.
**Moral dimension:** This is the murkiest category. Moving medical supplies to people who can't afford Syndic pricing — sympathetic. Moving re-embodiment backup materials to people who want personal insurance against death without going through the certified system — complicated. Moving materials of unknown provenance through an unregulated supply chain without knowing what they actually are — this is where the ring's "we don't ask" culture becomes a liability.
**Volume in v0.1:** Minor fraction of total cargo. The ring focuses on lattice components. The biologicals are occasional, handled only through a specific trusted member, and never discussed in the same conversation as the lattice business.
---
## Part 3: Supply Chain
### Upstream — Manufacture and Acquisition
**Aftermarket lattice component manufacturers:**
- Primarily operate in systems with lighter Commission presence — frontier and deep-frontier manufacturing, occasionally mid-Reach systems with local regulatory variance
- Not criminal enterprises in their origin systems — they operate legally where they are; the illegality is at the destination end where Commission certification applies
- Some Syndic-adjacent: subsidiaries or licensing agreements that produce off-spec components under plausible deniability
- Products are shipped in legitimate freight that also carries legitimate cargo — the contraband fraction of any given container transit is small
**Acquisition chain:**
- Coordinator receives orders from the distribution network
- Acquisition contacts at origin-system freight handlers arrange for components to be included in outbound shipments with doctored manifests
- The doctored manifests show legitimate cargo codes; the actual components are repacked into containers that look right
**Detection risk at origin:** Low. Commission enforcement is concentrated at destination hubs (where the Commission has jurisdiction), not at frontier or mid-Reach origin points.
### Midstream — The Ring's Role
**The ring's function is transit, not manufacture or retail.** They are a distribution node in a larger network. Their specific value is:
- Access to Sova's span gate (a legitimate freight chokepoint)
- Relationships with dock workers who control scanner routing
- Knowledge of scanner blind spots and maintenance windows
- The ability to blend contraband into legitimate freight flows
**Operational mechanics:**
1. **Notification:** Coordinator receives arrival details through off-Meridian channels. This information is passed to ring members through code language on Meridian or through physical dead drops.
2. **Preparation:** Ring member verifies scanner bay availability and status. Flags maintenance windows. Identifies lowest-scrutiny route through the hub.
3. **Receipt:** Contraband arrives in a standard freight container. On the manifest, it's listed as a legitimate cargo category (machine parts, agricultural supplies, industrial components — whatever is plausible for the transit route).
4. **Routing:** Ring member routes the container to a scanner bay with known compliance. Either a bay with a calibration blind spot (specific scanner types can't detect lattice components in certain packing configurations) or a bay operated by a ring-aware worker who logs a clean scan.
5. **Handoff:** Container moves to staging. Ring member marks it for the receiving contact's pickup. The receiving contact has legitimate access to staging (Syndic freight broker, commercial customer, station resident with freight delivery).
6. **Payment:** Through the coordinator, via off-manifest routes. The ring doesn't handle large cash-equivalent transactions on Meridian. Payment is managed in chunks, routed through legitimate purchases.
**What can go wrong:** The ring's operational security depends on nothing standing out. Problems occur when:
- A worker outside the ring notices something — wrong container in wrong bay, worker behavior that's off-routine
- A scanner bay does a spot-check instead of the expected throughput scan
- A ring member's behavior attracts attention (Torek's recent carelessness is an example)
- The detective's manifest analysis flags anomalies in the shipping record
### Downstream — Distribution
**The ring's customers are not known to most ring members.** The coordinator manages the downstream. Ring members know they're moving components; they don't know who ends up with them.
From what's inferrable:
- Local distribution: station residents, some district workers, a few customers from other districts on Sova
- Off-station: the span gate delivers cargo to the planetary freight depot on Velen. Some product continues via Velen's distribution network to system population centers.
- Regional: occasional higher-value shipments routed through the system to other ring nodes beyond Krenn System. The ring doesn't manage this leg.
**The v0.1 scope:** The detective's investigation is concerned with the Sova Transit District operation. The broader network is context, not target. Discovering that the ring is part of a larger network is an investigation milestone, not the investigation conclusion.
---
## Part 4: Terminology Reference
### Formal/Institutional Terms (Detective + Commission)
| Term | Meaning |
|---|---|
| **Unlicensed lattice components** | Hardware/firmware outside Commission certification |
| **Category 3 violation** | Commission classification for lattice tampering |
| **Manifest discrepancy** | Difference between declared and actual cargo |
| **Cargo routing anomaly** | Container reaching destination via non-standard path |
| **Off-manifest transfer** | Goods moving without Meridian-logged documentation |
| **Surveillance countermeasure equipment** | Severance tech (formal term) |
| **Category 2 violation** | Commission classification for Severance possession |
### Street/Ring Terms (Smuggler + NPC Insider)
| Term | Meaning | Context |
|---|---|---|
| **components** | Unlicensed lattice mods | "Got a components run tonight." |
| **inserts** | Same as above, slightly more specific | "The inserts are in Bay 7." |
| **upgrade kits** | Packaged lattice enhancement sets | "They ordered two upgrade kits this week." |
| **medical** | Medical-grade lattice replacements specifically | "This one's medical — handle it carefully." |
| **scramblers** | Severance tech | "He wanted a scrambler, I told him to ask the coordinator." |
| **the product** | Generic for whatever the ring is moving | "The product moves at 0200." |
| **a clean manifest** | Successfully falsified cargo documentation | "Manifest is clean." |
| **a bad can** | A container that's been flagged or is otherwise problematic | "Don't touch that one — it's a bad can." |
| **the coordinator** | The ring's off-site manager | Referred to obliquely. Never by name in non-secure channels. |
---
## Part 5: Detection and Evasion
### How the Commission Detects Contraband
**Manifest analysis:** Comparing declared cargo against shipping codes, routing patterns, and commercial plausibility. Anomalies flag for review. The detective's augmented lattice can run this analysis on a per-container basis; the Commission system runs it across all incoming freight as a pattern recognition exercise.
**Physical scanning:** Standard freight scanners check for mass discrepancies, specific material signatures, and packing anomalies. Unlicensed lattice components are difficult to detect via standard scan — they read as generic electronics without specific interrogation protocols. Enhanced scan (requiring cause and authorization) can identify lattice component signatures specifically.
**Lattice signature analysis:** An enhanced lattice running the right query can detect the electromagnetic signature of recently modified lattice hardware in proximity. This requires close physical proximity and active scanning — not passive detection. The detective could deploy this if they have a specific target and authorization.
**Behavioral surveillance:** Watching who goes where, who talks to whom, who handles certain containers. The Meridian logs access patterns in institutional spaces (Commission-authorized areas). Manual surveillance in non-Meridian spaces is the detective's work.
### How the Ring Evades Detection
**Manifest doctoring:** The primary defense. Contraband is listed as plausible legitimate cargo. Batch sizes are kept below the threshold that triggers automatic enhanced review. Shipping routes use established legitimate freight paths.
**Scanner routing:** Contraband containers are routed to scanner bays known to have calibration limitations or operated by ring-aware workers. The ring maintains awareness of which bays are running which protocols on any given day.
**Timing:** Operations occur during shift transitions (when attention is divided), during maintenance windows (when some scanner systems are offline), and during high-volume pushes (when individual container scrutiny is lower).
**Operational security:** No discussion of specifics on Meridian-logged channels. Limited need-to-know within the ring. The coordinator knows the full picture; individual ring members know only their operational role.
**Normalcy as cover:** The ring's greatest protection is that everything looks normal. No late-night break-ins. No dramatic meetings in dark corridors. Everything happens within the ordinary flow of freight operations. The detective is looking for anomalies in a sea of normal; the ring works to minimize the anomalies.
---
## Part 6: Gameplay Implications
### For the Smuggler
The smuggler's daily work IS the ring's operation. They don't separate their legitimate job from their criminal one — they do both simultaneously. The investigative element for a smuggler playthrough is not "will I get caught" but "how do I keep everything in balance as the detective's investigation narrows."
The three physical inventory items (D-065) connect directly to this:
1. **Manifest copy** — proves cargo discrepancy, but having it means carrying evidence of the operation
2. **Corridor access token** — proves ring membership, physical risk if discovered
3. **Personal comm log** — recorded conversations that prove network connections, the most dangerous of the three
### For the Detective
The detective's investigation begins with manifest analysis and behavioral observation. The progress trajectory is:
- Level 1: Notice statistical anomalies in freight manifests (requires augmented lattice + access)
- Level 2: Connect specific containers to specific workers (requires surveillance + NPC knowledge)
- Level 3: Establish the supply chain (requires either informant cooperation or direct evidence)
- Level 4: Identify the coordinator and the broader network (requires trust-gated disclosure or confrontation)
The detective does not need to know everything in this document. They discover it through investigation. This document is the ground truth that their knowledge state moves toward.
### Moral Ambiguity in Practice
The intent is that a player who investigates Kael seriously — who learns about Naia's medical situation, who understands why Kael is in the ring — should not feel straightforwardly good about bringing Kael down. The smuggler player, similarly, should not feel entirely comfortable with their complicity once they understand the ring's full scope.
The contraband is designed to be sympathetic at its core (medical necessity, access inequality) while creating complications the deeper you look (the biologicals of unknown provenance, the Severance tech enabling things the player may not endorse). Neither character has clean hands. Neither character has indefensible ones. That's the design intent.
---
*This document is the canonical contraband reference for v0.1 content. NPC dialogue involving ring operations, cargo handling, or investigation should cross-reference here for terminology consistency. If a content gap is identified — a term needed that isn't here, a scenario not covered — flag to Miri for addition.*
*Miri — Sprint 12*
@@ -0,0 +1,272 @@
# Contradiction Arc — THE FRIEND Pattern
**Ticket:** #332
**Source:** D-034 (THE FRIEND ECS spec), D-039 (wow moments — #3), D-041 (contradiction detection), D-028 (dialogue architecture)
**Derived from:** Kael Davan (smuggler's FRIEND, #297) + Sera Venn (detective's FRIEND, #298)
**Date:** 2026-02-19
---
## Overview
THE FRIEND is a reusable NPC pattern. One per playable character. The FRIEND is the emotional centerpiece of v0.1 — the NPC the player names when asked "who did you feel conflicted about?" (D-027 success criterion #3).
The pattern has one invariant: **the contradiction is observable through physical space or behavioral pattern, never through dialogue.**
The player discovers the truth by watching, not asking. Dialogue confirms or deflects — it never reveals first.
---
## The Five Phases
| Phase | Duration | NPC State | Player Experience |
|-------|----------|-----------|-------------------|
| 1. **Warmth** | 0-10 min | Baseline positive. No deviations. | This NPC is your best relationship. The day is better when they're here. |
| 2. **Background** | 10-15 min | Subtle behavioral flag begins. Not enough to read yet. | Social context accumulates. You learn who this person is to the world. |
| 3. **First Contradiction** | 15-20 min | Observable anomaly fires. Color shifts from green to amber. | Something doesn't fit. The belief you formed in Phase 1 is contradicted. |
| 4. **Confrontation Opportunity** | 20-25 min | Deflection. The TELL fires during the deflection. | You ask. They don't answer. But you notice something. |
| 5. **Contaminated Trust** | 25-30 min | Surface behavior returns to baseline. | The FRIEND acts normal. But you're watching now. Every Phase 1 moment is retroactively suspect. |
### Phase 1 requirements
- FRIEND must exhibit genuine warmth — greet the player first, share information willingly, reference personal connection
- At least one Phase 1 scene establishes an emotional anchor (shared meal, personal reference, easy banter)
- Routine is fully normal — punctual, sociable, consistent
- No behavioral flags — anything in Phase 1 must be readable as normal in isolation
### Phase 2 requirements
- FRIEND continues warm baseline but begins accumulating subtle signals
- Subtle signals must be individually readable as normal: leaving early has a plausible reason, checking lattice is common behavior
- Phase 2 establishes context that will retroactively charge Phase 3: introduce the relationship that's being protected, establish the FRIEND's expertise/role, build emotional depth
- At least one Phase 2 scene introduces the third party the FRIEND is protecting (Naia for Kael, Naia-via-Sera for the detective's arc)
### Phase 3 requirements — the contradiction
**Type A: Spatial Anomaly**
Character is in a location they have no legitimate reason to be in, at an illegitimate time, doing something outside expected protocol. Single observation. Sudden alarm.
*Kael example:* In Corridor B-7 (restricted, not his zone) after his shift, meeting an unrecognized person outside ring protocol. The smuggler sees it and the prior belief ("Kael operates within ring protocol") is immediately contradicted.
**Type B: Behavioral Pattern**
Character exhibits a consistent behavioral response across multiple observations. No single observation is conclusive — the third occurrence completes the pattern. Gradual recognition.
*Sera example:* Leaves the bar within 2-3 minutes every time Torek arrives. Three occurrences, three different stated reasons. Detective training fires on the third: this is not coincidence.
**Pattern B requires three observations** before the contradiction fires — one is nothing, two is coincidence, three is data.
**Both types:**
- The contradiction must be spatially or behaviorally discoverable — something the player's character would notice from observation
- Dialogue does not reveal the contradiction — the FRIEND either doesn't know the player observed, or has a plausible explanation that doesn't collapse the contradiction
- Color shifts from green (Friendly) to amber (PersonOfInterest) when contradiction is observed
- Knowledge graph: contradiction fact set to `suspects` level, triggering contradiction-detection monologue
### Phase 4 requirements — the confrontation opportunity
Player initiates dialogue about the Phase 3 observation.
**Required structure:**
1. Player asks about the anomaly (spatial presence, behavioral pattern, etc.)
2. FRIEND deflects — gives a plausible answer that doesn't confirm or deny
3. **The TELL fires during the deflection.** Not before, not after — during the deflection. The tell is how you know they're hiding something.
4. The tell must be:
- Established or at minimum detectable before Phase 4 (player can already know this tell)
- Observable through the character's behavior, not through dialogue content
- Something that becomes clearer in retrospect once the player knows to watch for it
**Key constraint:** The FRIEND does not confess in Phase 4. The confrontation reveals the lie, not the truth. The truth comes later — either through investigation, a separate disclosure event, or the player's own inference.
### Phase 5 requirements — contaminated trust
Surface behavior returns to Phase 1 baseline. The FRIEND continues as before.
**What changes:**
- Every prior Phase 1 moment is retroactively ambiguous. Was the warmth genuine, or instrumental?
- New monologue lines fire that filter current observations through doubt: "Same old Kael. Same jokes, same routine. ...What are you not telling me?"
- Relationship state: PersonOfInterest persists. Color stays amber.
- Three resolution paths open (see below)
**The answer to "was the warmth genuine?"** is always both — yes AND they were hiding something. This ambiguity is the emotional payload. The FRIEND pattern does not deliver a villain. It delivers a person making a hard choice.
---
## Resolution Paths
Three paths available post-Phase 4. None is correct. All carry costs.
| Path | Action | What It Tests |
|------|--------|---------------|
| **Understanding** | Gentle confrontation — player asks again, creates space for disclosure | Willingness to extend trust despite evidence |
| **Betrayal** | Report to authority (ring leadership / Commission) | Institutional loyalty over personal loyalty |
| **Observation** | Watch and wait — build knowledge without confronting | Information-gathering over emotional resolution |
**Design constraint:** No path delivers a clean outcome within v0.1. Understanding may get partial confession but not full truth. Betrayal removes the FRIEND from play but doesn't close the case. Observation extends the ambiguity indefinitely.
---
## Tell Progression
Every FRIEND NPC requires at least three tells, each with different observability:
| Tell Level | Observability | Timing | Purpose |
|-----------|---------------|--------|---------|
| **Peripheral** | Visible to any observer with some attention | Phase 2 onset | Subconscious accumulation — player may notice retroactively |
| **Forward** | Visible to focused observers / in direct interaction | Phase 3-4 | Confirmation tell — fires when the player is paying attention |
| **Direct** | Confirmed in post-conversation monologue | Phase 4 | The TELL — fires during the deflection, seals the case |
### Kael's tells
1. Increased lattice checking (peripheral, Phase 2)
2. Shortening social interactions / leaving early (any, Phase 2-3)
3. **Looks left when lying** (forward, Phase 4 — the personal tell the smuggler knows from years of friendship)
4. Unauthorized spatial presence in B-7 (any, Phase 3 — the contradiction itself)
### Sera's tells
1. Extended Commission kiosk time (peripheral, Phase 2)
2. Overcompensation with information (forward, Phase 2-3)
3. **Topic deflection — redirects rather than goes silent** (forward, Phase 4 — controlled delivery reveals the redirect IS the tell)
4. Leaves bar when Torek arrives (any, Phase 3 — the contradiction itself, cumulative)
---
## Knowledge Graph Mechanics
What the FRIEND arc requires from the knowledge system (D-041):
### Phase 1-2: baseline
```
player.KnowledgeGraph[friend_npc]:
confidence: KnowsOf # Knows who they are and their role
relationship: Friendly # Warm relationship established
state: Active
known_attributes.trust_level: trusted
```
### Phase 3: contradiction fires
```
# The contradiction event is observed
player.KnowledgeGraph.facts[investigation.{friend}_contradiction_event]:
confidence: Suspects # "Something's off"
state: Active
# If prior knowledge entry conflicts with observation:
player.KnowledgeGraph[friend_npc]:
state: Contradicted # Both entries get this — D-041 contradiction detection
# Triggers: ContradictionEvent → monologue system → relationship review
relationship: PersonOfInterest # Color shifts amber
```
### Phase 4: tell confirmed
```
# Tell confirmed in post-conversation monologue
player.KnowledgeGraph.facts[behavioral.{friend}_evasion]:
confidence: Direct # Set during conversation, Direct = "I just saw this"
state: Active
# Contradiction fact upgraded
player.KnowledgeGraph.facts[investigation.{friend}_contradiction_event]:
confidence: KnowsOf # Now more than suspicion — confirmed deliberate
```
### Phase 5: contaminated trust
```
player.KnowledgeGraph[friend_npc]:
confidence: KnowsDetails # You know they're hiding something specific
relationship: PersonOfInterest # Stays amber — cannot return to Friendly without deep trust event
state: Contradicted # Persists until resolution path completes
```
---
## Content Requirements Per FRIEND NPC
### Authored lines (no generation expansion)
| Content Type | Count | Notes |
|-------------|-------|-------|
| Dialogue — surface tier | 8-10 | Phase 1-5 casual lines, available at any trust |
| Dialogue — real tier | 6-8 | Phase 1-3 insider/operational content |
| Dialogue — secret tier | 3-5 | Phase 5 disclosure (if Understanding path taken) |
| Dialogue — confrontation options | 4-6 | Phase 4 initiation + FRIEND response variants |
| Monologue — about FRIEND (player char) | 12-15 | Full arc coverage; character partitioned (D-032) |
| Tell observation lines | 5-8 | One per tell, per observability tier |
| Contradiction discovery monologue | 3-4 | Fires when Phase 3 contradiction is observed |
| Post-contradiction monologue | 4-6 | Phase 5 filtered-through-doubt lines |
| Dual-lens notes (not in engine) | 1-2 per character | For authoring consistency |
**Total per FRIEND NPC: ~70-100 authored lines.** No generation expansion — every line hand-authored.
### Dual-lens authoring
The same FRIEND NPC is experienced differently by each playable character. Authors must write:
- Primary arc (bonded character): full emotional weight, personal tell knowledge, relationship history
- Secondary observation (other character): distant, filtered through their lens — the FRIEND is "just" a Commission worker / dock worker until patterns accumulate
**The dual-lens makes the FRIEND arc pay off on replay.** Playing the other character, you now understand Sera's avoidance or Kael's tension from a completely different angle.
---
## Checklist for New FRIEND NPCs
Use this checklist when creating a new FRIEND for a future playable character.
### Design requirements
- [ ] FRIEND motivation chosen: what drives the concealment? (Kael: protect partner, Sera: protect friend)
- [ ] Contradiction type chosen: spatial anomaly (Type A) or behavioral pattern (Type B)
- [ ] Third party identified: who the FRIEND is protecting, and why this creates the contradiction
- [ ] Sympathetic motivation: FRIEND has a reason the player can understand, even if they can't endorse it
- [ ] No clean resolution: the FRIEND arc ends in ambiguity, not revelation or catharsis
- [ ] Triangle integration: FRIEND participates in at least one active triangle (D-024)
- [ ] Dual-lens: secondary character observation notes written
### Tell requirements
- [ ] Three tells defined: peripheral + forward + direct
- [ ] Peripheral tell established in Phase 2 monologue (retroactive recognition)
- [ ] Direct tell fires during Phase 4 deflection (post-conversation monologue)
- [ ] Tell known by player character from prior context (friendship history or professional training)
### Phase requirements
- [ ] Phase 1 includes genuine emotional anchor (shared activity, personal reference, easy rapport)
- [ ] Phase 2 introduces the third party the FRIEND is protecting
- [ ] Phase 3 contradiction is physically/behaviorally observable (not dialogue-revealed)
- [ ] Phase 3 has required observation count: Type A = 1, Type B = 3
- [ ] Phase 4 deflection gives plausible answer that doesn't collapse the contradiction
- [ ] Phase 5 has three distinct resolution paths, none delivering clean closure
### Content requirements
- [ ] ~70-100 authored lines total (no generation expansion)
- [ ] Monologue pools hard-partitioned by character (D-032)
- [ ] Contradiction discovery monologue written (3-4 lines, fires on Phase 3 observation)
- [ ] Post-contradiction filtered-through-doubt monologue written (4-6 lines)
- [ ] All prerequisite fact IDs from knowledge-vocabulary-v01.md — no invented strings
### Systems integration
- [ ] FRIEND is in the knowledge graph at `KnowsOf + Friendly` from Phase 1
- [ ] Contradiction fact ID defined in knowledge-vocabulary-v01.md (e.g., `investigation.{friend}_contradiction_event`)
- [ ] Tell fact ID defined (e.g., `behavioral.{friend}_evasion`)
- [ ] Phase transitions map to relationship state changes (Friendly → PersonOfInterest)
- [ ] Resolution paths have distinct knowledge state outcomes
---
## Why This Pattern Works
**Single mechanical insight:** Asymmetric information creates emotional weight. The player has information (the observation) that the FRIEND doesn't know the player has. The FRIEND acts normally. The player watches through a new lens. That gap IS the dramatic irony.
**Systems framing:** The FRIEND arc is asymmetric information mechanics applied to trust. The player's knowledge graph and the FRIEND's knowledge graph are out of sync. The moment they sync — when the FRIEND realizes the player knows — is where the confrontation lives.
**Emergent replayability:** The dual-lens design means the FRIEND arc reads completely differently on a second playthrough as the other character. Sera's tells mean something different when you know what she's hiding. Kael's warmth is more painful when you know the exit attempt will fail. This is emergent story from system interaction, not scripted revelation.
---
*Design document. Reusable pattern template for all future FRIEND NPCs.*
*Derived from: Kael Davan (#297), Sera Venn (#298).*
*Full spec: D-034, D-039, D-041*
+296
View File
@@ -0,0 +1,296 @@
# Cultural Generation Guide — The Settled Reach
**Ticket:** #189
**Author:** Miri
**Status:** canonical
**Last updated:** 2026-02-19
---
## Purpose
This guide provides the framework for generating culturally consistent content in The Settled Reach. Every named district, NPC, and piece of authored dialogue reflects a specific cultural context. This guide codifies how to derive that context from first principles so that any content author — writing a dockworker or a station administrator, a bar greeting or a confrontation line — can produce work that feels like it belongs.
The Sova Transit District in the Krenn System is the first concrete instance of this framework. All examples in this guide are drawn from Sova. Subsequent settings use the same framework applied to different parameters.
---
## Part 1: The Framework — Five Dimensions of Cultural Context
Every location in The Settled Reach has cultural context along five dimensions. These are not rigid categories — they blend and interact. But knowing them for a setting before writing a single line of dialogue prevents inconsistency.
### Dimension 1: Reach Position
**Range:** Core → Mid-Reach → Frontier → Deep Frontier
This determines the age of institutions, the density of infrastructure, and the relationship between residents and authority.
| Position | Infrastructure | Institutional Presence | Resident Attitude to Authority | Setting Feel |
|---|---|---|---|---|
| **Core** | Mature, well-maintained | Heavy. Commission offices, Concord representatives, Syndic headquarters | Deferential-to-compliant. Authority is a daily reality. | Corporate. Managed. Politics visible. |
| **Mid-Reach** | Functional, mixed-era | Moderate. Commission terminals, periodic visits, field investigators | Pragmatic-to-skeptical. Authority exists but isn't everywhere. | Working. Lived-in. Rules enforced selectively. |
| **Frontier** | Sparse, improvised | Light. Syndic representatives, occasional Commission audit | Independent-to-resistant. Authority is a distant abstraction. | Improvised. Tight-knit. Self-reliant. |
| **Deep Frontier** | Minimal | Nominal or none | Sovereign. Authority is whatever the community enforces. | Harsh. Communal. Rules of necessity. |
**Sova Transit District:** Mid-Reach. Commission presence is intermittent — enough to maintain order, not enough to catch everything. The district's residents are pragmatic about authority: they comply when observed, continue as normal when not.
---
### Dimension 2: Economic Base
**Range:** Freight → Resource Extraction → Manufacturing → Service → Research → Mixed
Economic base determines daily rhythms, vocabulary, and the social pressures that create narrative stakes.
| Economic Base | Daily Rhythm | NPC Concerns | Social Pressure | Narrative Hook |
|---|---|---|---|---|
| **Freight** | Span gate schedules. Shift rotations. Cargo waves. | Manifests, container counts, shift assignments, dock schedules. | Wages stable but tight. Extra income tempting. | Manifest discrepancy. Smuggling access. |
| **Resource Extraction** | Mining shifts. Equipment maintenance cycles. | Equipment reliability, extraction quotas, safety protocols. | Dangerous work, irregular income, company-town dynamics. | Workplace accident cover-up. Union pressure. |
| **Manufacturing** | Production cycles. Quality control. | Production quotas, supply chains, quality flags. | Line work monotony, automation anxiety, middle-management tension. | Sabotage. Corporate espionage. |
| **Service** | Tourist/commercial traffic. | Tips, regulars, reputation management. | Income dependent on traffic volume. Reputation fragile. | Blackmail. Theft. Information sale. |
| **Research** | Project deadlines. Funding cycles. | Research progress, institutional politics, publication credit. | Competitive, status-driven, secrecy around results. | Data theft. Fabrication. Factions. |
**Sova Transit District:** Freight primary. The span gate transit schedule structures everything. NPC routines, the ring's operational calendar, and social patterns (when the bar fills, when corridors are quiet) all derive from freight rhythm.
---
### Dimension 3: Settlement Age and Character
**Range:** New Colony (< 20 years) → Established (20-80 years) → Mature (80-200 years) → Old (200+ years)
Settlement age determines architectural character, generational memory, and whether residents think of themselves as pioneers or locals.
| Age | Architecture | Cultural Identity | Generational Memory | Relationship to Origin |
|---|---|---|---|---|
| **New Colony** | Prefab, temporary-feeling. Little personalization. | Identity still forming. People compare everything to where they came from. | Recent. First-generation. Stories of the journey. | Strong nostalgia for origin system. |
| **Established** | Prefab base showing modification. Layers visible. Some personalization, some institutional standardization. | Local identity forming but not solid. Mix of origin-cultures. | Mixed. Parents remember old places; children only know here. | Ambivalent. Here is home, but parents' memories linger. |
| **Mature** | Mixed construction eras clearly visible. Original structure heavily modified. Strong local character in older sections. | Distinct local identity. People think of themselves as "from here." | Distant. The origin is history, not memory. | Local pride. Skepticism of core-system assumptions. |
| **Old** | Accretive. Original construction may be invisible under layers. Idiosyncratic. | Deeply rooted identity. History is ambient. | Institutional. Stories are embedded in place-names and customs. | The origin system is foreign. "We are our own thing." |
**Sova Transit District:** Established-to-Mature. The district is ~40 years old on a station settled ~180 years ago. The district itself shows layered construction — prefab base, retrofitted sections, personalized details. Local identity is present but not deep. Workers think of themselves as Sova people, not Krenn people; but they don't have the rootedness of a settlement in its third or fourth generation.
---
### Dimension 4: Cultural Reference Point
The cultural reference point is the system of origin for the majority population, combined with how many generations of local drift have occurred.
This determines:
- **Naming conventions** — how names sound, what they reference, whether family names matter
- **Social norms** — eye contact, titles, how trust is established
- **Relationship to technology** — whether lattice use is stigmatized, normalized, or a source of pride
- **Attitudes toward the Commission** — reverence, compliance, resentment, or active evasion
**Krenn System Cultural Profile (established, working-class mid-Reach):**
- **Names:** Compact, consonant-heavy, first-name-primary in social contexts. Family names exist but are used formally (official documents, institutional contexts, introductions to strangers). Nicknames common among colleagues. Off-system names (longer vowels, different consonant clusters) stand out subtly but aren't cause for suspicion — just quietly noted.
- **Social norms:**
- Eye contact is normal during conversation, brief during passing. Sustained eye contact from a stranger reads as challenge or authority.
- Physical contact minimal — a hand on the shoulder is significant. A handshake is formal.
- Complaining is fine. Complaining to the person you're complaining about is not.
- Mind your business is the unspoken rule. People don't ask intrusive questions; they don't volunteer information they haven't been asked for. This makes the detective's investigation harder and the smuggler's operation safer.
- Trust is established through repeated small interactions over time, not through declarations.
- **Technology attitudes:** Lattice use is unreflective — ubiquitous, taken for granted, the way breathing is taken for granted. Checking your lattice overlay in conversation is rude but common. The Unbound (those without lattices) are a known category — treated with mild curiosity and zero hostility. Nobody makes a fuss about it. Augmented lattice (institutional or premium) carries subtle status markers — people notice, don't comment.
- **Commission attitude:** Pragmatic compliance. The Commission regulates neural lattice technology; that regulation affects everyone, constantly. Workers find it mildly onerous (recertification requirements, software update mandates, firmware flags on aftermarket accessories). The Commission isn't hated — it's just the reality of living in the Reach. Like weather.
---
### Dimension 5: Institutional Ecosystem
Every location has a set of institutions that structure daily life. Understanding which institutions are present, which are visible, and which are resented tells you how NPCs talk about authority, rules, and power.
**Core institutions across the Reach:**
| Institution | Role | Local Manifestation |
|---|---|---|
| **Concord Assembly** | Reach-wide legislature | Background. Distant. Appears in news tickers. Not visible on the ground. |
| **Lattice Commission** | Neural tech regulation | Kiosks, certification terminals, field investigators. The most visible institutional presence in most districts. |
| **Syndics** | Commercial entities, freight and resource management | The actual employers of most working-class NPCs. Contracts, shipping manifests, wage structures. |
| **Meridian** | Information network | Ambient. The infrastructure everyone uses without thinking about. Dead spots are notable. |
| **Station Administration** | Local governance | Immediate authority. Maintenance schedules, district regulations, housing allocation. |
| **Medical Services** | Re-embodiment and healthcare | Presence varies by wealth. Clinics in working districts, full services in affluent ones. |
**Sova Transit District institutional profile:**
- Syndic logistics consortium: the main employer, highly visible in daily operations
- Station Administration: present through maintenance schedules, posted notices, the occasional administrator in the corridors
- Lattice Commission: intermittent. A kiosk in the logistics hub lobby, field investigators who visit quarterly (usually)
- Medical clinic: small, basic. Re-embodiment services require transfer to the main station medical center
- Meridian: strong coverage in institutional spaces (hub, bar), degraded in older sections, minimal in maintenance corridors
---
## Part 2: Derived Guidelines — From Framework to Content
### 2.1 Naming Conventions
Naming conventions follow from Dimension 4 (Cultural Reference Point). The table below captures the Krenn System standard, which serves as the v0.1 model.
**Krenn System naming rules:**
- **First names:** 1-2 syllables, consonant-forward, no double vowels in typical form. Examples: Kael, Voss, Drin, Lera, Torek, Maret, Naia, Sera, Nils, Kosse, Pael, Tev, Ren, Resha, Harek, Sess, Devra.
- **Family names:** 1-2 syllables, similar phoneme pattern. Examples: Davan, Lintar, Sessik, Korr.
- **Social usage:** First name in most contexts. "Supervisor Voss" for institutional address. Full name only in formal or hostile contexts.
- **Nicknaming:** Common among colleagues. A name like "Renn" might be short for something nobody uses.
- **Off-system names:** Slightly softer vowels, longer syllable count, different consonant clusters. Example: Olin, Sabel. These stand out quietly — the detective's lattice may flag them as non-Krenn-origin before the detective consciously registers why.
**IP check:** Krenn names are constructed to feel plausibly human-future without mapping to any existing franchise. They are NOT Hamilton Commonwealth names (conspicuously contemporary English, which is Hamilton's signature device). They are NOT Banks Culture names (often whimsical or elaborate). They are NOT Firefly or Star Wars (no Earth-ethnic-name echoes). They suggest cultural drift across 180 years of mid-Reach settlement.
---
### 2.2 Economic Vocabulary
Economic base (Dimension 2) determines the vocabulary of daily life. Workers in a freight hub speak differently from workers in a mining operation or a research station. This vocabulary should permeate every NPC's dialogue — not as jargon dumps, but as the ambient language of their daily reality.
**Sova Transit District — Freight Economic Vocabulary:**
See companion document: **Sova Texture Appendix (#302)** for the full slang glossary.
Core vocabulary patterns for freight:
- Work = "shift" (not "rotation," not "duty," not "tour")
- The gate = "the gate" casually, "the span gate terminal" institutionally — never "the span gate" in casual speech
- Cargo containers = "containers" or "cans" (informal) — never "pods"
- Cargo manifests = "manifests" (precise), "paperwork" (dismissive), "the file" (when being evasive)
- Credits = "marks" in casual conversation (a Krenn System local usage)
- Off-shift time = "off-book" among the ring; just "after" for civilians ("see you after")
- Lattice communication = "pinging" (sending), "a ping" (notification)
---
### 2.3 Social Rhythms and Norms
The freight rhythm (span gate schedules, shift rotations) structures social life. Content authors should use these rhythms as temporal anchors for dialogue and monologue.
**Sova Transit social rhythm:**
| Time | Activity | Social Atmosphere |
|---|---|---|
| 0600-0800 | Morning shift change | High traffic, business-focused, brief greetings |
| 0800-1200 | Morning freight cycle | Focused work, minimal social interaction |
| 1200-1400 | Midday break | Break room social time, bar has light lunch trade |
| 1400-1600 | Afternoon freight cycle | Quieter, post-lunch pace |
| 1600-1800 | Evening shift change | Second major transition, people heading to or from |
| 1800-2200 | Peak bar hours | The Last Shift fills up. Main social time of the day. |
| 2200-0200 | Late night | Bar quiet (late regulars only). Corridors quieter. Ring operations typical timing. |
| 0200-0600 | Deep night | Minimal staffing. Maintenance windows. Skeleton shift. |
---
### 2.4 Relationship to Authority
Dimension 3 (Reach Position) determines how NPCs speak about, around, and to authority figures. This affects dialogue at every trust tier.
**Sova Transit — Authority Relationship Patterns:**
| NPC Type | To Commission directly | About Commission (behind back) | To institutional-tagged stranger |
|---|---|---|---|
| Flat NPC (civilian) | Cooperative, neutral. Answers questions. Doesn't volunteer. | "They do their job. Just don't want them doing *my* job." | Polite. Slightly more careful with word choice. Watches for cues. |
| Triangle NPC (mundane) | Cooperative. Slightly more nervous if personally concerned. | "They'll always find something to flag if they look hard enough." | Normal, with an undercurrent of evaluation: are they relevant to me? |
| Entangled NPC (ring) | Carefully cooperative. Prepared answers. Nothing extra. | "Commission pokes around twice a year and finds nothing because they don't know where to look." | Alert. Measures words. Changes topics smoothly. |
---
### 2.5 Technology Integration
Lattice use is unreflective for most NPCs — it's background, like breathing. Content should treat lattice references the way people treat phone references: incidentally, in passing, without ceremony.
**Correct treatment:**
> "I pinged you twice. Did you sleep through your lattice?"
**Wrong treatment (over-technological):**
> "I sent a message through the neural lattice communication network to your personal lattice implant."
**Wrong treatment (under-technological):**
> "I tried to call you." *(This isn't the Reach — people don't "call." They "ping.")*
The exception: NPCs under stress or in unusual circumstances may reference their lattice more explicitly — "I checked three times, it's not in the manifest" — where the precision itself signals anxiety.
---
## Part 3: Scaling Beyond v0.1
The Krenn System instance demonstrates the framework. Future settings apply the same five dimensions with different parameters. The cultural output — naming conventions, vocabulary, social norms, authority attitudes — follows from the parameters, not from free-form invention.
**Quick-generation checklist for a new district:**
1. **Reach Position:** Core / Mid-Reach / Frontier / Deep Frontier
→ Determines institutional density and authority attitude
2. **Economic Base:** Freight / Extraction / Manufacturing / Service / Research / Mixed
→ Determines daily rhythm and vocabulary domain
3. **Settlement Age:** New / Established / Mature / Old
→ Determines architectural character and cultural rootedness
4. **Cultural Reference Point:** Which system of origin? How many generations of local drift?
→ Determines naming conventions and social norms
5. **Institutional Ecosystem:** Which institutions are present, how visibly?
→ Determines how NPCs relate to authority
From these five inputs, the following outputs are derivable for any new setting:
- Naming conventions (with example names)
- Economic vocabulary (10-15 terms)
- Social rhythm (daily time anchors)
- Authority relationship patterns (flat/triangle/entangled × authority figure)
- Technology integration register (how casually lattice is referenced)
**When a new setting is created:** Write a cultural profile following this guide and add it to `docs/wiki/cultural-groups/`. Tag it with the relevant star system. Link from the district entry in the wiki.
---
## Part 4: The Dual Lens Principle — Culture Serves Asymmetry
Every cultural detail in The Settled Reach exists to serve asymmetric information. Culture is not decoration — it's the medium through which two characters experience the same world differently.
**What this means in practice:**
The same cultural detail should mean something different to the smuggler and the detective:
| Cultural Detail | Smuggler Reads It As | Detective Reads It As |
|---|---|---|
| "Mind your business" norm | Operational cover. Nobody asks, nobody knows. | Investigative friction. People won't volunteer. |
| First-name social culture | Social camouflage. Insider by default. | Access challenge. Have to earn first names. |
| Lattice pings as communication | Ring coordination. Off-Meridian pings are clean. | Evidence trail. Pings log to Meridian (mostly). |
| Bar as social heart | Safe social space. Home territory. | Intelligence gathering venue. |
| Commission presence intermittent | The gap in oversight is the operational window. | The jurisdictional gap is also my investigative constraint. |
| Span gate schedule as daily rhythm | The operational calendar. Timing is everything. | Investigation timeline. Window when oversight is thinnest. |
**Authoring instruction:** When writing any cultural detail — a sign, a greeting, a social norm — ask: what does the smuggler see, and what does the detective see? If the answer is identical, the detail is decorative. If the answers differ, the detail is load-bearing.
---
## Appendix: v0.1 Named Characters — Krenn System Consistency Check
All named characters in the Sova Transit District, with naming convention validation:
| Name | Convention Check | Role | Notes |
|---|---|---|---|
| Kael Davan | Krenn standard | Dock worker, ring member, smuggler's FRIEND | Full name used in formal contexts |
| Sera Venn | Krenn standard | Commission field tech, detective's FRIEND | "Venn" slightly softer than typical Krenn surname — deliberate: she's institutional, slightly apart |
| Lera Sessik | Krenn standard | Bar owner, The Last Shift | "Lera's" informal name. Never Lera Sessik to regulars. |
| Torek Lintar | Krenn standard | Dock inspector, compromised | Full name used when the detective runs his file |
| Voss | Krenn standard | Logistics supervisor | Single name in social use. Probably Voss-something officially. |
| Drin | Krenn standard | Maintenance tech, flat NPC | Single name. Been here long enough nobody uses a surname. |
| Maret Korr | Krenn standard | Freight scheduler, ring-adjacent | "Korr" is direct Krenn standard |
| Naia | Krenn standard | Kael's partner (mentioned, not primary) | Brief, soft — contrast to Kael |
| Nils | Krenn standard | Bar regular (mentioned) | |
| Kosse | Krenn standard | Station name / character reference | |
| Pael | Krenn standard | Maintenance tech, flat NPC (Sprint 12: #307) | |
| Tev | Krenn standard | Lookout/runner, flat NPC (Sprint 12: #307) | |
| Ren | Krenn standard | Drifter, flat NPC (Sprint 12: #307) | "Ren" is earlier-established; "Renn" is the courier variant from Round 2 mapping |
**Off-system characters (deliberate naming deviation):**
| Name | Deviation | Role | Purpose of Deviation |
|---|---|---|---|
| Resha | Softer vowel pattern | New hire | Implies off-system origin — explains why they ask too many questions |
| Sabel | Different phoneme cluster | Ring fence/buyer, external contact | External contact should sound external |
**IP check:** All Krenn names validated against Hamilton (Contemporary English — cleared), Banks (whimsical/elaborate — cleared), Reynolds (Dutch/Germanic — cleared), Simmons (Greek/Latin register — cleared), Morgan (Anglo-American register — cleared). No franchise overlap found.
---
*This guide is a living document. As new settings are established, update the scaling examples. As cultural decisions are refined through gameplay testing, update the social norm descriptions. The framework is the constant; the instances are the evidence.*
*Miri — Sprint 12*
+162
View File
@@ -0,0 +1,162 @@
# Detective: Institutional Chain of Command
**Ticket:** #322 | **Priority:** High | **Sprint:** 12
**Status:** v0.1 Draft
**Owners:** Paula (narrative arc), Miri (setting/institutional context)
**Decisions referenced:** D-005, D-027, D-037, D-041
---
## 1. Purpose
The detective is not a freelance investigator. They are a Commission officer, which means someone assigned them here, someone defined their mandate, someone is waiting for reports, and someone may have an interest in how this investigation concludes — not all of them aligned.
The institutional chain of command is not just background. It determines:
- **What the detective is officially permitted to do** (arrest authority, warrant scope, search powers)
- **What they're NOT supposed to do** (exceed mandate, go outside channels, use extra-institutional leverage)
- **Who benefits if the investigation succeeds** — and who doesn't
- **The political weight of the previous liaison's rotation** — which may not have been routine
This document establishes the detective's institutional world. It does not need to be fully visible to the player in v0.1 — it lives in the detective's case file, in their monologue, in what institutional NPCs say when authority-tier access opens. But it must be consistent.
---
## 2. The Commission — Brief Institutional Context
The Commission (Intersolar Commission) is the primary regulatory body governing freight transit, customs enforcement, and neural lattice certification across the Settled Reach. It is not a police force. It operates through:
- **Compliance review** — routine audits of freight hubs, manifest reconciliation
- **Liaison officers** — semi-permanent postings to transit hubs; advisory and investigative authority
- **Field technicians** — technical support, equipment maintenance, passive monitoring (Sera Venn's role)
- **Enforcement officers** — arrest authority; called in from sector headquarters for formal investigations
The detective's posting as "Commission Liaison Officer" sits between compliance and enforcement. They have investigative authority — enough to request manifests, interview persons of interest, and flag suspects — but formal arrest and search warrant authority requires escalation to sector headquarters.
**This matters for the detective's v0.1 situation:** They can investigate. They cannot arrest without authorization from above. If they find evidence of a smuggling ring, they need to report it up the chain before taking action — and that creates a window of political exposure.
---
## 3. The Formal Chain
### Level 1: The Detective (On-Station)
**Title:** Commission Liaison Officer, Sova Transit District
**Mandate (official):** Routine compliance review — manifest reconciliation, scheduling compliance, standard freight hub audit.
**Mandate (actual):** Investigate manifest irregularities suggesting organized smuggling. Identify structure, membership, and supply chain. Report to Sector Oversight with arrest recommendations.
**Reporting to:** Sector Liaison Coordinator, Commission Sector 7 Headquarters (off-station, a span-gate transit away)
### Level 2: Sector Liaison Coordinator — Renna Veth
**Title:** Liaison Coordinator, Commission Sector 7
**Who she is:** Career Commission officer. Mid-level administrator with 20 years of routine compliance work and two serious investigations to her name. Professional, risk-averse. Not corrupt — but also not courageous. Wants the investigation to produce results without embarrassing the Commission or producing a political fight she didn't expect.
**Her interest in this investigation:** Wants it resolved cleanly. A successful arrest closes a compliance gap in her sector metrics. A failed investigation — especially one that implicates a Commission insider — would be a problem.
**Relationship with detective:** Assigned the detective to Sova because the detective has a clean record and a reputation for methodical work. Does not know the detective personally.
### Level 3: Sector Deputy Commissioner — Halden Pres
**Title:** Deputy Commissioner, Sector 7
**Who he is:** Political appointee. Has been in position for 6 years. Sector 7 covers the Krenn System transit hubs, which are economically significant — freight logistics for Velen's surface operations. Not personally corrupt. But his political relationships include figures who have interests in how freight regulation is managed in the sector.
**His knowledge of this investigation:** Was briefed at level of "routine compliance review at Sova." Does not know the actual investigative mandate. If the investigation escalates — if arrests are made, if Commission involvement in a smuggling ring is implied — he will be briefed, and his political relationships will activate.
**The political risk:** Deputy Commissioner Pres is not an enemy. But if the investigation implies that a previous Commission liaison was arranged to protect a smuggling ring, Pres may have indirect connections to whoever arranged it. He is not the villain. But he is a constraint.
---
## 4. The Previous Liaison: Korrean Tsev
**Title (former):** Commission Liaison Officer, Sova Transit District
**Rotation:** Posted to Sova for 18 months; rotated out 3 months before the detective arrived.
**Official reason for rotation:** Standard posting cycle. Liaison officers rotate every 18-24 months to prevent over-familiarity with local operations.
**The detective's case file note:** The previous liaison, Korrean Tsev, was in position during the period when manifest discrepancies were already detectable in the freight data. No irregularity reports were filed. Three compliance audits during Tsev's tenure all came back clean. The anomalies that the detective's pattern analysis flagged from the data should have been visible to Tsev.
**Was the rotation arranged?**
The detective's case file flags this as unconfirmed but suspicious. The canonical answer (not available to the player in v0.1): **yes, it was arranged.** Tsev was not corrupt in the sense of taking ring money — but Tsev was compromised by personal loyalty. A relationship with someone at sector administration meant Tsev was quietly moved when the manifest anomalies became too persistent to ignore, rather than being required to file a report that would implicate people above Tsev's pay grade.
**What the detective can learn in v0.1:**
- That Tsev filed no reports (case file, accessible from session start)
- That the anomalies were detectable during Tsev's tenure (manifest data analysis, takes investigation time)
- That Tsev was rotated on a compressed timeline (standard was 24 months; actual was 18 months) — this is discoverable through institutional records if the detective requests them
**What the detective cannot learn in v0.1:**
- WHY the rotation was arranged
- Who in sector administration was involved
- Whether Deputy Commissioner Pres had knowledge
This is a thread that extends beyond the v0.1 vertical slice. The detective can pull the thread enough to see it exists, but not enough to unravel what's at the other end.
---
## 5. Torek Lintar: Commission Compliance Officer
Torek Lintar is Commission compliance on Sova — not an investigator, not a liaison officer, but a permanent-fixture compliance monitoring role. Commission compliance officers are lower in the formal hierarchy than liaison officers, but they have one thing liaison officers don't: **local knowledge accumulated over years.**
Torek has been on Sova for 4 years. He knows everyone. He knows the bar. He knows the shifts. He knows the social fabric in a way a three-day-old liaison officer cannot.
**The detective's starting relationship with Torek:** Professional known name from institutional channels. Not a friend. Encountered at the bar (Torek is a bar regular).
**What Torek knows that the detective doesn't:**
- The ring's social footprint, at least at the edges — conspicuous spending, familiar faces
- Sera Venn's unofficial compliance check (Torek suspects; he doesn't know for certain)
- The shape of the social web connecting Kael's group
**Why Torek hasn't reported what he knows:**
Torek's tolerance threshold for confrontation is low. He is ring-adjacent (D-024 triangle 3: Bar Tensions), which means he is socially embedded with people who are embedded with the ring. His conspicuous spending suggests he may have accepted something from ring-peripheral sources — not enough to be a member, but enough to compromise his ability to report cleanly. He is not a villain. He is a compromised observer who has talked himself into believing the right moment to report will come later.
**Torek as asset for the detective:**
If the detective builds enough trust (social + authority), Torek can become an information source — but carefully. The ring is aware of Torek's connection to Commission compliance. Torek going to the detective too visibly risks blowback on both of them.
**Torek as the signal in Sera's FRIEND arc:**
Sera avoids Torek. Three times. Three different excuses. The detective's monologue reads this as a behavioral pattern. The reason: Sera ran an unofficial check on Kael's work patterns (using Commission technical access she wasn't supposed to use) and found the same manifest discrepancies the detective found. Torek, as Commission compliance, would be the person Sera should have reported this to. Sera's avoidance of Torek is the tell that she found something and didn't report it. The detective doesn't know this yet — but the behavioral signal points there.
---
## 6. Political Pressures on the Investigation
The detective's investigation operates under several simultaneous pressures:
| Pressure | Source | Direction | Effect |
|----------|--------|-----------|--------|
| **Succeed cleanly** | Veth (Sector Coordinator) | Upward | Produce results without creating institutional embarrassment |
| **Don't exceed mandate** | Institutional | Inward | Formal arrests require escalation; the detective can't act unilaterally |
| **Previous liaison question** | Case file | Lateral | Pulling this thread risks finding Commission involvement; politically dangerous |
| **Sera's position** | Personal | Personal | Using what Sera knows against her would destroy the detective's only personal relationship in the district |
| **Community non-cooperation** | Social | Environmental | The district's default resistance to institutional authority; this is not malice, it's culture |
The detective is not just investigating a smuggling ring. They are investigating within an institutional structure that has already failed to catch this ring once. The question of whether that failure was incompetence or arrangement is the shadow behind every manifest discrepancy.
---
## 7. How This Appears in Monologue
The detective's institutional context should surface in monologue lines as internal processing — the analytical framing through which they filter every observation. Not institutional jargon, but institutional habit.
**Examples:**
At session start, observing the hub:
*"Third day. Tsev was here for eighteen months and filed nothing. Either I'm better at this than Tsev, or there's something in the water. Let's find out."*
When Voss deflects a question:
*"That answer was technically responsive. It gave me nothing. Voss knows exactly what I'm asking. Note it."*
When considering what Torek knows:
*"Lintar's been on Sova for four years. He's seen whatever there is to see. Commission compliance doesn't file reports in a vacuum — something told him to stay quiet."*
When the Sera contradiction emerges:
*"I could escalate this. Report what I suspect to Veth. But if I'm wrong about Sera, I've just burned my only ally on a hunch. And if I'm right — I need to know how far this goes before I put it in writing."*
---
## 8. v0.1 Content Scope
What needs to be authored for this to work:
| Element | Content Requirement |
|---------|---------------------|
| Detective opening monologue (lines about Tsev's missing reports) | 1-2 lines from session start knowledge |
| Case file items (accessible via evidence display) | 3-5 readable entries: Tsev rotation note, manifest cluster data, POI summaries |
| Torek dialogue (authority tier) | 3-5 lines for institutional context questions |
| Veth (off-station, not interactive) | Referenced in case file and detective monologue only |
| Detective monologue about political constraints | 2-3 lines scattered across session |
The chain of command does not require NPCs to appear — Veth and Pres are off-station and non-interactive in v0.1. Their presence is felt through the detective's case file entries and internal monologue about institutional constraints. Torek is the only chain-of-command-adjacent NPC who is interactive.
---
*Ticket #322 — Detective institutional chain of command. v0.1 scope.*
+231
View File
@@ -0,0 +1,231 @@
# Divergent Relationships
**Ticket:** #183 | **Priority:** Critical | **Sprint:** 12
**Status:** v0.1 Draft
**Blocked by:** #180 (Smuggler build), #181 (Detective build)
**Decisions referenced:** D-005, D-027, D-028 (four relational layers), D-029 (30/50/20), D-033 (entity color), D-075 (knowledge confidence gate on trust)
---
## 1. Design Principle
Same NPCs. Two different starting relationship webs. The smuggler is inside the social fabric of Sova Transit District. The detective is outside it, with institutional authority as their primary access tool.
This divergence is not symmetric. The smuggler has deep relationships with fewer NPCs but holds insider access from session start. The detective has shallow relationships with many NPCs but can use authority credentials to access people the smuggler cannot pressure. Neither coverage model is superior — they produce different investigations.
**The honest truth is:** the smuggler's social depth is their strength AND their vulnerability. When the ring starts to crack, their close relationships become sources of betrayal, not safety. The detective's institutional distance is protection AND limitation — they never get the warmth, but they also never get the blindspot.
---
## 2. NPC Relationship Comparison
### Key: RelationshipState | Access Tier | Trust | Entity Color (D-033)
| NPC | Smuggler | Detective |
|-----|---------|-----------|
| **Kael Davan** | Friendly / insider+peer / trusted / **green** | Unknown / public / N/A / **grey** |
| **Sera Venn** | Known / public / uncertain / **teal** | Friendly / peer / trusted / **green** |
| **Voss** | Known / insider+peer / reliable / **teal** | Known / authority / uncertain / **teal** |
| **Maret Korr** | Known / insider+peer / reliable / **teal** | Known / authority / uncertain / **teal** |
| **Drin Rosta** | Known / insider / reliable / **teal** | Known / authority / uncertain / **teal** |
| **Lera Sessik** | Friendly / insider+peer / trusted / **green** | Unknown/Known / public / uncertain / **grey→teal** |
| **Naia Tamm** | Known / peer / reliable / **teal** | Unknown / public / N/A / **grey** |
| **Torek Lintar** | Known / peer / uncertain / **amber** | Known / public / uncertain / **teal→amber** |
| **Nils Davan** | Known / insider / reliable / **teal** | Unknown / N/A / N/A / **grey** |
| **Renn** | Known / insider / reliable / **teal** | Unknown / N/A / N/A / **grey** |
| **Devra** | Known / insider / reliable / **teal** | Unknown / N/A / N/A / **grey** |
| **PC-Detective** | Unknown/Known / public / uncertain / **amber** (warned) | N/A — is the detective | N/A |
| **PC-Smuggler** | N/A — is the smuggler | Unknown / public / N/A / **grey** |
**Color key (D-033):**
- **Green** = Friendly relationship — warm, accessible, insider content possible
- **Teal** = Known/neutral — recognized, baseline access
- **Amber** = Person of Interest — heightened observation, potential threat
- **Grey** = Unknown — no existing relationship
---
## 3. The Inversion Architecture
The same NPCs appear in both relationship maps. But the maps are mirrors, not duplicates.
### Kael Davan (THE FRIEND): Maximum Divergence
| | Smuggler | Detective |
|-|---------|-----------|
| RelationshipState | Friendly | Unknown |
| Access Tier | insider + peer | public |
| Trust | 8/10 — "the day is better when he's on shift" | N/A — one of many dock workers |
| Entity color | Green | Grey |
| Dialogue available | Phase-gated ring talk, personal warmth, confession (if trust maxed) | Greeting, basic questions, deflection |
| Emotional weight | Maximum — 2 years of shared risk | Zero — no relationship to lose |
| FRIEND arc | Betrayal, protection, contaminated trust | Behavioral tells, manifest connection, informant potential |
This is the central architectural divergence. The smuggler's most important NPC is the detective's least known. When the smuggler feels Kael's betrayal, the detective is trying to figure out who Kael is.
### Sera Venn (THE FRIEND): Mirror of Kael
| | Smuggler | Detective |
|-|---------|-----------|
| RelationshipState | Known | Friendly |
| Access Tier | public | peer |
| Trust | 0/10 — "Commission, keep distance" | 9/10 — "she always was the reliable one" |
| Entity color | Teal | Green |
| Dialogue available | Basic public-tier; no personal content | District orientation, gossip, institutional sharing |
| Emotional weight | Near zero — categorized threat | Maximum — only ally in unfamiliar territory |
| FRIEND arc | N/A | Avoidance pattern, suppressed evidence, contaminated trust |
Same NPC. The smuggler maintains institutional distance. The detective relies on personal friendship. The contradiction (Sera sitting on evidence that could advance the investigation) hits differently: for the detective, it's a betrayal of trust. For the smuggler, if they were ever to learn it, it would be irrelevant — Sera was never trusted in the first place.
### Voss: Inverted Access, Same NPC
| | Smuggler | Detective |
|-|---------|-----------|
| RelationshipState | Known | Known |
| Access Tier | insider + peer | authority |
| Trust | "reliable but not warm" — operational respect | "uncertain — answers but doesn't volunteer" |
| Dialogue available | Ring operational coordination, schedule management | Institutional questions, manifest data, evasive answers |
| What Voss says to them | Operational specifics: "Transition window is clean." | Institutional surface: "Everything is in compliance." |
Both characters talk to Voss. The conversations are nothing alike. The smuggler gets operational truth. The detective gets institutional deflection.
---
## 4. Access Tier Divergence Table
Per-NPC table of which dialogue tiers each character can access at session start vs. through play:
| NPC | Smuggler Starting | Smuggler Max | Detective Starting | Detective Max |
|-----|-----------------|-------------|-------------------|--------------|
| Kael | insider + peer | insider + peer | public | authority (if POI) |
| Sera | public | public (no growth path) | peer | authority (post-contradiction) |
| Voss | insider + peer | hostile (if cover blown) | authority | peer (if trust earned) |
| Maret | insider + peer | hostile (if pressured wrong) | authority | peer (if cooperation) |
| Drin | insider | hostile (if flipped) | authority | — see note |
| Lera | insider + peer | hostile (social contamination) | public | peer (4+ visits) |
| Naia | peer | hostile (if truth revealed) | public | peer (through Sera) |
| Torek | peer | hostile (if confronted about spending) | public | authority (financial tells) |
**Note on Drin:** The detective can pressure Drin via gambling debt (institutional leverage). This is the detective's entry point to actual ring structure — Drin as turned informant. The smuggler knows Drin is compromised; they have no leverage angle because they're on the same side.
---
## 5. Social Site Coverage
How each character covers the three social sites of Sova Transit District:
### The Terminal (Logistics Hub)
**Smuggler:**
- Full insider access to ring-aware NPCs (Kael, Voss, Maret, Drin, Renn)
- Cargo floor access (shifts, freight systems, break room)
- Social belonging — people wave, greet, assume presence is normal
- Blind spots: doesn't see the detective's investigation angle; doesn't know Maret is worried about the manifest requests
**Detective:**
- Authority access to institutional NPCs (Voss, Maret, Drin)
- Data access (manifests, scheduling records, inspection logs)
- Visible outsider — NPCs stiffen, answers get shorter, body language shifts
- Blind spots: ring-operational dialogue is completely inaccessible; NPCs perform compliance without candor
**Coverage overlap:** Both characters are in the Terminal. Same physical space. The overlap zone is the ~20% of dialogue that's truly `public` — shift schedule announcements, generic greetings, environmental observations. Everything else is character-specific.
### The Last Shift (Bar)
**Smuggler:**
- Insider status — Lera saves their seat, ring-aware regulars speak freely
- Social hub for ring: Kael, Renn, Torek (if present), Lera all accessible
- Emotional territory: this is where the smuggler goes to decompress, not investigate
- Blind spots: doesn't see Sera's avoidance of Torek as anything other than "Commission, normal behavior"
**Detective:**
- Outsider becoming regular — needs 3-4 visits to unlock peer access with Lera
- Social anchor through Sera — Sera's presence grants partial access to bar social fabric
- Observation territory: this is where the detective watches, not relaxes
- Blind spots: ring-adjacent social dynamics (Torek's rounds, Lera's protectiveness) read as social texture, not evidence
**Coverage overlap:** Sera is the bridge. Both characters interact with Sera at the bar. Sera's dialogue with the smuggler (brief, guarded, Commission-wary) and with the detective (warm, orienting, personal) are completely different. An NPC the detective trusts most is the one the smuggler avoids most.
### The Maintenance Corridors
**Smuggler:**
- Full access (corridor token)
- Routes, surveillance gaps, timing windows all known
- The corridors are operationally familiar — movement here is intentional and professional
- THE FRIEND contradiction happens here: Corridor B-7, Kael's unauthorized meeting
**Detective:**
- No natural access — maintenance corridors are not institutional territory
- Presence here is suspicious (both to NPCs and mechanically)
- May access if security authority is invoked, but it triggers NPC awareness
- Can observe corridor access from entry points; cannot follow undetected
**Coverage gap:** The corridors are entirely the smuggler's territory. The detective's only route in involves institutional authority that announces itself. This makes the smuggling operation's most vulnerable moments physically inaccessible to the detective's normal investigative approach.
---
## 6. NPC Knowledge of Each Character
NPCs hold knowledge about both characters simultaneously (D-041 bidirectional). The same NPC has:
- EntityKnowledge about the smuggler (from the smuggler's perspective)
- EntityKnowledge about the detective (from the detective's perspective)
Key examples:
**Kael's knowledge of both characters:**
- Of the smuggler: Friendly / trusted / ring member / close colleague
- Of the detective: Known / suspicious / Commission / risk: moderate
**Lera's knowledge of both characters:**
- Of the smuggler: Friendly / trusted / regular customer / ring-aware
- Of the detective: Unknown-becoming-Known / uncertain / Commission presence
**Sera's knowledge of both characters:**
- Of the smuggler: Known / uncertain / "dock worker, keep minimal contact"
- Of the detective: Friendly / trusted / friend, institutional colleague
**Voss's knowledge of both characters:**
- Of the smuggler: Known / reliable / ring member
- Of the detective: Known / suspicious / risk: moderate / managing exposure
---
## 7. Relationship Shift Paths (First 30 Minutes)
How relationships shift during the vertical slice for each character:
### Smuggler Relationship Shifts
| NPC | Starting State | Likely Shift | Trigger |
|-----|---------------|-------------|---------|
| Kael | Friendly | → PersonOfInterest | Corridor B-7 observation (Phase 3) |
| All ring NPCs | Known/Friendly | → Hostile (conditional) | If smuggler cooperates with detective or cover blown |
| Naia | Known | → (no shift in v0.1) | No player action triggers this in 30 min |
| Sera | Known | → (no shift in v0.1) | Smuggler never gets close enough |
### Detective Relationship Shifts
| NPC | Starting State | Likely Shift | Trigger |
|-----|---------------|-------------|---------|
| Sera | Friendly | → PersonOfInterest | Torek avoidance pattern (Phase 3) |
| Kael | Unknown | → Known → PersonOfInterest | Behavioral tells + manifest discrepancy connection |
| Drin | Known | → Cooperative (conditional) | Gambling debt leverage applied |
| Lera | Unknown | → Known → peer (conditional) | 3-4 bar visits |
| Maret | Known | → peer (conditional) | Cooperative interview unlocks |
| Smuggler-NPC | Unknown | → Known → PersonOfInterest | Tell accumulation + cargo attention on 4471 |
---
## 8. Design Implications
**The most emotionally resonant NPCs are the ones each character DOESN'T know.**
The smuggler will encounter the detective investigating the people they love most. The detective will encounter the smuggler operating inside the community they're trying to understand. The tension comes from the player knowing what the NPC they're watching doesn't know.
**Relationship symmetry check:** For every NPC in one character's web, verify the other character's starting state. If the smuggler has a `Friendly` / insider relationship, the detective must start at `Unknown` or `Known` / public for there to be discovery room. If both characters start at `Friendly`, the NPC is a shared ally and loses narrative potential.
**The bar is the convergence zone.** Both characters are present at The Last Shift. The physical overlap creates the mirror moment opportunities (ticket #329). Same room, same NPCs, different access. The bar scenes are the most structurally dense content space in v0.1.
---
*Ticket #183 — Divergent relationships. v0.1 scope.*
+171
View File
@@ -0,0 +1,171 @@
# Divergent Starting Knowledge
**Ticket:** #182 | **Priority:** Critical | **Sprint:** 12
**Status:** v0.1 Draft
**Blocked by:** #180 (Smuggler build), #181 (Detective build)
**Decisions referenced:** D-005 (asymmetric information), D-027 (vertical slice), D-041 (knowledge graph), D-065 (evidence display)
---
## 1. Design Principle
Asymmetric information is the master mechanic (D-005). The two characters know different things about the same world. Neither has the full picture. The game's central tension comes from each player discovering what the other already knew — but only on their second playthrough.
The knowledge sets are not merely different. They are **inverted at the points that matter most.** The smuggler knows the conspiracy from inside it. The detective knows the investigation from outside it. The middle — the social web that connects them — is what neither fully understands at session start.
---
## 2. Master Knowledge Comparison Table
### 2a. Contraband and Ring
| Fact | Smuggler | Detective |
|------|---------|-----------|
| Ring exists (organized smuggling operation) | **KnowsEverything** — member | Unknown — suspects "systematic manipulation" but no structural knowledge |
| Ring members (Nils, Kael, Voss, Renn, Devra, Resha) | **KnowsDetails** — works with them daily | Unknown — names absent from case file; only Voss, Maret, Drin as institutional POIs |
| Cargo type (unlicensed lattice components, medical replacements) | **KnowsDetails** — handles the containers | Unknown — case file has weight/size anomalies but not cargo type |
| Smuggling routes (corridor access, timing windows) | **KnowsEverything** — runs the routes | Unknown — knows maintenance corridors exist from district layout documents |
| Operation schedule (shift transition windows) | **KnowsEverything** — the 20-minute gaps are the job | **KnowsDetails** — case file shows anomaly clustering around shift transitions |
| Supply chain upstream (Sabel, Syndic connection) | **KnowsOf** (partial: knows Sabel, not the full chain) | Unknown |
| Voss's complicity (takes a cut, provides scheduling cover) | **KnowsEverything** — operational fact | Unknown — Voss is a POI because of scheduling authority, not proven complicity |
| Drin's complicity (looks the other way on inspections) | **KnowsDetails** — operational asset | Unknown — Drin is a POI because of inspection failures |
| Maret's awareness (scheduling access to cover for ring) | **KnowsDetails** — hub colleague | **KnowsOf** — person of interest from case file |
### 2b. Investigation
| Fact | Smuggler | Detective |
|------|---------|-----------|
| Detective's identity as investigator | Unknown — may know "new Commission presence" from ring warning | **KnowsEverything** — is the investigator |
| Detective's actual mandate (investigate freight irregularities) | Unknown — "routine compliance review" is the cover | **KnowsEverything** |
| Manifest discrepancies exist and are flagged | **KnowsOf** — operational awareness that paperwork is being managed | **KnowsDetails** — has the discrepancy data, cluster analysis, container series |
| Previous Commission liaison rotation (possibly arranged) | Unknown | **KnowsOf** — noted in case file as suspicious; unconfirmed |
| Commission's investigation timeline | Unknown | **KnowsDetails** — their own timeline |
### 2c. Key People
| Fact | Smuggler | Detective |
|------|---------|-----------|
| Kael's exit attempt (unauthorized external contact) | **Unknown** (CRITICAL gap — basis for FRIEND arc) | Unknown |
| Sera's unreported evidence (suppressed compliance check on Kael) | Unknown | **Unknown** (CRITICAL gap — basis for FRIEND arc) |
| Naia confided in Sera about Kael | Unknown | Unknown |
| Naia's worry about Kael's behavior | **KnowsOf** — has noticed Naia seems stressed at bar; doesn't know why | Unknown — Naia is not yet a known individual |
| Torek's spending (conspicuous, potential security risk) | **KnowsOf** — has noticed; worries about exposure | **KnowsOf** — "slightly off in a way I can't yet articulate" |
| Sera's background (pre-existing contact for detective) | Unknown — knows Sera only as "Commission, keep distance" | **KnowsDetails** — trusted pre-existing friend |
| Smuggler's role in the ring | **KnowsEverything** — is a member | Unknown — one of many dock workers |
| Kael's relationship with Nils (brother; ring coordinator) | **KnowsDetails** | Unknown |
### 2d. Location and World
| Fact | Smuggler | Detective |
|------|---------|-----------|
| Sova Transit District geography (full) | **KnowsEverything** — 3 years here | **KnowsOf** — 3 days; still orienting |
| Corridor B-7 (restricted, ring access) | **KnowsEverything** — knows the access token, uses the corridor | Unknown — maintenance corridors noted in district layout |
| Dead drop location (temp storage bay 4, container 4471) | **KnowsEverything** | Unknown — container 4471 appears in manifest discrepancy data but not mapped to a location |
| Shift schedule rhythms (social and operational) | **KnowsEverything** | **KnowsOf** — knows shifts exist, pattern from case file |
| Bar social structure (who sits where, who's a regular) | **KnowsDetails** — 2 years of evenings at Lera's | **KnowsOf** — has visited twice |
---
## 3. Knowledge Asymmetry Map
The gaps in each character's knowledge are not random — they cluster around the same events and people, approached from opposite directions:
```
THE SHARED WORLD
┌─────────────────────────────────────┐
│ Triangle 4 │
│ Naia ←→ Kael ←→ Sera │
│ ↑ ↑ │
│ (Kael's (suppressed │
│ partner, evidence) │
│ knows ↕ │
│ something) Detective's │
│ ↕ FRIEND │
│ Smuggler's │
│ FRIEND │
└─────────────────────────────────────┘
Smuggler knows: Kael (trusted friend), ring details, routes, members
Smuggler doesn't know: Kael is exiting, Sera's evidence, Naia→Sera connection
Detective knows: Investigation data, Sera (trusted friend), manifest anomalies
Detective doesn't know: Ring structure, Kael's role, Sera's evidence, Naia→Sera connection
Neither knows: Triangle 4's full shape — the chain from Naia's worry to Sera's suppression to the investigation closing in
```
The dramatic irony of the second playthrough is that the player who's played as the smuggler watches the detective investigate from outside what the smuggler knows intimately. The player who's played as the detective watches the smuggler trust Kael — not knowing the thing the detective now knows (Kael is the link between the ring and Sera's evidence).
---
## 4. Knowledge Acquisition Curves
### Smuggler: What they can learn, and how
| Unknown → Known | Method | Timing |
|----------------|--------|--------|
| Detective's identity | Ring warning (Kael/Voss mentions Commission presence) OR direct observation | Minutes 0-5 (if warned); later if not |
| Kael's exit attempt | THE FRIEND arc — spatial observation (Corridor B-7), then confrontation | Minutes 15-25 |
| Sera has unreported evidence | Cannot discover this through normal play. Would require Sera to confess or Naia to reveal — extreme late-game territory beyond v0.1 scope | Post-v0.1 |
| Torek's specific risk level | Observation of Torek's behavior over time | Minutes 10-20 |
### Detective: What they can learn, and how
| Unknown → Known | Method | Timing |
|----------------|--------|--------|
| Ring exists (structured) | Evidence accumulation — NPC behavioral tells, manifest patterns, corridor observation | Minutes 15-25 |
| Kael is ring member | Connecting manifest discrepancies to Kael's shift data; cargo attention tell | Minutes 20-30 |
| Sera's unreported evidence | THE FRIEND arc — behavioral pattern observation (Torek avoidance), then confrontation | Minutes 20-30 |
| Smuggler is in the ring | Tell accumulation (corridor check, social clustering with Kael/Renn), manifest connection | Minutes 15-30 |
| Kael's exit attempt | Would require Kael to confess or Naia to reveal — beyond normal investigation path | Post-confrontation branch |
---
## 5. Evidence Display Difference
Same underlying Knowledge Graph, different presentation layer (D-065):
**Detective — Case File Format:**
```
Entity: Kael Davan
Role: Dock worker, Sova Terminal
Confidence: KnowsOf
Source: observation
Attributes:
- routine_pattern: "morning shift 06:00-14:00"
- behavior_flags: [lattice_checking, shortened_interactions]
- risk_assessment: "moderate — behavioral anomalies suggest concealment"
- link: [manifest_discrepancy_cluster_A]
Insert suggests: "Check shift records against anomaly window"
```
**Smuggler — Personal Notebook Format:**
```
Kael — closes colleague, ring. Two years. Trust: solid.
Lately: distracted. Lunch cut short twice this week.
Something's off. Not the operation — personal.
Watch for: the look-left tell.
```
Same underlying entity. Different framing. The detective files evidence. The smuggler worries about people.
---
## 6. Design Implications for Content Authors
**Rule 1: Don't explain what the character already knows.**
The smuggler doesn't need exposition about how the ring works. They know. Monologue lines about the ring should be operational, not explanatory. The detective doesn't need exposition about institutional procedures. They know. Monologue lines about the investigation should be observational, not procedural.
**Rule 2: The unknown facts are load-bearing.**
When writing monologue lines, verify against the unknown_facts list. A smuggler monologue line that references Kael's exit attempt before Phase 3 is a continuity error. A detective monologue line that identifies Kael as a ring member before connecting manifest data to shift records is a continuity error.
**Rule 3: Knowledge gaps produce monologue, not silence.**
What a character doesn't know generates JUST AS MUCH monologue content as what they know. The smuggler's worry about "is Torek a risk?" is monologue material. The detective's uncertainty about "previous liaison rotation — arranged or routine?" is monologue material. The unknown is active, not empty.
**Rule 4: The same event produces inverted monologue.**
When the same NPC does the same thing in both playthroughs, the monologue responses should be detectably from different starting states. Kael checking his lattice → smuggler thinks "not like him to be jumpy" (personal familiarity) → detective thinks "lattice activity spiked, behavioral flag" (observational analysis).
---
*Ticket #182 — Divergent starting knowledge. v0.1 scope.*
+220
View File
@@ -0,0 +1,220 @@
# Entity Color System — Visual Specification
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #304
**Status:** Active — input to client implementation (future sprint), constrains #318 (THE FRIEND visual treatment)
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§3), Decision D-033
---
## 1. Core Principle
Entity color encodes the **player character's subjective relationship** to an NPC — not an objective property of the NPC. The same entity can appear as different colors to the detective and the smuggler simultaneously. Color is a **client-side derivation** from the `RelationshipState` field on each `VisibleEntity` in the server's `ObserverSnapshot`. The server never says "this NPC is hostile" — it sends the relationship state the client derives the color from.
This is asymmetric information rendered visually. It is the most important single system in the game to get right because it is the visual language through which the player reads the world.
---
## 2. Relationship → Color Mapping
### 2.1 Canonical Palette
| RelationshipState | Color name | Hex | Visual quality | Notes |
|-------------------|-----------|-----|----------------|-------|
| `Unknown` | Cool teal | `#4a9ebb` | Default for unassessed entities. Cooler than sky, not clinical. | Every new NPC the player hasn't built a view of |
| `Known` / `Friendly` | Soft green | `#6bc9a6` | Trusted. Known person. Not safe — just trusted. | People the character knows and has reason to trust |
| `PersonOfInterest` | Warm amber | `#e8c547` | Monologue or case file has flagged something. | Not necessarily hostile — flagged |
| `Hostile` | Muted red | `#d45d5d` | Player character perceives subjective danger. Not omniscient. | **Red means danger TO YOUR CHARACTER**, not danger in the abstract |
### 2.2 Non-Entity Colors
| Entity type | Color name | Hex | Notes |
|-------------|-----------|-----|-------|
| Static objects | Muted grey | `#8b8ba0` | See §5 for what counts as static |
| Player — Detective | Cool blue-white | `#e0e8ff` | Near-neutral. "Self." No relationship loading. |
| Player — Smuggler | Warm cream | `#e8e0d0` | Near-neutral. "Self." Slightly warmer than detective. |
### 2.3 Color Psychology Notes
The palette was selected for functional warmth (D-043), not conventional danger-coding. Teal (`#4a9ebb`) reads as neutral-curious rather than cold. Green (`#6bc9a6`) reads as familiar rather than "good." Amber (`#e8c547`) reads as noteworthy rather than "warning." Red (`#d45d5d`) is muted — subjective danger, not objective alarm.
This matters because the detective might see amber where the smuggler sees green for the same NPC. Both are correct. Neither color is lying — they are rendering different epistemic positions.
---
## 3. Runtime Derivation
### 3.1 Data Source
The server sends each `VisibleEntity` in the `ObserverSnapshot` with a `relationship: RelationshipState` field (`server/src/bridge/types.rs`, `VisibleEntity` struct, line 258). This field is computed per-observer on the server using the observer's knowledge graph — it is NOT a shared NPC property.
`RelationshipState` variants:
- `Unknown` — no assessment, or newly visible
- `Known` — character has a relationship but nothing flagged
- `PersonOfInterest` — knowledge graph or case file has flagged this entity
- `Hostile` — character perceives active danger from this entity
### 3.2 Client Lookup
`entity_renderer.gd` maps `RelationshipState` to color via `Constants.color_for_entity_kind()`. The current implementation (`_color_for_kind`, line 181) delegates to this function. The full color lookup should follow:
```
RelationshipState → Color
Unknown → #4a9ebb
Known / Friendly → #6bc9a6
PersonOfInterest → #e8c547
Hostile → #d45d5d
Static object → #8b8ba0
Player entity → #e0e8ff (detective) or #e8e0d0 (smuggler)
```
The player's own entity is identified via `GameState.player_entity_id`. Player color does not participate in the relationship lookup — it is a fixed constant per character selection.
---
## 4. Transition Behavior
### 4.1 Standard Transition
When an entity's `RelationshipState` changes between snapshots, the color shift is a **0.5-second smooth fade** (linear lerp). This is already implemented in `entity_renderer.gd` via the `_entity_tweens` dictionary and `COLOR_FADE_DURATION = 0.5` constant.
**Never use an instant color swap.** The visual transition is part of the information delivery — the player reads the relationship changing as a small dramatic moment.
### 4.2 THE FRIEND's First Shift — Staged Priority
THE FRIEND's transition from `Known/Friendly` (green `#6bc9a6`) to `PersonOfInterest` (amber `#e8c547`) must be the **first relationship color change** the player observes in the session. The opening 2025 minutes of gameplay must be staged so no other NPC's relationship state changes before THE FRIEND's shift.
This requires narrative coordination: the player must have enough time with THE FRIEND as green to internalize what green means. When amber arrives, the player has a reference point. Without that contrast, the color change means nothing.
See `docs/design/the-friend-visual-treatment.md` (#318) for staging details.
### 4.3 Edge Cases
**Entity in fog (unrecognized):** D-033 colors do **not** show through fog for unrecognized entities. An unrecognized entity in fog renders as a neutral grey `#555566` blob with no silhouette features. Once the cognitive delay resolves (D-060, 0.6s base) and recognition completes, the blob transitions to the entity's D-033 color + identifying silhouette feature.
**Entity in fog (recognized):** If the character recognizes an entity in fog (their knowledge graph identifies them), the insert overlay (z-layer 6) can show a D-033 color glow + faint silhouette feature at ±0.5 tile approximate position. This is insert data, not visual data — the character knows where they were, not where they are.
**Entity at periphery:** Per D-015 (forward/peripheral/blind vision sectors), entities in the peripheral vision zone are rendered at reduced saturation and alpha. The `modulate.a` value is `Constants.PERIPHERAL_ALPHA` when `visibility == "Peripheral"`. This is already implemented in `entity_renderer.gd`. The D-033 color itself does not change — saturation reduction is handled via the visibility dimming, not a color override.
**Simultaneous transitions:** Two entities changing relationship state in the same tick each get independent 0.5s tweens. There is no synchronization between entity transitions. This is intentional.
---
## 5. Static Objects — Definition and Color
### 5.1 What is a Static Object
Static objects are non-person entities that do not have a relationship to the player and are not part of the NPC simulation. They always render at `#8b8ba0` regardless of any game state.
**Static objects (always `#8b8ba0`):**
- Furniture: chairs, tables, desks, counters, crates
- Fixtures: terminals, consoles, lockers, shelving
- Environmental: doors (in closed state), hatches, panels
- Props: datapads, mugs, comms units, cargo containers
**Not static objects (use D-033 colors):**
- All NPCs including Background-tier
- Player characters
- Carried items (if items become carriable entities, they follow their carrier's relationship color — this is a future sprint consideration, default to static color in v0.1)
### 5.2 Why Grey, Not Zone Palette
Static objects use `#8b8ba0` rather than blending into zone floor/wall colors because they need to be visually distinct from the environment while remaining subordinate to entity colors. Grey sits between structure (very low saturation zone palettes) and entities (moderate-to-high saturation D-033 colors) in the saturation hierarchy.
**Saturation hierarchy (D-044):**
| Tier | Saturation range | Examples |
|------|-----------------|---------|
| Entity (D-033) | 4060% | `#4a9ebb`, `#6bc9a6`, `#e8c547`, `#d45d5d` |
| Objects (static + D-052 favorites) | 1030% | `#8b8ba0`, dusty blue, warm terracotta |
| Structure (zone palette) | 515% | Zone floor tiles, wall faces |
Never add an object color that approaches entity saturation levels.
---
## 6. Color Blindness Assessment
### 6.1 Palette Under Common Deficiencies
The D-033 palette (`#4a9ebb` teal / `#6bc9a6` green / `#e8c547` amber / `#d45d5d` red) relies on hue differentiation as its primary signal. This creates accessibility challenges.
**Deuteranopia (green-blind, ~5% of males):**
The green (`#6bc9a6`) and teal (`#4a9ebb`) may be difficult to distinguish. Both occupy the blue-green range. Under deuteranopia simulation, they compress toward a similar cool-blue appearance. The amber (`#e8c547`) and red (`#d45d5d`) differentiate better — amber reads as yellow, red reads as brownish-orange.
**Protanopia (red-blind, ~1% of males):**
The red (`#d45d5d`) shifts toward olive/brown. More concerning: the red and green (`#6bc9a6`) may become difficult to distinguish. The amber (`#e8c547`) remains clearly distinct.
**Tritanopia (blue-blind, rare):**
The teal (`#4a9ebb`) shifts toward green, potentially conflating Unknown and Known. Less critical as tritanopia is uncommon.
### 6.2 Risk Assessment
**High risk:** Deuteranopia — teal/green confusion. A deuteranopic player may not clearly distinguish Unknown from Known/Friendly NPCs.
**Moderate risk:** Protanopia — red/green confusion. A protanopic player may not clearly distinguish Hostile from Known/Friendly.
### 6.3 Mitigation Recommendation
The current palette does not include a secondary differentiation signal beyond hue. For v0.1 (functional placeholder stage with colored rectangles), this is acceptable — the game ships with boxes, not full art, and accessibility features are milestone work.
**Recommended for v0.1.2+:** Add a shape or pattern secondary signal to entity sprites — border dash pattern or icon badge — that persists independently of hue. E.g., Hostile gets a diamond border, PersonOfInterest gets a cross-hatch border, Unknown gets no border treatment. This would not require changing the D-033 colors (which have been visually designed and approved) but would layer a non-color signal on top.
**Flag:** This is a known gap to be addressed before the game exits early access. Tracked as future accessibility ticket (not yet created — flag during Sprint 15 planning).
---
## 7. Insert Overlay Interaction (D-048)
In the insert overlay (z-layer 6), entity D-033 colors gain **soft halos**: 23px gaussian blur at ~40% blend. This is the insert's interpretation of the relationship data — rendered with organic neural texture.
In the natural vision layer (z-layer 3), entity colors are rendered with **2px outline in the relationship color** — hard edge, no bloom. This is the player's naked visual perception.
The difference: the insert annotates, the eye sees. Both use the same color, different rendering treatment.
When `insert_active == false`, the bloom halos disappear. The underlying hard-edge entity color remains (the character still sees the NPC). Only the insert's annotation layer suppresses.
---
## 8. Implementation Notes for Stig
The core lookup table is already partially implemented. The complete client-side mapping lives in `client/scripts/constants.gd` in the `color_for_entity_kind()` function. The entity renderer at `client/scripts/rendering/entity_renderer.gd` already:
- Tracks relationship state per entity in `_entity_relationships`
- Detects state changes and starts 0.5s color tweens
- Handles peripheral visibility dimming independently
**Outstanding for future implementation sprint:**
1. Ensure `Constants.color_for_entity_kind()` uses all five states above (including the player entity case keyed to `GameState.player_entity_id` and `lattice_profile`).
2. Fog-entity rendering (recognized vs unrecognized) is handled by the fog system, not `entity_renderer.gd`. The fog renderer queries the knowledge graph for recognition state.
3. Peripheral saturation reduction: currently implemented as alpha reduction (`modulate.a`). This is sufficient for v0.1. True saturation reduction (keeping brightness, reducing colorfulness) would require a shader and is deferred.
---
## Appendix A — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-033 | Source of truth for relationship state → color mapping. Hex values in this spec are canonical per D-033 approval. |
| D-043 | "Functional warmth" art direction. Palette tone and production principle. |
| D-044 | Visual hierarchy (entity > object > structure). Saturation rules. |
| D-048 | Neural insert overlay — bloom treatment for D-033 colors on z-layer 6. |
| D-049 | Z-level rendering stack. Entities at z-layer 3. Insert at z-layer 6. |
| D-059 | Fog shader — recognized vs unrecognized entity treatment in fog. |
| D-060 | Cognitive delay — controls when grey fog blob transitions to D-033 color. |
## Appendix B — Quick Reference for Stig
| State | Hex | Duration of transition |
|-------|-----|----------------------|
| Unknown | `#4a9ebb` | 0.5s fade from prior color |
| Known/Friendly | `#6bc9a6` | 0.5s fade from prior color |
| PersonOfInterest | `#e8c547` | 0.5s fade from prior color |
| Hostile | `#d45d5d` | 0.5s fade from prior color |
| Static object | `#8b8ba0` | Fixed — no transitions |
| Player (detective) | `#e0e8ff` | Fixed — not subject to relationship |
| Player (smuggler) | `#e8e0d0` | Fixed — not subject to relationship |
| Fog blob (unrecognized) | `#555566` | Transitions to D-033 over 0.3s during 0.6s cognitive delay (D-060) |
| Peripheral entities | Any D-033 color at reduced alpha | No separate color |
+252
View File
@@ -0,0 +1,252 @@
# Environmental Text Visual Standards
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #334
**Status:** Active — constrains world-layer text implementation and copy authoring
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§5), [Text Display Hierarchy](text-display-hierarchy.md) (#316), Decisions D-036, D-049, D-043
---
## 1. What Environmental Text Is (and Isn't)
Environmental text is text that exists in the physical world of Sova Transit District — on walls, above terminals, scrolling across displays. It is **not HUD**, not dialogue, not monologue. It is part of the same world layer that contains crates, chairs, and NPCs.
The key distinction: if a sign would survive the neural insert being switched off, it is environmental text. If it requires the insert to display, it belongs in the HUD layers.
In practice, environmental text renders in the world layer (z-layers 24) and is subject to overhead occlusion rules. It is NOT exempt from the fog shader — if a sign is in an unexplored area, the player cannot read it.
All environmental text renders in **Michroma** — the same typeface as all other game text. The fiction is that all text in the world is mediated through the character's insert perception layer. There is no "world handwriting" typeface separate from the insert-layer font.
---
## 2. Signage
### 2.1 Character Limits
| Sign type | Max characters | Notes |
|-----------|---------------|-------|
| Zone identifier (primary) | 20 chars | Facility name, zone designation |
| Zone identifier (secondary) | 30 chars | Sublevel, sector, unit designation |
| Directional/wayfinding | 15 chars | Single-line, all-caps convention |
| Safety/notice | 40 chars | Two-line max, 20 chars per line |
| Personal/informal (posted notice) | 60 chars | More casual, can be three lines |
**Hard limit rationale:** Signs at 64px tile width render at 11px Michroma. At this size, ~1012 characters fit per 64px. Signs wider than ~3 tiles (192px) become disproportionately large relative to the world. Character limits enforce realistic sign-making.
### 2.2 Visual Treatment
| Property | Value | Notes |
|----------|-------|-------|
| Font | Michroma Regular 400 | No exceptions |
| Font size | 11px | At 1080p base |
| Z-layer | 2 (wall surface) or 4 (overhead) | Depends on mounting position |
| Primary color | `#8899aa` | Concordat Standard signage |
| Informal color | `#9aa890` | Krenn vernacular or personal notices |
| Opacity | 70% for formal, 65% for informal | World layer — subordinate to entities |
| Case | ALL CAPS for formal zone signage | Standard for institutional text |
| Case | Mixed case for informal and vernacular | More human, less bureaucratic |
### 2.3 Mounting Position
**Wall-mounted (z-layer 2):** Signage on vertical wall surfaces renders as part of the wall face. Y-sorted with the wall, not with entities. The sign appears at approximately 1.52 visual tile height (upper third of wall face in the tilt view).
**Overhead (z-layer 4):** Suspended signs, hanging banners, and ceiling-mounted displays render in the overhead layer. These are subject to the semi-transparent occlusion rule (70% opacity when above entities/player). An NPC walking under a suspended sign becomes partially visible through the sign's opacity.
**Floor-mounted (z-layer 01):** Painted floor markings, directional arrows, hazard lines. These render under everything and are walked over. No occlusion consideration.
### 2.4 Bilingual Sign Treatment
When a sign has both Concordat Standard and Krenn vernacular text:
- Concordat Standard: primary size (11px), 70% opacity
- Krenn vernacular: secondary size (9px), 50% opacity, rendered below the primary line
- Both in Michroma. The size + opacity difference signals language register without a separate "handwriting" font.
- 2px vertical gap between language lines.
Only signs explicitly serving both audiences carry both languages. Official Commission signage is Concordat Standard only. The Last Shift menu and informal worker notices are Krenn vernacular only.
---
## 3. Terminals
### 3.1 Two-State Model
Terminals exist in two visual states depending on player proximity and interaction.
**State 1 — Ambient (player at range, not interacting):**
The terminal sprite displays a short ambient identifier: equipment type + designation code. This is always visible within LOS, no interaction required. It is the equivalent of a label on a machine — something a worker in this district would glance at to orient themselves.
| Property | Value |
|----------|-------|
| Content | 12 words + code (e.g., "MANIFEST — TK-07") |
| Font | 11px Michroma |
| Color | `#8899aa` |
| Opacity | 60% |
| Z-layer | 4 (overhead, above terminal sprite) |
| Visibility range | Within LOS, up to ~4 visual tiles (~256px at 64px/tile) |
**State 2 — Active (player adjacent, Observe/Interact triggered):**
When the player triggers Observe on a terminal, the full content becomes readable. This is the only time environmental text content transitions — the ambient identifier expands to full text.
| Property | Value |
|----------|-------|
| Content | Full terminal display — up to ~12 lines |
| Font | 13px Michroma |
| Color | `#a8b8c8` |
| Opacity | 85% |
| Z-layer | 4 |
| Max width | 200px floating block |
| Text wrapping | Word-wrap within 200px |
| Dismissal | Player moves away (WASD) |
**Transition between states:** No animation. The ambient identifier is replaced by the active block when the Observe verb fires. The opacity increase (60% → 85%) and size increase (11px → 13px) signal the mode change without a sliding panel.
### 3.2 Terminal Content Format
Terminal text is formatted as structured data — this is a computer display, not prose.
```
[HEADER LINE IN ALL CAPS]
Field: Value
Field: Value
---
Additional section
```
The Michroma font at 13px with `#a8b8c8` at 85% renders this structure clearly. Colon-separated fields mimic insert data formatting and reinforce the diegetic conceit.
**Character limits per line:** ~25 characters at 200px max width, 13px Michroma. Content authors should target 20 characters per line for comfortable reading.
### 3.3 Terminal Icon (Insert Layer)
When the player's insert is active and a terminal is within interaction range (~2 visual tiles), the insert may overlay a small geometric icon on the terminal — a 4px cross or bracket indicating "this is interactive." This is insert data (z-layer 6), not environmental text (z-layer 4). It renders regardless of whether the player is looking at the terminal.
The insert icon uses the standard insert chrome color (`#c8d0e0` at 80% opacity). If the terminal has flagged data (e.g., manifest discrepancy), the icon shifts to amber `#e8c547`.
---
## 4. News Tickers
### 4.1 Overview
News tickers are ambient scrolling text elements attached to specific display fixtures in the scene. They are world-layer content — not HUD overlays. The player must physically approach and observe them.
News tickers serve two functions:
1. **World texture:** Makes the station feel like a living, connected place (Commission bulletins, interstellar news)
2. **Incidental information:** Occasionally carries plot-relevant data the player can discover by paying attention
The player is never required to read a news ticker to progress. When a ticker carries relevant information, the character's monologue will comment on it (via `observe_anomaly` or `enter_location` trigger) — the player doesn't have to stare at a scrolling display.
### 4.2 Visual Treatment
| Property | Value |
|----------|-------|
| Z-layer | 4 |
| Font | 10px Michroma |
| Color | `#8899aa` |
| Opacity | 55% |
| Language | Concordat Standard |
| Scroll direction | Right-to-left |
| Scroll speed | 30px/second |
| Loop | Yes — repeating |
| Pause on proximity | Yes — when player within 2 visual tiles, scroll pauses |
**Why 55% opacity:** Tickers are the most ambient environmental text element. They must not draw attention during active gameplay. A player engaged with NPCs should not be distracted by scrolling text in their peripheral vision. 55% opacity makes them effectively invisible until the player deliberately turns toward them.
**Pause on proximity:** When the player stands within 2 visual tiles of a ticker display, the scroll pauses to allow reading. This rewards deliberate attention without requiring the player to chase scrolling text.
### 4.3 Content Length
News ticker content is authored in segments:
- **Segment:** A complete sentence or news item, max 80 characters
- **Gap:** A `————` separator between segments (34 em-dashes, full width)
- **Loop depth:** 35 segments per ticker. More segments means longer before repeating — less metagameable.
Segments scroll sequentially separated by the gap. The player who watches a full loop has seen all content for that ticker.
### 4.4 Fixture Attachment
Each news ticker is attached to a specific fixture sprite (terminal, display panel, wall screen). The ticker text renders in a `Rect` overlaid on the upper portion of the fixture sprite. The fixture artist marks the text bounds when designing the fixture.
For v0.1 (colored rectangle stage), the ticker is a floating text element positioned above the fixture's placeholder rectangle. When sprite art arrives, the artist provides the correct text bounds.
---
## 5. Rendering Rules Summary
### 5.1 Z-Layer Assignment
| Text sub-type | Z-layer | Occlusion? | Fog-affected? |
|---------------|---------|------------|---------------|
| Floor signage | 01 | No (under entities) | Yes |
| Wall signage | 2 | Via y-sort | Yes |
| Terminal ambient ID | 4 | Semi-transparent occlusion | Yes |
| Terminal active display | 4 | Semi-transparent occlusion | Yes |
| News ticker | 4 | Semi-transparent occlusion | Yes |
| Insert terminal icon | 6 | No (insert layer) | No |
**Fog-affected:** Yes means the text is invisible in unexplored areas. The player cannot read a sign they haven't walked past. This is correct and intentional.
### 5.2 Entity Occlusion Rule
Environmental text on z-layer 4 (overhead) renders at **70% opacity maximum** — the same rule as all overhead layer content. An entity moving below an overhead sign remains partially visible through the sign. This is the "local information gap within a known space" design (D-049 §4.4).
Environmental text on z-layer 2 (wall surface) is y-sorted with entities. If an entity is standing in front of a wall sign, the sign is occluded by the entity. This is correct — the entity is always the primary visual element (D-044).
### 5.3 Text Never Occludes Entities
Entities are always visually dominant (D-044: entity > object > structure). Environmental text never achieves enough opacity to obscure entity colors. If a layout choice places text where it would overlap an entity, the text loses — reduce opacity or reposition. The entity's D-033 color must remain readable.
---
## 6. Language by Context — Decision Matrix
| Location | Formal element | Informal element |
|----------|---------------|-----------------|
| The Terminal (logistics hub) | Concordat Standard | Krenn vernacular where workers have added personal notices |
| The Last Shift (bar) | Concordat Standard (Commission notices, if any) | Krenn vernacular (menu, worker notices, personal signs) |
| Maintenance corridors | Concordat Standard (safety markings) | Krenn vernacular (informal worker notes) |
| Cargo containers | Concordat Standard (manifests, labels) | None |
| Personal effects area | Neither — no formal signage | Krenn vernacular if labeled |
**Decision rule:** Formal = the institution did it. Informal = a person did it. The institution writes in Concordat Standard. People write in Krenn vernacular. When you're not sure, ask who made the sign.
---
## Appendix A — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-036 | Sova Transit District / Krenn System. Concordat Standard vs Krenn vernacular framework. |
| D-043 | "Functional warmth" art direction. Environmental text is world texture, not decoration. |
| D-044 | Visual hierarchy. Environmental text is subordinate to entities at all times. |
| D-049 | Z-level rendering stack. Layer assignments for all text types. |
| D-051 | Settling is placement. Environmental text density reflects the district's history. |
| D-066 | Dual-scale grid. 64px tiles → ~1012 characters per tile at 11px Michroma. |
## Appendix B — Quick Reference for Copy Team
| Format | Max chars/line | Lines | Language |
|--------|---------------|-------|----------|
| Zone signage (primary) | 20 | 1 | Concordat Standard |
| Zone signage (secondary) | 30 | 1 | Concordat Standard |
| Directional | 15 | 1, ALL CAPS | Concordat Standard |
| Safety notice | 20 | 2 | Concordat Standard |
| Informal posted notice | 20 | 3 | Krenn vernacular |
| Terminal active (per line) | 25 | Up to 12 | Either, per context |
| News ticker (per segment) | 80 | 1 | Concordat Standard |
## Appendix C — Quick Reference for Stig
| Sub-type | Z-layer | Font | Size | Hex | Opacity |
|----------|---------|------|------|-----|---------|
| Formal signage | 2 or 4 | Michroma | 11px | `#8899aa` | 70% |
| Informal signage | 2 or 4 | Michroma | 11px | `#9aa890` | 65% |
| Terminal ambient | 4 | Michroma | 11px | `#8899aa` | 60% |
| Terminal active | 4 | Michroma | 13px | `#a8b8c8` | 85% |
| News ticker | 4 | Michroma | 10px | `#8899aa` | 55% |
| Insert terminal icon (active) | 6 | — | 4px glyph | `#c8d0e0` | 80% |
| Insert terminal icon (flagged) | 6 | — | 4px glyph | `#e8c547` | 80% |
+296
View File
@@ -0,0 +1,296 @@
# First 5 Minutes Experience Design
**Ticket:** #259
**Owner:** Gestalt (systems framework)
**Unblocks:** #260 (opening hook content per character)
**Source:** D-027 (vertical slice success criteria), D-032 (monologue pools), D-041 (background knowledge)
**Date:** 2026-02-19
---
## Design Constraints
### What this IS
A systemic opening sequence using only existing game systems. No cutscene. No special tutorial mode. No force-scripted path. The game runs at tick 1 — fog active, NPCs on schedule, world simulating — and the player experiences a designed version of that.
"Designed" means: the storyteller has selected a seed-specific opening state that makes the first 5 minutes feel motivated, not random. The player has clear first actions without being told to do them.
### What this is NOT
- A cutscene explaining the premise
- An objective marker pointing the player somewhere
- A tutorial UI teaching them how to move
- A scripted conversation they must complete
- A guaranteed identical sequence on every run
### D-027 success criteria — first 5 minutes contribution
The first 5 minutes must lay the foundation for all four success criteria:
1. **30 minutes of breathing room** — opening must feel like daily life, not like a conspiracy. The hook should be subtle enough that the player isn't in investigation mode yet.
2. **Different games** — smuggler and detective must feel like different people in different situations from tick 1.
3. **Named NPC** — the FRIEND relationship must be established in the first 5 minutes, before any contamination.
4. **Emerge from systems** — the first "something is wrong" must be observable, not spoken.
---
## Systems That Fire in the First 5 Minutes
### Tick 0: World state initialized
At game start, before the player's first input:
- Knowledge graph loaded with Background facts (per Appendix A, `docs/design/knowledge-flags-v01.md`)
- NPCs on their Phase 1 schedules (D-034 friend arc: baseline, no deviations)
- Fog fully opaque — the player's vision cone starts revealing the world from their spawn position
- Ambient audio playing (span gate hum, station background)
### First input: Movement
First LOS reveal is the first conscious experience. The world expands around the player's movement. This is simultaneously:
- Tutorial for the fog system (learned by doing, not told)
- Spatial introduction to the opening location
- First monologue opportunity: `enter_location` trigger fires on first tile in a new zone
**Design requirement:** The spawn location must have something interesting in LOS range without moving. The player should be able to see at least one NPC or meaningful location element from their starting position.
### First monologue: enter_location trigger
The insert chime plays (UI sounds bus, D-068). First internal monologue line fires from the `enter_location` trigger pool for this location.
**This line does three things:**
1. Establishes character voice (who are you?)
2. Establishes relationship to the space (do you belong here?)
3. Plants the first seed of the day's texture (operational awareness or investigative alertness)
The player doesn't need to understand the monologue system — they just hear a thought that sounds like them.
### First NPC: observe_npc trigger
Within the first minute of play, a significant NPC should enter the player's LOS. This is designed into the opening state: the FRIEND is present or arrives during this window.
The observe_npc trigger fires an appropriate opening-register monologue line. For the FRIEND specifically:
- Smuggler → sees Kael: warmth, ease, "the day is better when he's here"
- Detective → sees Sera (if bar opening) or a stranger (if Terminal opening): recognition vs. observation
**The FRIEND must be established in the first 5 minutes.** This is the anchor for success criterion #3.
### First operational beat
Within 2-3 minutes, the player encounters something to DO — not a quest, but a situation that pulls them toward action.
- **Smuggler:** Kael is at dock. Lunch window approaching. Shift board has today's assignments. Checking in = social interaction + operational context.
- **Detective:** Sera's usual booth. Or a manifest terminal with flagged entry. Or Voss giving the new arrival a territorial once-over.
The first action should be something the character would naturally do. Not "find the smuggling ring" — "say hello to Kael" or "get a read on the district."
---
## Character Opening Designs
### Smuggler Opening
**Core emotional register:** Operational confidence. This is your place. You know these people, this space, this rhythm. Something small is slightly off — but you won't notice it for a few minutes.
**Starting location:** The Terminal (Logistics Hub), dock floor or break room area.
**Background knowledge active at game start:**
- Full ring routing knowledge (`knows_details: knowledge.ring_routing_knowledge`)
- Kael is a ring member and close friend (`knows_details + friendly`)
- Today's operational context (whether a drop is scheduled or it's a routine day)
**First 5 minutes beat sequence:**
| Beat | Trigger | System | Content Purpose |
|------|---------|--------|-----------------|
| 1 | Enter Terminal | `enter_location` | Voice + tone established. "Home sweet home" register. |
| 2 | See Kael (if on shift) | `observe_npc` | FRIEND Phase 1 warmth. "The day's better when he's on shift." |
| 3 | See shift board / loading arms | `enter_location` / `observe_anomaly` | Operational awareness. World is working. |
| 4 | First Phase 2 signal (optional, seed-dependent) | `observe_anomaly` or time | Kael checks lattice. Brief distracted pause. Too subtle to read. |
| 5 | First interaction opportunity | WASD to Kael, dialogue opens | Natural: greet your friend. Establishes the relationship interactively. |
**Hook:** The day feels normal. That's the hook. You're inside something, comfortable. The player doesn't know yet that they're going to want to understand what Kael is hiding.
---
### Detective Opening
**Core emotional register:** Arriving into someone else's established rhythm. You are the outsider here. The district has its own momentum and you have to find your place in it.
**Starting location:** Variable by seed — see opening variant table below.
**Background knowledge active at game start:**
- Manifest discrepancy flagged (`suspects: investigation.manifest_discrepancy`)
- Commission jurisdiction understood (`knows_details: world.commission_sova_jurisdiction`)
- Corridor B-7 flagged as restricted zone (`knows_of: location.corridor_b7_restricted`)
- Sera Venn as pre-existing contact (if bar-first variant)
**First 5 minutes beat sequence:**
| Beat | Trigger | System | Content Purpose |
|------|---------|--------|-----------------|
| 1 | Arrive in district / enter first location | `enter_location` | Clinical register. "Sova Transit. Working-class pragmatic. Start reading the room." |
| 2 | See first NPC (Voss or dock worker) | `observe_npc` | Institutional observation. Territorial reception or neutral professionalism. |
| 3 | See Sera (if bar variant) or manifest terminal (if Terminal variant) | `observe_npc` or `observe_anomaly` | Either: warmth of familiar contact OR analytic flagging of first anomaly |
| 4 | First lattice query opportunity | `observe_anomaly` near manifest data | Detective's analytical lattice flags a discrepancy. Knows this behavior. |
| 5 | First adversarial NPC | Voss or institutional resistance | First social friction. The district doesn't want you here. |
**Hook:** The detective is watching. Every detail potentially matters. The detective never relaxes into the environment the way the smuggler does — but they notice things the smuggler misses. The player feels competent and slightly outside.
**First NPC is adversarial by default** (per sprint brief). Even in the warm-arrival Sera variant, the first dock-floor NPC (Voss) reads the detective as an institutional intrusion and responds territorially. The detective has authority; using it has social costs.
---
## Seed-Selected Opening Variants
The storyteller selects from 3-4 opening states per character at game start. This creates replay variation without scripted divergence — the systems remain the same, the initial conditions differ.
### Smuggler variants (3-4 opening states)
| Variant | Initial Condition | First Difference Felt |
|---------|------------------|----------------------|
| **A: Quiet morning** | Kael on shift, no deviations, routine operational day | Day feels completely normal. The crack doesn't appear until 10+ minutes. |
| **B: Kael is late** | Kael hasn't arrived at expected time — first behavioral anomaly is his absence, not his presence | Player starts without the FRIEND anchor. Absence itself is the first tell. |
| **C: Heat from Voss** | Shift supervisor is monitoring the dock more than usual — scheduling tension | Operational caution from the start. The ring feels more exposed. |
| **D: Container held** | An operational container is in temp storage longer than routing protocol specifies | First anomaly is logistical, not relational. Triggers operational investigation before social. |
**Storyteller selection logic:** The storyteller picks based on seed-configured narrative pressure. Quiet morning on low-pressure seeds; Kael late or container held on medium-pressure; all three tensions concurrent on high-pressure opens.
### Detective variants (3-4 opening states)
| Variant | Initial Condition | First Difference Felt |
|---------|------------------|----------------------|
| **A: Bar first (warm arrival)** | Sera has been contacted ahead of time — detective starts at The Last Shift | First contact is friendly. District access comes through Sera's social network. |
| **B: Terminal first (cold arrival)** | No pre-contact — detective enters through the dock floor | First NPC is territorial (Voss). Institutional authority is the only access mechanism. |
| **C: Evidence in hand** | Arrives with two manifest discrepancies already flagged from prior analysis | Opens in investigation mode. First observations are filtered through existing suspicion. |
| **D: Institutional escort** | Commission liaison accompanies detective briefly — creates visible institutional friction with locals | Authority is visible and creates social walls immediately. Bar regulars see the badge first. |
**Storyteller selection logic:** Bar-first on low-pressure seeds (softest introduction, most narrative space). Evidence-in-hand on high-pressure seeds (player is already in investigation mode, mystery accelerates).
---
## The "Not A Tutorial" Rule
The first 5 minutes teach the systems through play, not instruction.
| System | How It's Taught |
|--------|----------------|
| Fog / LOS | Player moves; fog reveals. No explanation needed. |
| Monologue | Chime plays + character speaks. Player hears their internal voice immediately. No UI callout. |
| NPC observation | Observe an NPC; monologue responds. Causal connection is felt, not explained. |
| Knowledge graph | Relationship color appears on FRIEND (green). Player learns to read colors before understanding what they mean. |
| Dialogue | First dialogue is low-stakes — greeting a colleague. Player learns the system exists before they need to use it strategically. |
| Insert / map | Player opens insert naturally (looking for objectives). Insert contains only what their character would have on it. |
**No tutorial pop-ups.** Monologue handles all context. If the player does something and a monologue line fires about it, that IS the tutorial.
---
## Opening Hook Content Brief (for #299/#300)
This section briefs Mellanie and Paula on what the opening hook monologue lines need to accomplish.
### Smuggler opening hook (feeds #299)
**Lines needed:**
- 3-5 `enter_location` (Terminal) lines — operational warmth, different registers
- 2-3 `observe_npc` (Kael, Phase 1) lines — genuine affection, colleague register
- 2-3 `observe_npc` (Voss) lines — professional caution, working relationship
- 1-2 `observe_npc` (dock floor general) lines — belonging, routine
- 2-3 variant lines for non-standard opens (Kael absent, container held)
**Voice requirements:** Short sentences. Operational. Comfortable. The smuggler is home.
**Knowledge prereqs available:** All Background facts active from tick 0. No investigation prereqs needed for opening lines. `ring_routing_knowledge` at `knows_details` — lines can reference operational reality.
### Detective opening hook (feeds #300)
**Lines needed:**
- 3-5 `enter_location` (Terminal or bar) lines — observational register, arriving perspective
- 2-3 `observe_npc` (Sera, if bar variant) lines — recognition + warmth + slight wistfulness
- 2-3 `observe_npc` (Voss or unknown dock worker) lines — clinical assessment, outsider reading the room
- 2-3 `observe_anomaly` lines — lattice firing on first anomalies
- 2-3 variant lines for cold-arrival opens (terminal first, no Sera contact)
**Voice requirements:** Complete sentences. Precise. Observational. The detective is reading, not inhabiting.
**Knowledge prereqs available:** `suspects: investigation.manifest_discrepancy` active from tick 0. Lines can reference the anomaly as existing suspicion. `knows_details: world.commission_sova_jurisdiction` — institutional confidence is in voice from the start.
---
## Success Metric
The first 5 minutes are successful if:
1. The player has not been told what to do, but knows what to do
2. The player has met the FRIEND before anything suspicious has happened
3. The player has experienced at least one monologue line that felt like it came from inside them
4. The player is curious about something — the district, a person, the operational texture — without being told to be curious
5. The two characters feel like different people in different situations, not the same player in two costumes
---
## Narrative Texture: Character Voice in the First 5 Minutes
*Added by Paula — character voice and emotional arc guidance for opening line authoring.*
### The Smuggler: What the First 5 Minutes Must Establish
The smuggler's first 5 minutes are not about tension. They are about **belonging.** The player needs to feel, before anything goes wrong, that this place and these people are worth protecting. The crack cannot land unless there is something to crack.
**What "belonging" looks like in monologue:**
The first monologue lines should make the player feel they have been in this place for years. Not "here I am at the cargo terminal" but "home sweet home." Not "Kael Davan, my colleague" but "Kael's already at the dock. Good." The compression is the character. The first-name intimacy is the belonging. The operational awareness is comfort, not anxiety.
The smuggler's opening voice should feel like muscle memory. The characters observes their world the way you observe a room you've lived in for three years — not looking, knowing. Kael's position on the floor. The temperature of the recycled air. The ambient frequency that means the span gate cycled recently. All of this is ordinary. None of it needs to be explained. The character doesn't explain their home to themselves.
**The emotional trajectory of the first 5 minutes:**
Minutes 0-2: Comfort settling in. *"This is mine. I know where everything is."*
Minutes 2-4: Operational layer activates. *"The transition window opens in fifty minutes. Nothing complicated today."*
Minutes 4-5: The first hairline crack (seed-dependent). Not alarming. *"Kael seems distracted. Probably nothing."*
The crack should feel like a whisper, not an alarm. The player may not even notice it. That's correct. They're supposed to look back at it later and think: *"It was right there from the beginning."*
---
### The Detective: What the First 5 Minutes Must Establish
The detective's first 5 minutes are not about confidence. They are about **adjustment.** The player needs to feel, from the first second, that they are slightly outside the rhythm of this place — and that they are professional enough to work with that, but human enough to feel it.
**What "outside" looks like in monologue:**
The first monologue lines should carry a controlled observational distance. Not hostility toward the district — the detective isn't hostile, they're analytical. But the distance is real. *"Third day on station."* That's the key phrase. Not "day three of the investigation." That's too procedural. *"Third day on station"* is how a person counts the time in an unfamiliar place. It's still counting.
The detective's opening voice should feel like reading a room for the first time and doing it professionally. They are good at this. They notice things. But they're noticing them as data points, not as feelings. The feelings come later — when Sera appears and the register softens. That contrast is the emotional architecture of the detective's whole arc: professional distance punctured by personal warmth punctured by professional suspicion. The first 5 minutes should establish the distance so the warmth lands when Sera arrives.
**The emotional trajectory of the first 5 minutes:**
Minutes 0-2: Professional orientation. *"This is the scene. Let me read it."*
Minutes 2-4: Slight isolation crystallizes. *"The briefing said routine. The briefing is always wrong about something."*
Minutes 4-5: Warmth (if Sera variant) or first friction (if Terminal variant). Either: *"Good to know Sera's here."* Or: *"That supervisor has decided I'm a problem. Filing it."*
The detective's emotional starting point is competent isolation. They are good at their job and slightly alone. The player should feel both.
---
### Voice Rules for Opening Hook Lines
**Smuggler — rules for the author:**
1. No sentences longer than 10 words unless it's an operational calculation
2. Fragment sentences are not a mistake — they are the voice
3. First-name all NPCs the character knows. Never "dock worker Davan" — "Kael"
4. Emotional states are physical: "my stomach settled," "something loosened," not "I felt relieved"
5. The world is sensory: recycled air, cargo lubricant, span gate frequency. Not visual abstractions.
6. No self-analysis: the smuggler doesn't think about why they feel things, they think about what to do next
**Detective — rules for the author:**
1. Complete sentences, but not long. One clause per thought, mostly.
2. Analytical habit is always on: "file it," "note it," "that's twice now"
3. Surname-first for NPCs without personal relationships: "Voss," "Davan," "Korr" (not yet first names)
4. Emotional states come out sideways: not "I felt alone" but "At least someone in this district speaks my language"
5. Self-monitoring: the detective observes their own analysis for bias — "I'm reading too much into this" is a detective-voice line; the smuggler never thinks this
6. Institutional humor: wry, observational, appreciates irony at a distance — "The briefing said routine"
---
*Ticket #259 — First 5 minutes experience design. Systems: Gestalt. Narrative texture: Paula. Line authoring: Mellanie (#299, #300).*
+170
View File
@@ -0,0 +1,170 @@
# Flat NPC Memorable Trait Pass
**Ticket:** #307 | **Priority:** High | **Sprint:** 12
**Status:** v0.1 Draft
**Owners:** Paula (traits + design), Mellanie (line authoring)
**Decisions referenced:** D-024 (NPC generation model), D-029 (30/50/20 population split)
---
## 1. Purpose
The 30/50/20 population split (D-029) requires that 30% of NPCs are genuinely flat: routine + greeting, nothing more. But flat does not mean invisible. The problem this document solves is: **how do you make a flat NPC memorable without making them entangled?**
Without this, the investigation noise floor collapses. If every NPC the player encounters has depth, the player learns to treat them all as potential leads. The flat NPCs must be indistinguishable from signal at first glance — and remain background when investigated.
The answer is: **one memorable observable trait, one plausible-but-meaningless line, one visible routine.** Nothing more. The trait makes the NPC feel real. The line makes them feel briefly important. The routine reveals they were never a lead.
---
## 2. The Three Flat NPCs (v0.1)
### Pael — Maintenance Tech
**Role in district:** Technical maintenance worker. Repairs station systems, patches conduit runs, handles the small constant failures of a 40-year-old prefab-modular station.
**Memorable observable trait:**
Pael always has a specific piece of mechanical debris in one hand — a loose coupling, a stripped calibration pin, a shim. Not working with it. Not throwing it away. Just carrying it, the way people carry things they've picked up without thinking. Changes every few days. Has been seen patting his pockets as if looking for it when it's not there.
**Seems-important-but-isn't line (for Mellanie to author):**
Something that sounds like it could be about the ring but isn't. Possible directions:
- A complaint about recurring conduit failures in a specific corridor (a real maintenance problem, not a smuggling tell)
- A reference to "the same cargo coming through twice" that turns out to be a legitimate scheduling quirk
- A cryptic comment about "what goes on in Corridor B-7 at night" that turns out to mean: the ventilation cycles loudly and keeps him awake when he's doing late maintenance
**Visible routine:**
- Morning: arrives at maintenance depot, checks the day's work orders
- Mid-morning: visible in one of three locations (terminal catwalks, maintenance corridor access points, or near the span gate mechanism housing) — rotates daily
- Afternoon: back at depot, logging completed work
- Evening: does not go to The Last Shift — his social life is off-district
**Design note:** The rotation across three maintenance locations is important. It means a player who is watching Pael will see him in different places on different days, which can read as suspicious to an overly attentive detective. But the reason is just: different maintenance jobs. The rotation is the noise, not the signal.
**Dual-lens flat NPC:**
*Smuggler:* Pael is background. Known by sight, not by name unless prompted. "That's the maintenance tech — he's been patching conduit runs near corridor B-7 for weeks. Annoying. Have to check if he's gone before any runs."
*Detective:* Pael is a potential lead (maintenance tech → corridor access) who turns out to be nothing. The detective's monologue might flag him early ("Maintenance tech. Knows the corridors.") and unproductively file him as investigated-nothing later ("Pael's just doing maintenance. The B-7 visits are conduit issues. Nothing there.").
---
### Ren — Drifter
**Role in district:** Ren has no formal employment at Sova Transit. They appear to be between postings — the kind of person who stays at a transit hub while waiting for a berth on a freighter, or because the accommodation is cheap and they have no particular place to be. Has been here for about two months. Nobody knows much about them.
**Memorable observable trait:**
Ren is always reading. Not a lattice — a physical reader, the kind that looks like it's been repaired three times and barely holds together. They read in the bar, in the terminal waiting area, in the corridor access lobby. But here's the notable thing: they never seem to be further along in the same book. Whatever they're reading, they're not making progress, or they keep starting over, or they're reading the same page. Hard to tell from a distance.
**Seems-important-but-isn't line (for Mellanie to author):**
Something that sounds conspiratorial but is just evasive privacy. Possible directions:
- "I don't ask what goes in containers. Safer that way." (Said to anyone who asks what they do here — it's just a social habit, not self-incrimination)
- "I've seen a lot of stations. This one's quieter than most." (Sounds like they're watching something, but they're just making conversation with anyone who'll have it)
- References to "a package I'm waiting for" that turns out to be a personal delivery, not a criminal handoff
**Visible routine:**
- Mornings: bar, before it opens fully. Lera tolerates early arrivals. Ren nurses a hot drink and reads.
- Afternoons: terminal waiting area, near the departure board. Not going anywhere. Just watching.
- Evenings: bar again, stays late. Quiet, doesn't bother people, drinks slowly.
**Design note:** Ren's presence in the terminal waiting area watching the departure board is the designed mislead. They look like they're surveilling. They're just waiting for something — possibly a ship that hasn't arrived, possibly just habit. The departure board is the most interesting thing in their day.
**Dual-lens flat NPC:**
*Smuggler:* "Ren's been here two months and nobody knows who they are. If I were paranoid, that'd worry me. But if they were commission, they'd have introduced themselves. Probably just between postings." [Assessment: filed, not relevant.]
*Detective:* Ren is the most suspicious person in the district who has nothing to do with anything. "Unknown. Unaffiliated. Watching the departure board daily. Background check shows nothing — no warrants, no commission flags, three previous transit hub stays in the last year. Probably a drifter. But I'm keeping an eye." The detective may waste 10-15 minutes of investigation time on Ren before filing them as noise.
---
### Tev — Lookout
**Role in district:** Tev works at the The Last Shift as part-time bar support — cleaning tables, stacking deliveries, doing the small physical work that Lera doesn't want to do herself. Not a formal bartender. Present most evenings, some afternoons.
**Memorable observable trait:**
Tev has a specific relationship with the bar entrance. Every time someone walks in, Tev notices — not obviously, not a full head-turn, but a brief flick of attention in that direction. It's trained behavior, or old habit. They check who's coming in. Every time. Including people they've seen every day for months. The tell is subtle but consistent, and the pattern becomes recognizable once noticed.
**Seems-important-but-isn't line (for Mellanie to author):**
Something that sounds like a lookout's report but is just social awareness. Possible directions:
- "Commission's in again" (said to Lera, routine observation — they note the detective's presence the same way they'd note a regular who comes in after a long absence: as social texture, not operational intelligence)
- "Kael's running late." (Said to nobody in particular. Not a ring update — just someone who keeps track of who shows up)
- Overheard to a regular: "You hear about the new cargo schedule?" — turns out to be about legitimate freight delays, nothing to do with the ring
**Visible routine:**
- Afternoons: setting up for the evening shift, cleaning, stocking
- Evenings: working the floor. Tables cleaned, deliveries stacked, entrance watched
- Late evenings: last to leave after the bar closes, stacking chairs
**Design note:** Tev's entrance-watching is the primary mislead. In a bar where a ring operates peripherally, having someone who tracks everyone who comes in reads as a lookout. And functionally, Tev probably IS a lookout for Lera — but Lera's lookout, not the ring's. Lera likes to know who's in her bar before they're at the bar. Tev's habit is loyalty to Lera, not criminal coordination.
**Dual-lens flat NPC:**
*Smuggler:* "Tev sees everything that comes through that door. Lera's eyes. Not our problem as long as Lera's solid." [Tev is background, not a concern.]
*Detective:* "The bar worker — Tev. Watches the entrance. Consistent pattern. In a criminal-adjacent environment, that's potentially a lookout. But their behavior doesn't cluster with any other ring indicators. Social texture, not operational role. File under 'observed, not flagged.'" The detective may ask Lera about Tev — Lera's answer ("been here three years, good worker, minds their own business") is uninformative and accurate.
---
## 3. Design Principles for Flat NPCs
**Principle 1: The trait must be observable without interaction.**
The player should be able to recognize Pael (carrying something), Ren (reading), and Tev (watching the door) from a distance, through fog-of-war partial visibility, without ever having a conversation. The trait is a visual behavior, not a dialogue reveal.
**Principle 2: The seems-important-but-isn't line is bait.**
Every flat NPC needs exactly one line that makes the player think "wait, is this person involved?" followed by a resolution that shows they're not. This is how the noise floor works: the player wastes attention on the noise and learns that not everything is a lead. This is both a design lesson and a realistic texture of investigation.
**Principle 3: The routine must explain the suspicious-looking behavior.**
Pael is in Corridor B-7 because of maintenance. Ren watches the departure board because they're waiting for something. Tev watches the door because Lera wants them to. In every case, the explanation is boring and true. The mundane explanation is the design.
**Principle 4: Dual-lens treatment for all three.**
Each flat NPC is perceived differently by the smuggler (background assessment, no anxiety) and the detective (potential lead, then filed as noise). The difference in perception is the same as for all other NPCs — but for flat NPCs, the detective's arc is: initial flag → investigation → nothing. That's the journey this population is designed to produce.
---
## 4. Content Requirements Summary
| NPC | Paula delivers | Mellanie delivers |
|-----|---------------|-----------------|
| Pael | Trait, dual-lens design, routine | 1 seems-important line + 2-3 variants, 2 dialogue lines (greeting-level) |
| Ren | Trait, dual-lens design, routine | 1 seems-important line + 2-3 variants, 2 dialogue lines |
| Tev | Trait, dual-lens design, routine | 1 seems-important line + 2-3 variants, 2 dialogue lines |
**Mellanie note:** The seems-important lines should feel like they could be something. They shouldn't feel like obviously random NPC chatter. The art is in the plausible misdirection — specific enough to be memorable, mundane enough to resolve as nothing. Voice for all three flat NPCs is brief, non-explanatory, station-working-class register.
---
## 5. YAML Stub for Content Authoring
Each flat NPC needs a minimal NPC profile. Example for Pael:
```yaml
canonical_id: "npc:pael"
display_name: "Pael"
tier: 3
pattern: "FLAT"
motivation: "WORKER"
description: >
Maintenance tech. Handles station system repairs across the district.
Observable: always carrying a piece of mechanical debris. Changes what
it is every few days.
routine:
summary: >
Maintenance depot (morning arrival), one of three maintenance locations
(mid-morning, rotates), back to depot (afternoon), off-district (evening).
trust_levels:
surface: >
Minimal. Complaints about current repair job, maintenance schedule.
Never personal, always task-focused.
real: "Not accessible — flat NPC."
secret: "Not accessible — flat NPC."
notes: >
FLAT NPC. No triangle membership. No ring connection. The B-7 corridor
visits are conduit maintenance. The investigation dead-end is the point.
```
---
*Ticket #307 — Flat NPC memorable trait pass. 3 flat NPCs. Paula: traits. Mellanie: lines.*
+273
View File
@@ -0,0 +1,273 @@
# Knowledge Flags — v0.1 Quick Reference
**For:** Mellanie (content authoring), Paula (narrative design)
**Source:** docs/design/knowledge-vocabulary-v01.md (full specification)
**Ticket:** #309 — unblocks #299, #300 (opening hook content)
**Date:** 2026-02-19
---
## How to Use This Document
When writing a monologue line that should only fire once the character knows something specific, add a `prerequisites:` block. Use the fact IDs and shorthand from this document — do not invent strings.
### The three prerequisite types
```yaml
# 1. Entity relationship check
prerequisites:
relationship:
target: npc:kael-davan # use the NPC's kebab-case file name
state: friendly # unknown | known | friendly | person_of_interest | hostile
# 2. World fact check
prerequisites:
facts:
- fact_id: investigation.kael_corridor_meeting
min_confidence: suspects # suspects | knows_of | knows_details | direct
# 3. Entity attribute check (rare — use for specific behavioral tells)
prerequisites:
entity_attributes:
- entity: npc:kael-davan
key: behavior_flags
value: avoidance
```
All conditions are AND. For OR logic, write two lines with different prerequisites targeting the same trigger.
---
## Confidence Level Cheat Sheet
| Level | Shorthand | Plain English | When to use it |
|-------|-----------|---------------|----------------|
| `suspects` | `suspects:` | "Something's off" | Early-game, vague lines — player has noticed but not confirmed |
| `knows_of` | `knows:` | "X is connected to Y" | Mid-game, specific lines — player has real information |
| `knows_details` | `knows_details:` | "X did Y because Z" — actionable | Late-game, confrontation-adjacent lines — player knows enough to act |
| `direct` | `direct:` | "I'm looking at it right now" | Set by perception system, not authored — use only for immediate-observation monologue |
**Design rule:** Most lines need no prerequisites (they fire freely on trigger). Gated lines should reveal something the ungated versions can't say. Don't over-gate — use prerequisites to create *surprise when a new line appears*, not to withhold most of the content.
---
## Valid Fact IDs
### `investigation.*` — case findings
Use for: specific events witnessed, evidence found, NPC behaviors confirmed through investigation.
```
investigation.kael_corridor_meeting Kael was seen in B-7 off-shift, meeting someone
investigation.kael_corridor_sighting Detective's initial B-7 observation (detective only)
investigation.kael_unknown_contact Kael's contact is not from the regular rotation
investigation.kael_ring_membership Kael is a ring member
investigation.kael_exit_attempt Kael is trying to leave the ring
investigation.manifest_discrepancy Cargo manifests show irregularities
investigation.cargo_anomaly Specific container/weight/route anomaly
investigation.shift_mismatch Shift records don't match observed personnel
investigation.voss_ring_involvement Voss manages schedule changes to cover drops
investigation.torek_spending_pattern Torek is spending beyond his means
investigation.drin_inspection_pattern Drin's inspections are conspicuously lax
investigation.drin_gambling_debt Drin has a gambling debt — leverage
investigation.oversight_gap_pattern Inspection gaps are systematic, not random
investigation.surveillance_gaps Specific corridor camera blind spots
investigation.smuggling_route The exact smuggling route through the station
investigation.ring_existence A smuggling ring is definitely operating here
investigation.renn_courier_role Renn is a courier for the ring
investigation.corridor_regular_use Maintenance corridors are routinely used for drops
investigation.container_delay A specific container has been held too long
investigation.sera_evidence_held Sera has unreported manifest evidence
investigation.naia_lattice_condition Naia's lattice is degrading — the medical components are for her
investigation.ring_leadership Who directs ring operations (late discovery)
```
### `awareness.*` — situational awareness
Use for: who's present right now, active threats, operational status.
```
awareness.detective_presence A Commission detective is on Sova
awareness.detective_investigating_ring Detective is specifically targeting the ring
awareness.commission_audit_scheduled An audit is coming — officially scheduled
awareness.ring_route_compromise The current drop route may be blown
awareness.surveillance_change Corridor surveillance patterns have changed
awareness.ring_tension_escalation Something is wrong inside the ring
awareness.inspection_sweep_active Active sweep in progress right now
```
### `behavioral.*` — recognized patterns
Use for: tells and habits confirmed through multiple observations.
```
behavioral.kael_evasion Kael looks left when lying — tell recognized
behavioral.kael_behavioral_change Kael is quieter, distracted, different from normal
behavioral.sera_avoidance_pattern Sera consistently avoids Torek Lintar
behavioral.sera_topic_deflection Sera deflects Commission/manifest topics
behavioral.sera_kiosk_pattern Sera stops at the same kiosk at the same time
behavioral.sera_knowledge_detail Sera's offhand comments reveal too much routing knowledge
behavioral.voss_awareness Voss is tracking people — beyond normal supervisor behavior
```
### `knowledge.*` — operational knowledge
Use for: procedural/institutional knowledge the character has accumulated or knows from background.
```
knowledge.ring_routing_knowledge Full drop routes and timing protocols (smuggler background)
knowledge.ring_bar_operations The bar has a role in ring communications
knowledge.bar_back_room_significance The back room at The Last Shift matters somehow
knowledge.bar_ring_awareness Bar regulars have varying awareness of the ring
knowledge.kael_exit_plan Kael has a specific exit plan — timing, contact, conditions
```
### `contraband.*` — smuggling ring world facts
Use for: what the ring is moving and how.
```
contraband.ring_exists A smuggling operation is running through Sova (smuggler starts at knows_details)
contraband.ring_lattice_components The ring moves unlicensed lattice mods
contraband.ring_medical_components The ring also moves diverted medical-grade lattice
contraband.supply_chain Understanding of where the components come from
contraband.severance_tech_presence Severance counter-surveillance tech is in play
contraband.drop_tonight A drop is scheduled for tonight
```
### `location.*` — spatial knowledge
Use for: what a location is, who has access, what it's used for.
```
location.corridor_b7_restricted Corridor B-7 requires credentials
location.corridor_b7_drop_site B-7 is an active drop site
location.maintenance_corridors_access Character can navigate the maintenance corridor layout
location.bay4_sealed Bay 4 is sealed — inspection or cover
location.span_gate_schedule_gap Span gate has a window that enables unseen drops
```
### `relationship.*` — inter-NPC connections
Use for: third-party connections between NPCs (not player-NPC relationship).
```
relationship.kael_naia_connection Kael and Naia are romantically partnered
relationship.ring_membership Player has identified specific ring members
relationship.trust_network Who trusts whom inside the ring (operational level)
```
### `social.*` — social positioning
Use for: player's social standing in a specific context.
```
social.bar_regular_status Player is a recognized regular at The Last Shift
```
---
## Valid NPC IDs (for `relationship:` and `entity_attributes:` blocks)
```
npc:kael-davan Dock worker, smuggler's FRIEND, ring member
npc:sera-venn Commission field tech, detective's FRIEND
npc:voss Shift supervisor, ring operative
npc:maret-korr Scheduler
npc:drin Inspector, ring-adjacent
npc:torek-lintar Dock worker, spending beyond means
npc:naia-tamm Ring adjacent, Kael's partner
npc:nils-davan Kael's brother, dock worker
npc:lera-sessik Bar owner at The Last Shift
npc:renn Drifter, ring courier
npc:pell Maintenance
npc:sess Dock worker
npc:tav Dock worker
npc:sabel Bar regular
npc:harek Bar regular / dock adjacent
npc:olin Ring operative
npc:resha Ring operative
npc:devra Ring operative
```
---
## Character Starting Knowledge
Lines in the opening sequence should fire on Background knowledge — these facts are in the graph from tick 0, no investigation needed.
### Smuggler — knows at game start
The smuggler IS in the ring. These prerequisites will always pass:
| Fact ID | Confidence |
|---------|-----------|
| `contraband.ring_exists` | `knows_details` |
| `knowledge.ring_routing_knowledge` | `knows_details` |
| `contraband.ring_lattice_components` | `knows_details` |
| `location.corridor_b7_drop_site` | `knows_details` |
| `location.maintenance_corridors_access` | `knows_details` |
| `world.lattice_regulation_active` | `knows_of` |
### Detective — knows at game start
The detective has anomalies flagged, but nothing confirmed:
| Fact ID | Confidence |
|---------|-----------|
| `investigation.manifest_discrepancy` | `suspects` |
| `contraband.ring_exists` | `suspects` |
| `world.lattice_regulation_active` | `knows_details` |
| `location.corridor_b7_restricted` | `knows_of` |
---
## Opening Hook Prereq Patterns (for #299/#300)
### Smuggler opening lines (first 10-15 monologue lines)
The smuggler arrives with operational awareness. Use these patterns:
```yaml
# No prereqs — fires for any player on arrival (atmospheric, sets tone)
trigger: enter_location
tags: [arrival, atmospheric]
# Fires when smuggler sees a specific known NPC
trigger: observe_npc
# No prereqs needed — smuggler knows everyone from background
tags: [npc, kael, warm]
# First sign something might be wrong
prerequisites:
facts:
- fact_id: contraband.ring_exists
min_confidence: knows_details # Always true for smuggler — line fires immediately
tags: [operational, setup]
```
### Detective opening lines (first 10-15 monologue lines)
The detective arrives with a case hypothesis. Use these patterns:
```yaml
# No prereqs — arrives fresh, first impressions
trigger: enter_location
tags: [arrival, institutional]
# First lattice-flag moment
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: suspects # True from background — detective has the anomaly flagged
tags: [investigation, lattice]
# Sees a person of interest for the first time
trigger: observe_npc
# No prereqs for first sighting — use relationship gating on follow-up lines
tags: [npc, observation]
```
---
*Quick reference for Sprint 12 content authoring. Full specification: docs/design/knowledge-vocabulary-v01.md*
+531
View File
@@ -0,0 +1,531 @@
# Knowledge Vocabulary for v0.1 Content
**Source:** D-041 (Knowledge Graph Data Model), D-035 (Tag Taxonomy), D-028 (Dialogue Architecture), D-075 (Dialogue Filtering)
**Ticket:** #368
**Unblocks:** #309 (knowledge state vocabulary for v0.1) → #299, #300 (opening hook content)
**Date:** 2026-02-19
---
## Overview
Two knowledge stores, one prerequisite system.
**Entity knowledge** — what your character knows *about a specific NPC*. Stored per entity in `EntityKnowledge.known_attributes`. Six attribute categories. Accessed via `relationship:` and `entity_attributes:` blocks in `prerequisites:`.
**Fact knowledge** — what your character knows *about the world*. Stored in `KnowledgeGraph.facts`, keyed by `FactId` in `category.topic` format. Eight categories. Accessed via `facts:` blocks in `prerequisites:`.
Both stores gate monologue lines (D-035 `prerequisite` tag) and dialogue access tiers (D-028 / D-075).
---
## Part 1: Confidence Levels
Four levels. Ordered ascending. Knowledge decays downward over time (once per game-minute, per D-031/D-026).
| Engine Name | Author Shorthand | Plain English | What It Unlocks |
|-------------|-----------------|---------------|-----------------|
| `Suspects` | `suspects:` | "Something's off" / "I've heard the name" | Initial investigation, vague monologue, open-ended questions |
| `KnowsOf` | `knows:` | "X is involved in Y" / "X has a connection to Z" | Topic-specific dialogue, specific monologue, peer-tier access (D-075) |
| `KnowsDetails` | `knows_details:` | "X did Y at Z because W" — actionable | Confrontation unlock, secret-tier dialogue (D-075), detailed monologue |
| `Direct` | `direct:` | "I'm looking at X right now" | Live position, full rendering fidelity — **set by perception system, not authored** |
**Decay path:** `Direct``KnowsDetails``KnowsOf``Suspects` (floor — `Suspects` does not decay further).
**For content authors:** Lines gated at `suspects:` fire early and broadly. Lines gated at `knows_details:` fire late and rarely. Design monologue arcs with both ends — arrival lines (no prereqs) and deep-investigation lines (high confidence prereqs).
---
## Part 2: Entity Knowledge
Entity knowledge tracks what character A knows about a specific NPC B. Stored in `EntityKnowledge.known_attributes` as a BTreeMap of string keys. The `EntityKnowledge` struct also carries `confidence`, `relationship`, `state`, and `source` fields at the top level.
### 2.1 Entity Knowledge Categories
Six categories of entity-level knowledge for v0.1:
| Category | What It Covers | Key Attributes |
|----------|---------------|----------------|
| **identity** | Who they are — name, role, faction affiliation | `name`, `role`, `faction`, `species` |
| **location** | Where they go — last known position, routine zones | `routine_pattern` (observed), `last_known_position` (system-managed) |
| **behavior** | How they act — tells, flags, pattern anomalies | `routine_pattern`, `behavior_flags`, `tell_observed` |
| **relationship** | Their connections — to the player, to other NPCs | `relationship_type`, `trust_level`, `loyalty_assessment` |
| **secret** | What they're hiding — leverage, contradiction flags | `leverage_held`, `leverage_confidence`, `contradiction_flagged` |
| **contraband** | Ring involvement — membership, operational knowledge | `faction` (ring member), `behavior_flags` (contraband-tagged) |
Full attribute definitions: `content/global/knowledge/entity-attributes.yaml`
### 2.2 Prerequisite Format — Entity Checks
```yaml
# Check relationship state with a specific entity
prerequisites:
relationship:
target: npc:kael-davan
state: friendly # unknown | known | friendly | person_of_interest | hostile
# Check a specific entity attribute value
prerequisites:
entity_attributes:
- entity: npc:kael-davan
key: behavior_flags
value: avoidance
# Both together (all conditions AND)
prerequisites:
relationship:
target: npc:kael-davan
state: person_of_interest
entity_attributes:
- entity: npc:kael-davan
key: contradiction_flagged
value: meeting_unknown_contact
```
**Relationship states** (D-033): `unknown` · `known` · `friendly` · `person_of_interest` · `hostile`
### 2.3 Entity Shorthand Conventions
For planning and #309 shortlist authoring:
| Shorthand | Meaning | Prerequisite Equivalent |
|-----------|---------|------------------------|
| `met: npc:{id}` | NPC is in the knowledge graph at all | entity entry exists, any confidence |
| `has_seen: npc:{id}` | NPC was directly observed in LOS at least once | Direct confidence was set at least once |
| `knows_npc: npc:{id}` | Character knows who this NPC is (KnowsOf+) | entity confidence ≥ KnowsOf |
| `friend: npc:{id}` | Friendly relationship established | relationship state = friendly |
| `poi: npc:{id}` | NPC flagged as person of interest | relationship state = person_of_interest |
| `hostile: npc:{id}` | Character perceives NPC as hostile | relationship state = hostile |
---
## Part 3: Fact Knowledge
Fact knowledge tracks non-entity information — events, situations, operational states, world conditions. Stored in `KnowledgeGraph.facts` keyed by `FactId`.
### 3.1 Fact ID Naming Convention
**Format:** `{category}.{topic_in_snake_case}`
**Examples:**
- `investigation.kael_corridor_meeting`
- `awareness.detective_presence`
- `behavioral.sera_avoidance_pattern`
- `knowledge.ring_routing_knowledge`
**Naming rules:**
- Category is always one of the eight below
- Topic uses `snake_case`, no spaces, no hyphens
- Topic should name one discrete fact — specific enough to gate a specific line
- Avoid generic topics: `thing_happened` → no; `kael_seen_with_unknown_contact` → yes
- NPC facts use the NPC's canonical short name: `kael`, `sera`, `voss`, `torek`, `drin`, `renn`
- Shared facts across characters use identical IDs — same fact, different confidence per character
### 3.2 Fact Categories
Eight categories for v0.1:
| Category | What It Tracks | Namespace Usage |
|----------|---------------|-----------------|
| `investigation` | Case-specific findings — events witnessed, evidence discovered, NPC behaviors confirmed | Primary investigation facts, both characters |
| `awareness` | Situational awareness — who's present, active threats, operational status changes | Real-time or near-term situational facts |
| `behavioral` | Behavioral patterns recognized across multiple observations | Accumulated tell data, avoidance patterns, habit deviations |
| `knowledge` | Operational knowledge — ring procedures, bar dynamics, specific institutional mechanics | Character-background knowledge + learned procedures |
| `contraband` | Smuggling ring facts — existence, components, routes, supply chain | Ring-specific world facts |
| `location` | Location-specific facts — access rights, significance, usage patterns | Spatial knowledge that drives spatial decisions |
| `relationship` | Inter-NPC connections — who knows who, what connects them | Third-party relationship knowledge (not player-NPC) |
| `social` | Social positioning — standing in a social context, bar regular status | Context-specific social facts |
> **Note on `progress` category:** A `progress` namespace exists in the file structure but is not used directly in prerequisite checks. Narrative phase gating uses relationship states and specific investigation facts rather than explicit progress flags. Authors should NOT create `progress.*` facts for opening hook content — use investigation fact chains instead.
### 3.3 Prerequisite Format — Fact Checks
```yaml
# Single fact check
prerequisites:
facts:
- fact_id: investigation.kael_corridor_meeting
min_confidence: suspects
# Multiple facts (all must pass)
prerequisites:
facts:
- fact_id: knowledge.ring_routing_knowledge
min_confidence: knows_details
- fact_id: awareness.detective_presence
min_confidence: knows_of
# Combined entity + fact check
prerequisites:
relationship:
target: npc:kael-davan
state: friendly
facts:
- fact_id: investigation.kael_corridor_meeting
min_confidence: knows_of
```
**`min_confidence` values:** `suspects` · `knows_of` · `knows_details` · `direct`
### 3.4 Fact Shorthand Conventions
For planning, shortlists, and #309 authoring:
| Shorthand | Meaning | Prerequisite Equivalent |
|-----------|---------|------------------------|
| `suspects: {fact_id}` | Fact known at Suspects level | `min_confidence: suspects` |
| `knows: {fact_id}` | Fact known at KnowsOf level | `min_confidence: knows_of` |
| `knows_details: {fact_id}` | Fact known at KnowsDetails level | `min_confidence: knows_details` |
| `phase: {progress_fact}` | Narrative milestone reached | relationship-state check or specific investigation fact |
---
## Part 4: v0.1 Fact Catalog — Transit District
Canonical fact list for the Sova Transit District scenario. All fact IDs in content must appear here. This is the source of truth for #309 (knowledge state vocabulary for v0.1).
Facts marked **[S]** are relevant to the Smuggler, **[D]** to the Detective, **[B]** to both.
### 4.1 Investigation Facts
Case-specific findings accumulated through play.
| Fact ID | Plain Description | Typical Confidence When Learned | Chars |
|---------|------------------|--------------------------------|-------|
| `investigation.kael_corridor_meeting` | Kael was seen in corridor B-7 off-shift, apparently meeting someone | `suspects` (glimpsed) → `knows_of` (confirmed) | B |
| `investigation.kael_corridor_sighting` | Kael spotted in the maintenance corridors at an unusual hour (detective's initial observation ID) | `suspects` | D |
| `investigation.kael_unknown_contact` | The person Kael met in B-7 is unknown — not from the regular rotation | `suspects``knows_of` | B |
| `investigation.kael_ring_membership` | Kael is a member of the smuggling ring | `suspects``knows_details` | B |
| `investigation.manifest_discrepancy` | Cargo manifests show irregularities — containers, weights, routing anomalies | `suspects` (detective lattice scan) → `knows_of` | B |
| `investigation.manifest_discrepancy` | *Same ID, different source:* Smuggler notices an anomalous entry in shared manifest data | `suspects``knows_of` | S |
| `investigation.cargo_anomaly` | A specific cargo anomaly — wrong container, wrong weight, wrong route | `suspects` (observation) | D |
| `investigation.shift_mismatch` | Shift records don't match observed personnel — someone's covering for someone | `knows_of` | B |
| `investigation.voss_ring_involvement` | Voss is involved in ring operations — schedule changes cover for drops | `knows_of` (pattern) → `knows_details` | B |
| `investigation.torek_spending_pattern` | Torek is spending beyond his means — drinks, visible spending uptick | `knows_of` (observation) | B |
| `investigation.drin_inspection_pattern` | Drin's inspection style is conspicuously casual — clearing containers without real checking | `knows_of` | D |
| `investigation.drin_gambling_debt` | Drin has a gambling debt — provides leverage for whoever is running him | `knows_details` | D |
| `investigation.oversight_gap_pattern` | Inspection gaps are systematic, not accidental — someone designed the blind spots | `knows_of``knows_details` | D |
| `investigation.surveillance_gaps` | Specific surveillance gaps in the maintenance corridors — where the cameras don't reach | `knows_details` | B |
| `investigation.smuggling_route` | The specific smuggling route through the station | `knows_details` | B |
| `investigation.ring_existence` | A smuggling operation is definitely running through Sova Transit | `suspects` (accumulating anomalies) → `knows_of` | D |
| `investigation.renn_courier_role` | Renn operates as a courier in the ring — runs packages between drop sites | `knows_of` | D |
| `investigation.corridor_regular_use` | The maintenance corridors are regularly used for smuggling runs, not just incidentally | `knows_of` | D |
| `investigation.container_delay` | A specific container has been held in temporary storage longer than normal — something is wrong | `suspects` | S |
| `investigation.sera_evidence_held` | Sera has unreported evidence about manifest irregularities — sitting on it deliberately | `suspects` (avoidance pattern) → `knows_details` (Sera discloses) | D |
| `investigation.kael_exit_attempt` | Kael is actively trying to leave the ring | `knows_of``knows_details` | B |
| `investigation.naia_lattice_condition` | Naia's neural lattice is degrading — the medical components are for her | `knows_details` (Kael discloses or Naia encountered) | B |
> **Normalization note:** `investigation.kael_corridor_meeting` (smuggler perspective, meeting focus) and `investigation.kael_corridor_sighting` (detective perspective, initial observation) describe different observations of the same event. They should remain as separate IDs — the detective may reach `kael_corridor_meeting` after establishing `kael_corridor_sighting`. Content authors should prefer `kael_corridor_meeting` when writing lines that reference the meeting itself.
### 4.2 Awareness Facts
Situational awareness — who's here, what's active, what's changed.
| Fact ID | Plain Description | Typical Confidence | Chars |
|---------|------------------|-------------------|-------|
| `awareness.detective_presence` | A Commission detective is operating on Sova Station | `knows_of` (rumor, observation) | S |
| `awareness.detective_investigating_ring` | The detective is specifically investigating the ring, not a routine audit | `knows_details` | S |
| `awareness.commission_audit_scheduled` | A Commission audit is coming — schedule confirmed | `knows_of` | B |
| `awareness.ring_route_compromise` | The current smuggling route may be compromised or under observation | `suspects``knows_of` | S |
| `awareness.surveillance_change` | Surveillance patterns in the corridors have changed recently | `suspects` | S |
| `awareness.ring_tension_escalation` | Tension inside the ring is escalating — someone is scared or making moves | `suspects``knows_of` | S |
| `awareness.inspection_sweep_active` | Active inspection sweep in progress right now | `direct` | B |
### 4.3 Behavioral Facts
Patterns recognized through repeated observation across multiple scenes.
| Fact ID | Plain Description | Typical Confidence | Chars |
|---------|------------------|-------------------|-------|
| `behavioral.kael_evasion` | Kael uses a specific evasion tell (looks left when lying) — recognized post-conversation | `direct` (set in conversation) | S |
| `behavioral.kael_behavioral_change` | Kael's behavior has noticeably changed recently — quieter, distracted, different routine | `suspects``knows_of` | S |
| `behavioral.sera_avoidance_pattern` | Sera consistently avoids Torek Lintar — leaves when he arrives, repositions | `suspects` (one observation) → `knows_of` (pattern confirmed) | B |
| `behavioral.sera_topic_deflection` | Sera deflects specific topics — Commission work, manifest audits | `suspects``knows_of` | B |
| `behavioral.sera_kiosk_pattern` | Sera habitually stops at the same kiosk at the same time — a recognizable routine | `knows_of` | B |
| `behavioral.sera_knowledge_detail` | Sera's offhand comments reveal detailed knowledge of manifest routing she shouldn't have | `knows_of` | S |
| `behavioral.voss_awareness` | Voss appears to be tracking people — situational awareness beyond normal supervisor behavior | `suspects` | D |
### 4.4 Knowledge Facts
Operational knowledge — ring procedures, social dynamics, institutional mechanics.
| Fact ID | Plain Description | Typical Confidence | Chars |
|---------|------------------|-------------------|-------|
| `knowledge.ring_routing_knowledge` | Full knowledge of the ring's drop routes and timing protocols | `knows_details` | S (background) |
| `knowledge.ring_bar_operations` | The bar (The Last Shift) has a role in ring communications or social cover | `knows_of``knows_details` | B |
| `knowledge.bar_back_room_significance` | The bar's back room has significance — meeting space, handoff point, or storage | `suspects``knows_details` | D |
| `knowledge.bar_ring_awareness` | The bar's regulars have varying levels of ring awareness — it's an open secret at some level | `knows_of` | B |
| `knowledge.kael_exit_plan` | Kael has a specific plan for exiting the ring — timing, conditions, contact | `knows_details` | B |
### 4.5 Contraband Facts
Smuggling ring world facts — existence, composition, operations.
| Fact ID | Plain Description | Typical Confidence | Chars |
|---------|------------------|-------------------|-------|
| `contraband.ring_exists` | A smuggling operation is running through Sova Transit District | `suspects` (detective) / `knows_details` (smuggler, background) | B |
| `contraband.ring_lattice_components` | The ring moves unlicensed lattice components — aftermarket neural mods | `knows_of``knows_details` | B |
| `contraband.ring_medical_components` | The ring also diverts medical-grade lattice replacements from Syndic supply chains | `knows_details` | B |
| `contraband.supply_chain` | Understanding of the ring's supply chain — where components come from | `knows_details` | B |
| `contraband.severance_tech_presence` | Severance counter-surveillance technology is in play on the station | `suspects` | D |
| `contraband.drop_tonight` | A drop is scheduled for tonight specifically | `knows_details` | S |
### 4.6 Location Facts
Location-specific access, restrictions, and significance.
| Fact ID | Plain Description | Typical Confidence | Chars |
|---------|------------------|-------------------|-------|
| `location.corridor_b7_restricted` | Corridor B-7 is a restricted access zone — requires credentials | `knows_of` | B |
| `location.corridor_b7_drop_site` | B-7 is an active drop site for the ring | `knows_details` | B |
| `location.maintenance_corridors_access` | Character knows the maintenance corridor layout and can navigate it | `knows_of` (detective, investigation) / `knows_details` (smuggler, background) | B |
| `location.bay4_sealed` | Bay 4 is sealed — inspection or deliberate cover | `suspects` | B |
| `location.span_gate_schedule_gap` | The span gate schedule has a window that enables unseen drops | `knows_details` | S |
### 4.7 Relationship Facts
Third-party inter-NPC connections — what one character knows about relationships between others.
| Fact ID | Plain Description | Typical Confidence | Chars |
|---------|------------------|-------------------|-------|
| `relationship.kael_naia_connection` | Kael and Naia are romantically partnered — not just colleagues | `knows_of``knows_details` | B |
| `relationship.ring_membership` | Character has identified specific ring members | `knows_of` (list incomplete) → `knows_details` (full ring) | B |
| `relationship.trust_network` | Understanding of who trusts whom inside the ring | `knows_details` | S |
### 4.8 Social Facts
Social positioning in a specific context.
| Fact ID | Plain Description | Typical Confidence | Chars |
|---------|------------------|-------------------|-------|
| `social.bar_regular_status` | Character is established as a regular at The Last Shift — recognized, trusted in that context | `knows_of` | B |
---
## Part 5: Prerequisite Format Quick Reference
### Full YAML format (engine canonical)
```yaml
- id: transit_m_s_025
character: smuggler
text: "Kael keeps checking his lattice. Waiting for a message? Not like him to be jumpy."
trigger: observe_anomaly
prerequisites:
relationship: # Optional: entity relationship state check
target: npc:kael-davan
state: friendly
facts: # Optional: world fact checks — ALL must pass
- fact_id: investigation.kael_corridor_meeting
min_confidence: suspects
entity_attributes: # Optional: entity attribute value checks
- entity: npc:kael-davan
key: behavior_flags
value: lattice_checking
priority: 7
tags: [npc, kael, tell, friend-arc]
```
### Shorthand map (for #309 planning, not used directly in YAML)
| Shorthand | Full YAML | Notes |
|-----------|-----------|-------|
| `suspects: investigation.kael_corridor_meeting` | `facts: [{fact_id: investigation.kael_corridor_meeting, min_confidence: suspects}]` | |
| `knows: awareness.detective_presence` | `facts: [{fact_id: awareness.detective_presence, min_confidence: knows_of}]` | |
| `knows_details: contraband.ring_routing_known` | `facts: [{fact_id: contraband.ring_routing_known, min_confidence: knows_details}]` | |
| `met: npc:kael-davan` | entity in KnowledgeGraph at any confidence | |
| `has_seen: npc:kael-davan` | entity had Direct confidence at some point | |
| `friend: npc:kael-davan` | `relationship: {target: npc:kael-davan, state: friendly}` | |
| `poi: npc:kael-davan` | `relationship: {target: npc:kael-davan, state: person_of_interest}` | |
### Multi-condition logic
All conditions within a `prerequisites:` block are **AND** — all must pass for the line to be eligible.
To express **OR** logic: author multiple lines targeting the same trigger with different prerequisites. The engine selects the highest-priority eligible line.
---
## Part 6: Common Authoring Patterns
### Pattern 1: Progressive revelation — early
Line fires from the first suspicious observation. No prior facts required.
```yaml
trigger: observe_anomaly
# No prerequisites — fires on any anomaly observation
tags: [environmental, caution]
```
### Pattern 2: Progressive revelation — mid
Line fires after initial suspicion is established:
```yaml
prerequisites:
facts:
- fact_id: investigation.kael_corridor_meeting
min_confidence: suspects
```
### Pattern 3: Progressive revelation — late
Line fires with actionable evidence in hand:
```yaml
prerequisites:
facts:
- fact_id: investigation.kael_ring_membership
min_confidence: knows_details
```
### Pattern 4: THE FRIEND arc — contradiction trigger
Line fires when the contradiction is discovered (both relationship state and investigative knowledge required):
```yaml
prerequisites:
relationship:
target: npc:kael-davan
state: person_of_interest
facts:
- fact_id: investigation.kael_corridor_meeting
min_confidence: knows_of
tags: [npc, kael, contradiction, friend-arc]
```
### Pattern 5: D-075 trust tier — secret-tier dialogue gate
Secret-tier dialogue requires both `friendly` relationship AND `knows_details` on target (D-075):
```yaml
# This is handled by the dialogue engine — content authors do NOT need to
# add this as a prerequisite on dialogue lines. It applies automatically.
# For MONOLOGUE lines that mirror the emotional weight of secret disclosure:
prerequisites:
relationship:
target: npc:kael-davan
state: friendly
facts:
- fact_id: investigation.kael_exit_attempt
min_confidence: knows_details
```
### Pattern 6: Opening hook (for #299/#300)
Opening hook lines fire from character Background knowledge. The character arrives already knowing these things — do not gate them on investigation facts.
```yaml
# Smuggler — knows the ring, knows the route. No investigation prereqs needed.
# Gate on simple triggers (enter_location, time_idle) with no prerequisites.
trigger: enter_location
# No prerequisites — smuggler's operational awareness is Background knowledge
tags: [arrival, operational]
# Detective — knows Commission jurisdiction, has manifest anomaly suspicion.
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: suspects
```
---
## Appendix A: Character Starting Knowledge
What each character knows at game start. Source: `Background` (set at initialization). These facts do NOT need to be discovered — they are in the knowledge graph from tick 0.
### Smuggler starting facts
| Fact ID | Starting Confidence | Notes |
|---------|--------------------:|-------|
| `contraband.ring_exists` | `knows_details` | Is an active member |
| `knowledge.ring_routing_knowledge` | `knows_details` | Knows the full routing protocol |
| `contraband.ring_lattice_components` | `knows_details` | Knows what they're moving |
| `location.corridor_b7_drop_site` | `knows_details` | Knows the drop sites |
| `location.maintenance_corridors_access` | `knows_details` | Knows the layout |
| `relationship.ring_membership` | `knows_of` | Knows some ring members; full membership is `knows_details` |
| `world.lattice_regulation_active` | `knows_of` | Knows why there's demand |
| `world.unlicensed_lattice_demand` | `knows_of` | Understands the moral context |
| `location.corridor_b7_restricted` | `knows_of` | Knows the official restriction |
### Detective starting facts
| Fact ID | Starting Confidence | Notes |
|---------|--------------------:|-------|
| `investigation.manifest_discrepancy` | `suspects` | Has flagged anomalies, no confirmation yet |
| `contraband.ring_exists` | `suspects` | Has a case, not yet confirmed |
| `world.lattice_regulation_active` | `knows_details` | Full institutional knowledge |
| `world.commission_sova_jurisdiction` | `knows_details` | Knows their authority |
| `location.corridor_b7_restricted` | `knows_of` | Knows the restricted zone map |
| `investigation.surveillance_gaps` | `suspects` | Has noticed something is off |
---
## Appendix B: Fact ID Index
Alphabetical index for quick lookup. Format: `category.fact_name` → section.
| Fact ID | Section |
|---------|---------|
| `awareness.commission_audit_scheduled` | 4.2 |
| `awareness.detective_investigating_ring` | 4.2 |
| `awareness.detective_presence` | 4.2 |
| `awareness.inspection_sweep_active` | 4.2 |
| `awareness.ring_route_compromise` | 4.2 |
| `awareness.ring_tension_escalation` | 4.2 |
| `awareness.surveillance_change` | 4.2 |
| `behavioral.kael_behavioral_change` | 4.3 |
| `behavioral.kael_evasion` | 4.3 |
| `behavioral.sera_avoidance_pattern` | 4.3 |
| `behavioral.sera_knowledge_detail` | 4.3 |
| `behavioral.sera_kiosk_pattern` | 4.3 |
| `behavioral.sera_topic_deflection` | 4.3 |
| `behavioral.voss_awareness` | 4.3 |
| `contraband.drop_tonight` | 4.5 |
| `contraband.ring_exists` | 4.5 |
| `contraband.ring_lattice_components` | 4.5 |
| `contraband.ring_medical_components` | 4.5 |
| `contraband.severance_tech_presence` | 4.5 |
| `contraband.supply_chain` | 4.5 |
| `investigation.cargo_anomaly` | 4.1 |
| `investigation.container_delay` | 4.1 |
| `investigation.corridor_regular_use` | 4.1 |
| `investigation.drin_gambling_debt` | 4.1 |
| `investigation.drin_inspection_pattern` | 4.1 |
| `investigation.kael_corridor_meeting` | 4.1 |
| `investigation.kael_corridor_sighting` | 4.1 |
| `investigation.kael_exit_attempt` | 4.1 |
| `investigation.kael_ring_membership` | 4.1 |
| `investigation.kael_unknown_contact` | 4.1 |
| `investigation.manifest_discrepancy` | 4.1 |
| `investigation.naia_lattice_condition` | 4.1 |
| `investigation.oversight_gap_pattern` | 4.1 |
| `investigation.ring_existence` | 4.1 |
| `investigation.renn_courier_role` | 4.1 |
| `investigation.ring_leadership` | 4.1 |
| `investigation.sera_evidence_held` | 4.1 |
| `investigation.shift_mismatch` | 4.1 |
| `investigation.smuggling_route` | 4.1 |
| `investigation.surveillance_gaps` | 4.1 |
| `investigation.torek_spending_pattern` | 4.1 |
| `investigation.voss_ring_involvement` | 4.1 |
| `knowledge.bar_back_room_significance` | 4.4 |
| `knowledge.bar_ring_awareness` | 4.4 |
| `knowledge.kael_exit_plan` | 4.4 |
| `knowledge.ring_bar_operations` | 4.4 |
| `knowledge.ring_routing_knowledge` | 4.4 |
| `location.bay4_sealed` | 4.6 |
| `location.corridor_b7_drop_site` | 4.6 |
| `location.corridor_b7_restricted` | 4.6 |
| `location.maintenance_corridors_access` | 4.6 |
| `location.span_gate_schedule_gap` | 4.6 |
| `relationship.kael_naia_connection` | 4.7 |
| `relationship.ring_membership` | 4.7 |
| `relationship.trust_network` | 4.7 |
| `social.bar_regular_status` | 4.8 |
---
*Fact catalog current as of 2026-02-19. All fact IDs in existing content are documented here. New IDs added during Sprint 12 content authoring should be added to the relevant section and Appendix B.*

Some files were not shown because too many files have changed in this diff Show More