Compare commits

...
60 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 219af065d8 chore(skills): remove temp file pattern from pr-push skill
Pass PR description inline instead of writing to /tmp/pr-body.md
and cat-ing it back — matches the tea-comment wrapper approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:44:20 +01:00
jpmschweitzerandClaude Opus 4.6 0beeb32ff7 chore(db): backup database after maintenance merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:41:54 +01:00
jpmschweitzer c2eb8fdf8f Merge remote-tracking branch 'origin/maintenance'
# Conflicts:
#	CLAUDE.md
2026-02-24 12:41:43 +01:00
jpmschweitzerandClaude Opus 4.6 ca51594195 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:37:44 +01:00
jpmschweitzerandClaude Opus 4.6 0ce20b9cd2 refactor(meta): compact CLAUDE.md from 188 to 67 lines
Move CLI reference material, endpoint configs, and orchestration
patterns to .claude/rules/ (auto-loaded). CLAUDE.md now focuses on
project identity, safety rules, and workflow conventions.

Update cross-references in ticket, docs-search, and git-commit
skills to point to new rules/ locations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:37:29 +01:00
jpmschweitzerandClaude Opus 4.6 465f2bdf98 feat(config): add .claude/rules/ for modular auto-loaded instructions
Introduces 5 rules files following Anthropic's recommended pattern
for splitting large CLAUDE.md files. Rules auto-load every session
and separate CLI reference material from core project identity.

- tea-cli.md: Gitea CLI reference, PR creation, protected branches
- git-safety.md: staging rules, commit conventions (from git-commit skill)
- project-structure.md: full annotated directory tree
- team-patterns.md: model selection, large content push pattern
- local-services.md: Gitea/Qdrant/Ollama endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:37:23 +01:00
jpmschweitzer 7ec0eab541 Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-24 12:19:35 +01:00
jpmschweitzerandClaude Opus 4.6 2a4f096b4a Merge remote-tracking branch 'origin/copy'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:17:17 +01:00
jpmschweitzerandClaude Opus 4.6 6134c26afc Merge remote-tracking branch 'origin/visual'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:16:52 +01:00
jpmschweitzerandClaude Opus 4.6 f5a866fc4d fix(content): PR #61 round 2 — schema fields, cooldown, CHANGELOG sections
Contradiction files:
- Add role/access/trust back to all lines (schema-required)
- prerequisite: → prerequisites: (plural, matching schema)
- Remove cooldown entirely — fire-once enforced by trigger semantics
  (contradiction_detected fires once per KG state change), not magic numbers

Tutorial files:
- Remove cooldown: -1 — same reasoning, first_* triggers are one-time

Server:
- Document cooldown: Option<i32> on MonologueLine — None means no
  per-line cooldown, fire-once relies on trigger design

CHANGELOG:
- Move displaced entries from ### Fixed back to ### Changed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:05:16 +01:00
jpmschweitzerandClaude Opus 4.6 ffaf635e9a chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:56:54 +01:00
jpmschweitzerandClaude Opus 4.6 99a7841bf2 feat(config): add tea-comment wrapper for Gitea PR comments
Single-command wrapper that handles temp file creation and cleanup
so tea comment works reliably with multi-line strings. Pre-approved
in settings.json, referenced in CLAUDE.md and pr-review skill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:56:40 +01:00
jpmschweitzerandClaude Opus 4.6 b544303f29 docs(meta): allow removing own worktree index.lock in CLAUDE.md
Adds explicit exception to worktree boundary rules: agents may
remove their own worktree's stale index.lock file from the shared
.git directory, but never another worktree's lock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:37:37 +01:00
jpmschweitzerandClaude Opus 4.6 766a8b9c6d fix(content): PR #61 review — schema compliance for contradiction + tutorial
Contradiction files (both characters):
- location: contradiction-detected → contradiction (valid content ID)
- situation: removed invalid values (contradiction, reflection)
- prerequisites: (plural) → prerequisite: (singular) with canonical entities map
- Removed source_name from prerequisites (event payload, not KG gate)
- Removed phase: field (moved to tags as phase-2/phase-3)
- trigger: contradiction_processing → contradiction_detected with priority sequencing
- Removed dialogue-only fields (role, access, trust) from monologue lines
- Phase 3 detective: prerequisite uses torek-lintar PersonOfInterest
- Phase 3 smuggler: prerequisite uses tells_observed on entity

Tutorial files (both characters):
- priority: "tutorial" (string) → 8/6 (integer)
- situation: removed invalid values (movement, exploration)

CHANGELOG: merged duplicate ### Changed sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:32:43 +01:00
jpmschweitzerandClaude Opus 4.6 821d716509 fix(client): address PR #62 review suggestions — polish pass
- interaction_list.gd: skip queue_redraw() when position unchanged (Tyre #4)
- time_display.tscn: widen bounding rect 154x44→184x54, add clip note
  for #314 wireframe (Tyre #3)
- test_time_display_sprint17.gd: document private state access pattern
  in header (Tyre #5), add upper bound assertion for D-031 1439 max (Hoshe #6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:30:24 +01:00
jpmschweitzerandClaude Opus 4.6 16c00e137e fix(client): address PR #62 review — input guard, public API, geometry cache
- constants.gd: clamp format_game_time input to 0..1439 (Hoshe #2)
- interaction_list.gd: add public hide_list() wrapper (Hoshe #3, Tyre #1)
- main.gd: call hide_list() instead of private _hide()
- time_display.gd: cache font geometry in update_from_state(), use boolean
  _has_data flag instead of string guard (Tyre #2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:28:43 +01:00
jpmschweitzerandClaude Opus 4.6 d8eb256dad test(client): align sprint 17 tests with implementation (#263, #537)
Fix test API mismatches: time display tests target InsertOverlay/
TimeDisplay and time_display.gd; E-Talk tests rewritten to target
interaction_list.gd _cache_entity_relationship() and _relationship_color
state. Phase 2 tests (name, tier hint) marked as skip placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:22:39 +01:00
jpmschweitzerandClaude Opus 4.6 2c15ad65e1 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:46 +01:00
jpmschweitzerandClaude Opus 4.6 6c3a30a4bc feat(ui): add relationship color accent to E-Talk overlay (#537)
Phase 1: interaction_list.gd shows a 3px left-edge accent bar in
D-033 relationship color (teal/green/amber/red) at 85% alpha.
Cross-references entity_id against visible_entities via
_cache_entity_relationship(). NPC name and tier hint deferred to
Phase 2 (requires server protocol extension).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:33 +01:00
jpmschweitzerandClaude Opus 4.6 66a435e201 feat(ui): add diegetic time display on insert HUD (#263)
Time display on InsertOverlay (CanvasLayer 10) shows station local
time (HH:MM), day phase with cycle-tinted color, and day number.
Reads SimulationTime from GameState.game_time via update_from_state().
Adds Constants.format_game_time() helper for testability.
Placeholder layout — position refines when #314 wireframe lands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:23 +01:00
jpmschweitzerandClaude Opus 4.6 5106358113 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:15:05 +01:00
jpmschweitzerandClaude Opus 4.6 619fbbd9f3 docs(meta): document model selection and 1M context option
Add model selection section to CLAUDE.md covering /model sonnet[1m]
and opus[1m] for extended context sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:51 +01:00
jpmschweitzerandClaude Opus 4.6 c1c8626263 refactor(skills): lighten sprint-status context footprint
Delegate sprint-status to a haiku subagent (sprint-plan pattern) so
intermediate artifacts (sweep JSON, template read, PR list) stay out
of the main context window. Trim sweep JSON output by removing unused
fields (ok, sprint.status, priority, ticket_id) and shortening issue
detail strings. Condense output template by moving rendering rules
into SKILL.md and simplifying the bookkeeping table to two columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:47 +01:00
jpmschweitzerandClaude Opus 4.6 badc0a6f20 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:13 +01:00
jpmschweitzerandClaude Opus 4.6 da34b92421 feat(content): author diegetic tutorial monologue lines — per character (#330)
10 lines per character teaching 5 core mechanics diegetically:
movement/exploration, fog of perception, sound model, NPC interaction,
insert/HUD. Detective lines in analytical register, smuggler lines in
street-smart fragments. Fires on first-time events (first_move,
first_fog_encounter, first_sound_heard, first_npc_proximity,
first_insert_open, etc.). _tut_ ID discriminator, cooldown -1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:01 +01:00
jpmschweitzerandClaude Opus 4.6 fa4d30a951 feat(content): author contradiction monologue lines — Sera/Kael FRIEND arc (#552)
Source-named contradiction monologue for both detective and smuggler.
Detective: Sera/Kael location contradiction (Phase 2 blindsiding +
Phase 3 pattern recognition with Torek avoidance). Smuggler: Kael in
B-7 with unrecognized contact (Phase 2 gut reaction + Phase 3
behavioral pattern accumulation). Cognitive-dissonance-not-accusation
tone per D-083. 16 hand-authored lines total (8 per character).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:13:54 +01:00
jpmschweitzerandClaude Opus 4.6 5f0e19547f chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:06:34 +01:00
jpmschweitzerandClaude Opus 4.6 9bda80174c docs(ui): add insert/HUD wireframe spec — dual character variants
Pixel-precise wireframe and visual specification for the neural lattice
overlay HUD (#314). Covers smuggler (social network view) and detective
(investigation overlay) variants via lattice_profile parameter.

Specifies: time display, entity markers, location labels, border arrows,
commission grid, inventory icons, stance indicator, and all interaction
states. Includes implementation notes for Stig (#263 time display).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:06:25 +01:00
jpmschweitzerandClaude Opus 4.6 381526ff74 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:35:52 +01:00
jpmschweitzerandClaude Opus 4.6 1627942627 feat(skills): add sprint-status cleanup sweep skill
Orchestrates sprint sweep + tea pr list to produce a consistent
health report: tickets by status, PR cross-reference, bookkeeping
issues, and open work by team summary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:35:38 +01:00
jpmschweitzerandClaude Opus 4.6 2fb8e95d53 feat(db): add sprint sweep subcommand for health checks
Structured JSON output with tickets grouped by status, per-team
summary counts, and bookkeeping issue detection (unassigned
in_progress, stale backlog, assigned but done).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:35:33 +01:00
jpmschweitzerandClaude Opus 4.6 35a3fe20cb Merge remote-tracking branch 'origin/planning'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:17:20 +01:00
jpmschweitzerandClaude Opus 4.6 e91610862a chore(db): backup database after client and copy merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:15:29 +01:00
jpmschweitzer f163658ed1 Merge remote-tracking branch 'origin/client' 2026-02-24 02:15:16 +01:00
jpmschweitzerandClaude Opus 4.6 2001d89b6c chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:12:22 +01:00
jpmschweitzerandClaude Opus 4.6 0f1b951691 docs(docs): update Sprint 17 briefings with workshop results
Reconcile sprint planning after Knowledge Flow workshop:
- Server briefing: add #545-#551 workshop tickets, correct dependency
  chain, reference D-079–D-083, remove stale "blocked by workshop"
- Joint briefing: mark workshop complete, add completion proofs #9
  (contradiction) and #10 (NPC-to-NPC transfer), update teams table
- Copy briefing: add #552 contradiction monologue ticket with full
  Mellanie authoring spec per D-083 and Paula Round 2 output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:12:00 +01:00
jpmschweitzerandClaude Sonnet 4.6 f685cb7324 chore(meta): register D-079–D-083, close Q-024/Q-025/Q-026, ticket Sprint 17 knowledge flow work
Workshop outputs from Knowledge Flow & NPC Information Boundaries workshop (2026-02-24):

Decisions registered in decisions/perception.md:
- D-079: Knowledge Grant Architecture (unified KnowledgeGranted event, Fact+Entity enum, ContentEntityRegistry)
- D-080: NPC-to-NPC Knowledge Propagation (transfer_npc_knowledge system, trust-tier gating, ToldBy source)
- D-081: Unprompted Disclosure Design (DisclosureCandidates component, two-stage trait filter, trigger gates)
- D-082: NPC Information Boundaries MVP Scope (tell_state + disclosure; pathfinding KG integration explicitly not planned)
- D-083: Contradiction Detection Pipeline (event-driven at observe_entity, ContradictionClaim struct, downstream chain)

Questions closed in decisions/questions.md:
- Q-024: closed by D-080 (immediate during conversation, not queued)
- Q-025: confirmed closed (gossip propagation ships Sprint 17; ~12MB peak, no cap needed before v0.3)
- Q-026: closed by D-083 (event-driven at KG write time, ContradictionClaim struct)

Tickets created (#545–#551, Sprint 17, team server):
- #545 KnowledgeGrant schema + ContentEntityRegistry (critical, blocks all downstream)
- #546 KnowledgeGranted event + process_knowledge_events handler
- #547 ContradictionClaim struct + detection in observe_entity
- #548 NPC-to-NPC knowledge transfer system
- #549 tell_state.rs KG awareness — MVP information boundary
- #550 Contradiction monologue + event chain completion
- #551 DisclosureCandidates + unprompted disclosure

Existing tickets updated: #141, #142 (sprint 17 + team assigned, descriptions updated); #172, #173 (full mechanical spec from D-081).

Workshop source files committed: all round docs + workshop-outcomes.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:59:58 +01:00
jpmschweitzer 0561387de9 Merge remote-tracking branch 'origin/copy' 2026-02-23 23:25:12 +01:00
jpmschweitzerandClaude Opus 4.6 abff69a632 chore(meta): Sprint 17 planning — briefings, workshop brief, close Q-025
- Write sprint 17 briefings for server, client, copy, visual, joint
- Add Knowledge Flow & NPC Boundaries workshop brief
- Close Q-025 (KG cap/eviction not needed at current scale)
- Assign 9 tickets to Sprint 17: Tell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:25:08 +01:00
jpmschweitzerandClaude Opus 4.6 0eafff89e0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:37:24 +01:00
jpmschweitzerandClaude Opus 4.6 414b8ff0ec docs(decisions): register Q-028 collision-resistant line IDs for auto-gen NPCs
NPC-scoped line ID scheme (D-035) works for hand-authored content but
will produce slug collisions with auto-generated populations (D-029).
Tracked as open question with ticket #544.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:37:12 +01:00
jpmschweitzerandClaude Opus 4.6 56ed2c8810 fix(content): PR #59 review — stale moods, orphaned IDs, tenure, fact_id
Address review comments from Hoshe, Paula, and Miri:
- Update mood vocabulary in 3 docs (line-pool-format.md §6.5,
  style-guide §11, content-directory-structure.md Appendix B) from
  pre-Sprint 14 values to current D-035 enum
- Fix worked example IDs in line-pool-format.md §3.5 to match
  actual the-last-shift kael-davan sequence (_015, _024, _026)
- Fix Section 5.1 restart note to describe multi-location continuity
- Fix style-guide §16 worked example: dock-worker_d_071 → kael-davan_d_076
- Fix stale mood reference in style-guide §16 Step 3
- Fix orphaned the-terminal_d_040 in maintenance-tech.yaml comment
- Fix orphaned the-terminal_d_008/018 in smuggler-inventory.yaml
- Fix Lera tenure: twelve → eighteen years (bar-owner_d_018)
- Fix fact_id: location.surveillance_gaps → investigation.surveillance_gaps
  in ring-operative.yaml (2 occurrences)
- Fix NPC name: Lera Osk → Lera Sessik in bar-owner.yaml comment
- Fix mood line format example in style-guide §5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:35:12 +01:00
jpmschweitzerandClaude Opus 4.6 0ebd417a1f test(client): add direction mapping tests for octant and entity facing (#540 review)
14 tests covering _octant_to_direction (all 8 octants + 2 fallbacks)
and _entity_direction (NPC default south, player facing 3 cases).
Closes review warning on zero test coverage for direction system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:35 +01:00
jpmschweitzerandClaude Opus 4.6 56b8381c40 fix(client): guard null texture and warn on unknown octant (#540 review)
Add push_error on null texture at create time, keep previous texture
on null at update time (entity stays visible mid-game). Add push_warning
on unrecognised octant in _octant_to_direction fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 892491dea2 chore(meta): update changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:04:55 +01:00
jpmschweitzerandClaude Sonnet 4.6 c056ea1a1c feat(content): migrate line IDs to NPC-scoped namespace (D-035)
Line IDs in all dialogue and monologue pool files renamed from the
old location-scoped format (e.g. the-terminal_d_039) to the new
NPC-scoped format (e.g. kael-davan_d_001) per the D-035 Sprint 15
amendment.

Changes:
- 20 dialogue pool files across 3 locations renamed
- 14 monologue pool files (detective + smuggler) renamed
- Schema descriptions updated in dialogue/monologue schema files
- Authoring style guide and design docs updated with new examples
- Multi-location NPCs (kael-davan, pc-detective, pc-smuggler) given
  globally unique cross-file sequences to satisfy XREF uniqueness check

The location-scoped scheme already caused a collision (the-terminal_d_039
appearing in multiple NPC files) and would not scale to procedurally
generated NPC populations (D-029). Zero content changes — pure ID
substitution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:04:43 +01:00
jpmschweitzerandClaude Opus 4.6 f67f13ac95 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:01:48 +01:00
jpmschweitzerandClaude Opus 4.6 a0260176b4 feat(client): integrate D-019 angle sprites into entity renderer (#540)
Migrate entity rendering from ColorRect placeholders to Sprite2D with
rendered PNGs at -72.5° from horizontal. Key changes:
- Sprite2D.centered=false, scale=0.5 for 64px source → 32px runtime
- self_modulate for D-033 relationship tinting (modulate.a reserved
  for D-015 peripheral dimming)
- 8-octant to 4-cardinal direction mapping for sprite selection
- Feet-anchored ENTITY_OFFSET_Y for correct y-sort with tilted sprites
- Facing indicator repositioned to sprite local center (32,32)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:01:30 +01:00
jpmschweitzerandClaude Opus 4.6 ebc973b558 refactor(client): optimize zone_id extraction from O(N) to O(1) lookup (#543)
Build _tile_by_coord dictionary from member visible_tiles (covers both
test-mode "tiles" key and live-server "visible_tiles" key), then replace
the linear scan with a single dict lookup. Net-zero complexity: adds one
dict-set per tile in an existing iteration, removes the separate scan loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:01:22 +01:00
jpmschweitzerandClaude Opus 4.6 41e3b4a9b3 chore(db): backup database after server merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:35:46 +01:00
jpmschweitzer 459f0f5e66 Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
2026-02-23 20:35:33 +01:00
jpmschweitzerandClaude Opus 4.6 f331916869 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:32:12 +01:00
jpmschweitzerandClaude Opus 4.6 a4d5f0895b chore(skills): keep sprint team alive through PR review lifecycle
sprint-start: add step 9 (post-work lifecycle) — commit, push, review,
fix-comments loop, approve, then shutdown. Team stays alive until PR is
accepted. Step 8e updated to reference the new lifecycle.

pr-push: add step 9 (team awareness) — don't shut down agents after
pushing, suggest /pr-review next.

pr-review: add step 8 (post-review team actions) — cross-references
sprint-start step 9c for dispatching review comments to agents on
CHANGES_REQUESTED and shutdown on APPROVED.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:31:47 +01:00
jpmschweitzer f2d006fddf Merge remote-tracking branch 'origin/server' 2026-02-23 20:25:39 +01:00
jpmschweitzerandClaude Opus 4.6 2189a9addd fix(simulation): address PR #56 review — pipeline extraction, ActiveDialogue, range check
Tyre #1: Extract run_dialogue_pipeline() shared helper — eliminates ~60
lines of duplication between process_talk_interaction and
process_dialogue_response (L1-L4 pipeline).

Hoshe #1: process_dialogue_response now updates ActiveDialogue with
current tick on follow-up selection — prevents stale started_tick.

Tyre #4: process_dialogue_response now updates InteractionMemory on
follow-up — multi-turn conversations are visible in history.

Hoshe #6 / Tyre #6: handle_dialogue_response adds server-side range
check (CLOSE_RANGE), matching Talk/Confront pattern (D-010 info
boundary).

Hoshe #2: Weighted selection fallback replaced with unreachable!() —
score_line always returns >= 1, so the fallback was dead code.

Hoshe #3: assert!(false, ...) → panic!() in serialization.rs (clippy).

Hoshe #4: SetFacing and TeleportToHub added to roundtrip test.

Hoshe #5: setup_dialogue_response_world inlined (trivial pass-through).

Tyre #2: Doc comment on DialogueCooldownTracker explains per-player-global
design choice (line IDs are NPC-scoped per D-035, no collision risk).

Tyre #3: CONFRONTATION_LINES comment updated with TODO for D-028/D-035
migration.

Tyre #5: DialogueResponse fixture added for cross-language GDScript
testing (input_dialogue_response.msgpack).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:20:00 +01:00
jpmschweitzerandClaude Opus 4.6 19f81d4887 fix(assets): PR #57 review — docs, uid, citation fixes
- Fix client sprite naming in README (files are _64.png, not .png)
- Clarify --path working directory (renderer/ from repo root)
- Remove /sprite-gen reference (skill not in branch yet)
- Add D-043 citation on outline color in render_export.gd
- Add uid to wall_bar_green.tscn for reproducible imports
- Note wall_bar_green has no rendered sprites yet
- Note npc_generic capsule symmetry is by design (D-044)
- Add outline + D-033 tint compatibility guidance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:17:47 +01:00
jpmschweitzerandClaude Opus 4.6 35594b7f14 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:02:04 +01:00
jpmschweitzerandClaude Opus 4.6 885ad21688 feat(assets): add test sprites for NPC and wall at D-019 angle (#541)
Runtime 64px sprites rendered through the new pipeline: generic NPC
(4 directions) and structural wall (4 directions). These unblock
client ticket #540 for sprite camera angle integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:01:49 +01:00
jpmschweitzerandClaude Opus 4.6 8217ebc0fb feat(assets): 3D sprite render pipeline — camera, lighting, docs (#541)
Update Camera3D to exact D-019 angle (-72.5° from horizontal).
Replace single DirectionalLight with three-point studio rig
(key 1.0, fill 0.4, rim 0.3) for clean silhouettes without
baked shadows. Add generic NPC capsule model (24×32px footprint
per D-044). Document full pipeline spec in renderer/README.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:01:43 +01:00
jpmschweitzerandClaude Opus 4.6 efa2dc543f feat(simulation): sprint 16 dialogue server — response handler, trust gossip, variety tracker
Move dialogue system registrations from BridgePlugin to NpcPlugin (#538):
game logic that depends on NPC-layer resources now registers where it
belongs. BridgePlugin retains only wire protocol concerns.

Implement DialogueResponse verb handler (#539): new process_dialogue_response
system runs the full D-028 four-layer pipeline to select follow-up lines
when the player picks a dialogue option. Clears ActiveDialogue when no
candidates remain. Fix latent schedule ambiguity — emit_observation_events
now has explicit .before(advance_tick) constraint.

Verify trust-gated gossip pipeline (#171): confirmed process_talk_interaction
correctly passes KnowledgeConfidence through relationship_to_trust() per
D-075. Added integration tests for Secret-tier access (Friendly+KnowsDetails)
and Surface-only fallback (Friendly+Suspects).

Wire DialogueCooldownTracker into selection (#338): added regression test
confirming no line_id repeats within the 600-tick cooldown window across
10 consecutive Talk interactions.

Closes #538, #539, #171, #338

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:01:39 +01:00
125 changed files with 10351 additions and 1173 deletions
+16
View File
@@ -0,0 +1,16 @@
# Git Safety
## Staging rules
- **Stage files by name** — never use `git add -A` or `git add .`
- Verify no secrets, saves, or binary blobs are staged
- Skip files in `.gitignore`
- The `.claude/` directory IS tracked — skills and agents belong in the repo
## Commit conventions
Use conventional commits: `<type>(<scope>): <summary>`
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
+8
View File
@@ -0,0 +1,8 @@
# Local Services
Endpoints are also preconfigured in `db/connectors/config.json`.
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
- **Qdrant:** `http://tower-of-joy:6333/`
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
+40
View File
@@ -0,0 +1,40 @@
# Project Structure (detailed)
```
client/ # Godot 4 client
server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests
.config/ # Configuration files (linters, formatters, CI)
.cache/ # Local caches for testing/linting (gitignored)
docs/
discussions/ # Discussion rounds (archived here when complete)
briefings/ # Per-agent context briefings (maintained by Qatux)
architecture/ # Technical architecture documents
design/ # Game design documents
diagrams/ # d2 source + PNG renders
sprints/ # Sprint briefings per team
workshops/ # Workshop briefs and outputs
db/
schema.sql # Database schema
connectors/ # Connector scripts for SQLite and Qdrant
config.json # Endpoint configuration
ticket # Ticket CLI
sqlite_connector.py # SQLite mini MCP
qdrant_connector.py # Qdrant + ollama mini MCP
.claude/
agents/ # Agent personality files
skills/ # Skill definitions
rules/ # Auto-loaded instruction modules
decisions/ # Decision domain files (source of truth)
README.md # Domain index — use this to find specific D-records
architecture.md # Architecture decisions
perception.md # Perception and information system decisions
content.md # Content and narrative decisions
scope.md # Scope and feature decisions
process.md # Process and workflow decisions
questions.md # Open questions (Q-NNN)
rejected.md # Rejected proposals (R-NNN)
DECISIONS.md # Redirect to decisions/ directory
TEAM.md # Team roster and roles
```
+45
View File
@@ -0,0 +1,45 @@
# Gitea Access (tea CLI)
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
```bash
# List open PRs
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
# View a PR with comments
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
# Post a comment on a PR (or issue)
tooling/tea-comment <NUMBER> "comment body"
# Approve a PR
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
# List issues
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
## 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)
- **For comments, use `tooling/tea-comment <number> "body"`** — handles temp files and cleanup automatically. Works with multi-line strings.
- **`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.
## Pull requests
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
Always provide all required flags to ensure non-interactive execution:
```bash
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "feat(scope): short description" \
--description "PR body here" \
--base main \
--head branch-name
```
+19
View File
@@ -0,0 +1,19 @@
# Team Patterns
## Model selection
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
sprint planning, large reviews), switch to extended context on-demand:
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
- `/model opus[1m]` — Opus 4.6 with 1M context window
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
## Large content pushes
When producing many files (wiki pages, content batches, bulk docs):
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
+1
View File
@@ -44,6 +44,7 @@
"Bash(make)",
"Bash(tea *)",
"Bash(tooling/tea-comment *)",
"Bash(chmod *)",
"Bash(ls *)",
+3 -3
View File
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
# Search Docs Skill
Semantic search across project documents. Basic commands (`qdrant-search`,
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
in CLAUDE.md. This skill covers advanced operations and workflows.
Semantic search across project documents. Endpoints are in
`.claude/rules/local-services.md`. This skill covers advanced operations
and workflows.
## Advanced Commands
+2 -4
View File
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
## Staging Rules
- Stage files by name — never use `git add -A` or `git add .`
- Verify no secrets, saves, or binary blobs are staged
- Skip files in `.gitignore`
- The `.claude/` directory IS tracked — skills belong in the repo
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
These apply to ALL git operations, not just this skill.
+9 -5
View File
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
```bash
cat > /tmp/pr-body.md << 'EOF'
## Summary
...
EOF
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "<title>" \
--description "$(cat /tmp/pr-body.md)" \
--description "## Summary ..." \
--base main \
--head <branch>
```
@@ -134,6 +130,14 @@ Report which tickets were moved to review. Skip tickets that are
already `done`, `review`, `cancelled`, or `backlog` (only transition
`in_progress` → `review`).
### 9. Next steps
If a sprint team is active (you are the team lead), do NOT shut down
agents after pushing. The team should remain alive for PR review and
potential comment fixes.
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
## Arguments
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
+19 -11
View File
@@ -174,19 +174,11 @@ After presenting results to the user, post the review as a PR comment.
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
Post using the `tea-comment` wrapper (handles temp files and cleanup):
```bash
tooling/tea-comment <PR_NUMBER> "review markdown here"
```
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
# Step 2: Post to Gitea (separate Bash call)
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
```
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
directory is gitignored and exists in the repo root for this purpose.
## 7. Merging approved PRs
@@ -203,6 +195,22 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
Gitea does **not** auto-close PRs when you push a local merge — always close
manually with `tea pr close` after pushing.
### 8. Post-review team actions
If a sprint team is active and you are the team lead, handle the
review outcome:
**CHANGES_REQUESTED:**
The sprint-start lifecycle (step 9c) handles dispatching review
comments to agents. After presenting results, remind the lead:
"Review requested changes. Create tasks from the warnings/critical
issues and dispatch to idle agents, then re-push and re-review."
**APPROVED:**
The sprint-start lifecycle (step 9c) handles shutdown. After
presenting results, remind the lead: "Review approved. Proceed with
team shutdown per sprint-start step 9c."
## Tips from practice
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
+54
View File
@@ -325,3 +325,57 @@ Output to the user:
You are now the team lead. Agents work autonomously — monitor via
`TaskList`, communicate via `SendMessage`, and handle blockers as
they arise.
**When all tasks complete:** Do NOT shut down agents. The team stays
alive through the PR review cycle. Follow step 9 (post-work lifecycle).
### 9. Post-work lifecycle
When all tasks are complete (TaskList shows all completed):
#### 9a. Commit and push
Run `/git-commit` to commit all changes, then `/pr-push` to create or
update the PR. Do NOT shut down agents — the team stays alive for review.
#### 9b. Review
Run `/pr-review` to spawn temporary reviewers. Wait for results.
#### 9c. Handle review outcome
**If CHANGES_REQUESTED:**
1. Parse the review comment table (from the Gitea PR comment or the
review output). Extract each warning/critical issue with:
- File path and approximate line
- Severity (critical / warning / suggestion)
- Description
2. Create a task per warning/critical issue:
```
TaskCreate(
subject: "Review: {short description}",
description: "{full issue description from review table, including
file path, severity, and reviewer name}",
activeForm: "Fixing review comment: {short description}"
)
```
Skip suggestion-severity items unless they are trivial (1-line fixes).
3. Dispatch to idle agents: send each a message via SendMessage telling
them to check TaskList for new review-fix tasks. Agents claim and
work tasks as usual.
4. After all review-fix tasks are complete, re-run `/git-commit` then
`/pr-push` to update the PR. Then re-run `/pr-review`.
5. Repeat this loop until review returns APPROVED.
**If APPROVED:**
1. Send `shutdown_request` to all sprint agents.
2. Wait for all `shutdown_response` confirmations.
3. Call `TeamDelete` to clean up.
4. Report: "Sprint {N} {team} complete. PR #{X} approved and ready for
merge on main."
+94
View File
@@ -0,0 +1,94 @@
---
name: sprint-status
description: >
Sprint health check and cleanup sweep. Lists all tickets in the active
sprint grouped by status, detects bookkeeping issues (stale tickets,
orphan PRs, done-but-open PRs, unassigned work), and shows open work
by team. Use when checking sprint progress, before sprint close, or
when housekeeping feels off. Triggers on "sprint status", "cleanup
sweep", "what's open", "sprint health".
user-invocable: true
allowed-tools: Task, Read, Grep, Glob
---
# Sprint Status
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
When this skill is invoked, spawn a subagent using the Task tool:
```
Task(
subagent_type: "general-purpose",
model: "haiku",
prompt: "Run /sprint-status. Read the skill at
.claude/skills/sprint-status/SKILL.md for the full workflow
(below the --- separator), then execute it.",
description: "Sprint status report"
)
```
Present the subagent's output to the user verbatim. Do NOT run the
workflow yourself.
---
The remainder of this file is the subagent's reference for executing
the workflow.
## Step 1 — Gather data
Run these two commands in parallel:
```bash
db/connectors/sprint sweep
```
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
The `sweep` command returns JSON with:
- `sprint` — id, name, goal
- `progress` — total, done, pct
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
- `by_team` — per-team counts
- `issues` — bookkeeping problems with suggested fix commands
The `tea pr list` returns open PRs as `#N title` lines.
## Step 2 — Cross-reference PRs with tickets
Parse PR head branches from the `tea pr list` output. Known team branches:
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
Detect additional issues:
- **done_team_open_pr**: A team's tickets are all done but an open PR
still exists for that team branch.
- **orphan_pr**: An open PR exists on a branch that has no tickets in
the active sprint.
Add these to the issues list from step 1.
## Step 3 — Format output
Read `references/output-template.md` for the exact format spec.
Render the report using data from steps 1-2. Key rules:
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
- Sort tickets within sections by team then ticket ID
- Empty sections: show header with "(0)" and "(none)" — no empty table
- Bookkeeping Issues: two-column table (Issue, Fix)
- Open Work by Team: summary table at the bottom
- Issue type labels: `stale_backlog` → "Stale backlog",
`unassigned_in_progress` → "Unassigned in_progress",
`assigned_but_done` → "Assigned but done",
`done_team_open_pr` → "Done team with open PR",
`orphan_pr` → "Orphan PR"
## Step 4 — Suggest actions
After the formatted report, if there are bookkeeping issues, add a
"Suggested fixes" section with the fix command for each issue. Group
by issue type for readability.
@@ -0,0 +1,54 @@
# Sprint Status Output Template
## Sprint {N}: {Theme} — Status Report
**Goal:** {goal}
**Status:** {status} | {done}/{total} tickets ({pct}%)
**Open PRs:** {count} ({branches})
---
### Completed ({count})
| # | Team | Title | Assigned |
|---|------|-------|----------|
| #{id} | {team} | {title} | {assigned} |
### In Review ({count})
| # | Team | Title | PR |
|---|------|-------|----|
| #{id} | {team} | {title} | #{pr} |
### In Progress ({count})
| # | Team | Title | Assigned | Note |
|---|------|-------|----------|------|
| #{id} | {team} | {title} | {assigned} | |
### Blocked ({count})
| # | Team | Title | Blocked by |
|---|------|-------|------------|
| #{id} | {team} | {title} | #{ids} |
### Backlog ({count})
| # | Team | Title | Note |
|---|------|-------|----|
| #{id} | {team} | {title} | not started |
---
### Bookkeeping Issues
| Issue | Fix |
|-------|-----|
| {type}: {detail} | `{command}` |
### Open Work by Team
| Team | Backlog | In Progress | Review | Blocked | Done |
|------|---------|-------------|--------|---------|------|
| {team} | {n} | {n} | {n} | {n} | {n} |
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
+2 -3
View File
@@ -10,9 +10,8 @@ allowed-tools: Bash, Read, Grep, Glob
# Ticket Skill
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
This skill covers the full command reference.
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
section. This skill covers the full command reference.
## Commands
+55
View File
@@ -6,6 +6,61 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
- 7 knowledge graph implementation tickets (#545–#551) with full dependency chain and line estimates
- Contradiction monologue content ticket (#552) for Sera/Kael FRIEND arc
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
### Changed
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
- Sprint 16 closed (8/8 done)
- 3D sprite render pipeline — Camera3D at D-019 angle (-72.5° from horizontal), three-point studio lighting rig, orthographic projection, resolution chain 1024→256→64
- Generic NPC capsule model (24×32px footprint per D-044) and structural wall model for pipeline validation
- Test sprites: 8 runtime 64px sprites (NPC + wall × 4 directions) deployed to client/assets/sprites/
- Pipeline documentation (renderer/README.md) — camera spec, lighting rig, resolution chain, model authoring guide
- DialogueResponse verb handler — players pick dialogue options and receive follow-up lines via full D-028 four-layer pipeline (#539)
- Trust-gated gossip verification — integration tests confirm Secret/Real/Surface tier gating per D-075 (#171)
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
- DialogueResponse cross-language fixture for GDScript testing
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
- pr-push and pr-review skills updated with team lifecycle awareness
### Fixed
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
- PR #59 review: orphaned location-scoped IDs in maintenance-tech.yaml comments and smuggler-inventory.yaml cross-references updated to NPC-scoped
- PR #59 review: Lera Sessik tenure corrected from "twelve years" to "eighteen years", NPC header fixed
- PR #59 review: ring-operative.yaml fact_id corrected from `location.surveillance_gaps` to `investigation.surveillance_gaps`
- Dialogue systems moved from BridgePlugin to NpcPlugin — game logic registers where it belongs (#538)
- Schedule ambiguity: emit_observation_events now has explicit .before(advance_tick) constraint
- process_dialogue_response updates ActiveDialogue tick and InteractionMemory on follow-up
- DialogueResponse range check added (CLOSE_RANGE, matching Talk/Confront pattern)
- Weighted selection fallback replaced with unreachable!() — dead code removed
- assert!(false) → panic!() in serialization tests (clippy)
- SetFacing and TeleportToHub added to roundtrip test coverage
## [v0.1.15] — 2026-02-23
### Added
+26 -141
View File
@@ -1,51 +1,26 @@
# The Settled Reach
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
**Official Title:** The Settled Reach (D-021)
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
**Official Title:** The Settled Reach
**Repository name:** settled-reach
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
## Project Structure
```
client/ # Godot 4 client (D-020)
server/ # Rust/bevy_ecs simulation server (D-020)
client/ # Godot 4 client
server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests
.config/ # Configuration files (linters, formatters, CI)
.cache/ # Local caches for testing/linting (gitignored)
docs/
discussions/ # Discussion rounds (all rounds archived here per D-022)
briefings/ # Per-agent context briefings (maintained by Qatux)
architecture/ # Technical architecture documents
design/ # Game design documents
diagrams/ # d2 source + PNG renders (architecture, data-flow, entity, state, ui)
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
db/
schema.sql # Database schema
connectors/ # Connector scripts for SQLite and Qdrant
config.json # Endpoint configuration
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
sqlite_connector.py # SQLite mini MCP
qdrant_connector.py # Qdrant + ollama mini MCP
.claude/
agents/ # Agent personality files
skills/ # Skill definitions
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, 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
questions.md # Q-001 through Q-011
rejected.md # R-001 through R-010
DECISIONS.md # Redirect to decisions/ directory
TEAM.md # Team roster and roles
docs/ # Architecture, design, briefings, sprints, workshops
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
.claude/ # Agents, skills, rules
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
```
Full annotated tree: `.claude/rules/project-structure.md`
## DevOps
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
@@ -54,15 +29,12 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
### Worktree boundaries
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- Do not navigate above the git root unless explicitly instructed.
- **All work must remain within the git root** unless explicitly instructed otherwise.
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
### Database
@@ -74,73 +46,18 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
### Ticket and database access
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
```bash
db/connectors/ticket list --sprint 2 --team server
db/connectors/ticket show 78
db/connectors/ticket sprint --active
```
### CLI tools
### Sprint CLI
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
```bash
db/connectors/sprint status # Current sprint progress
db/connectors/sprint status --team server # Team-scoped view
db/connectors/sprint start-work --team client # Full context dump for starting work
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
db/connectors/sprint start # Activate a planned sprint
db/connectors/sprint stop # Complete an active sprint
```
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
```bash
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
```
### Qdrant / document search
```bash
db/connectors/qdrant-search "asymmetric information design"
db/connectors/qdrant-index docs/briefings/tyre.md
db/connectors/qdrant-health
db/connectors/qdrant-count
```
### Gitea access (tea CLI)
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
```bash
# List open PRs
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
# View a PR with comments
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
# Post a comment on a PR (or issue)
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
# Approve a PR
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
# List issues
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
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.
| Tool | Command | Full reference |
|------|---------|----------------|
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
### File conventions
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
@@ -149,35 +66,3 @@ Key rules:
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
- Briefings: one per agent, updated after decision-producing rounds
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
### Commit conventions
Use conventional commits with project-specific scopes:
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
### Pull requests
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
Always provide all required flags to ensure non-interactive execution:
```bash
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "feat(scope): short description" \
--description "PR body here" \
--base main \
--head branch-name
```
### Large content pushes (team pattern)
When producing many files (wiki pages, content batches, bulk docs):
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
### Local services
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
- Qdrant: `http://tower-of-joy:6333/`
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
- Collection: `commonwealth` (768 dimensions, cosine distance)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+5 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=24 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"]
@@ -22,6 +22,7 @@
[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"]
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -118,6 +119,9 @@ zoom = Vector2(2, 2)
[node name="InsertOverlay" type="CanvasLayer" parent="."]
layer = 10
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
+10 -10
View File
@@ -240,16 +240,16 @@ func apply_snapshot(snapshot: Dictionary) -> void:
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
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
+5
View File
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 1200
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
static func format_game_time(time_of_day: int) -> String:
var clamped: int = clampi(time_of_day, 0, 1439)
return "%02d:%02d" % [clamped / 60, clamped % 60]
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
+6 -1
View File
@@ -14,6 +14,7 @@ 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 time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
@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)
@@ -104,7 +105,7 @@ func _process(delta: float) -> void:
if interaction_list and interaction_list.has_method("update_from_state"):
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list._hide()
interaction_list.hide_list()
else:
interaction_list.update_from_state()
@@ -131,6 +132,10 @@ func _process(delta: float) -> void:
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.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()
+94 -37
View File
@@ -2,7 +2,7 @@ class_name EntityRenderer
extends Node2D
# Entity renderer — manages entity sprites under the Entities node
# Creates/updates/removes ColorRect children based on entity data
# Creates/updates/removes Sprite2D children based on entity data
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
#
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
@@ -11,13 +11,15 @@ extends Node2D
#
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
# color from RelationshipState via the knowledge graph.
# #540: Sprites at D-019 angle (-72.5° from horizontal). Textures are neutral greyscale;
# self_modulate applies D-033 relationship tinting. modulate.a reserved for D-015 dimming.
const TILE_SIZE: int = Constants.TILE_SIZE
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime)
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
const ENTITY_WIDTH: int = 24
const ENTITY_HEIGHT: int = 32
const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally
const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering.
const ENTITY_OFFSET_X: float = 0.0 # sprite fills tile width at 0.5 scale
const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
# Lerp speed — framerate-independent exponential smoothing.
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
@@ -27,7 +29,8 @@ const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
@@ -48,7 +51,7 @@ func _process(delta: float) -> void:
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# #521: Advance color transitions (manual lerp, testable without SceneTree)
# #521: Advance self_modulate transitions (manual lerp, testable without SceneTree)
var finished_ids: Array = []
for entity_id in _entity_tweens.keys():
if not entity_nodes.has(entity_id):
@@ -57,8 +60,9 @@ func _process(delta: float) -> void:
var tween_data: Dictionary = _entity_tweens[entity_id]
tween_data.elapsed += delta
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
node_c.color = tween_data.from.lerp(tween_data.target, t)
var node_s: Sprite2D = entity_nodes[entity_id] as Sprite2D
if node_s:
node_s.self_modulate = tween_data.from.lerp(tween_data.target, t)
if t >= 1.0:
finished_ids.append(entity_id)
for eid in finished_ids:
@@ -92,15 +96,26 @@ func update_entities(entities: Array) -> void:
for entity_id in ids_to_remove:
_remove_entity_node(entity_id)
# Create a new entity node with D-033 color and optional facing indicator
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
var entity_node = ColorRect.new()
entity_node.name = "Entity_" + str(entity_id)
entity_node.size = Vector2(ENTITY_WIDTH, ENTITY_HEIGHT)
entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
# D-033 color by relationship (#521)
entity_node.color = _color_for_kind(entity_data)
# Create a new entity node with D-033 tint and sprite texture at D-019 angle
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
var entity_node := Sprite2D.new()
entity_node.name = "Entity_" + str(entity_id)
# centered=false: top-left origin aligns with tile grid.
# scale=0.5: maps 64px source texture to 32px runtime (D-043, 2x camera = 64px on screen).
entity_node.centered = false
entity_node.scale = Vector2(0.5, 0.5)
# Load sprite for current facing direction
var direction := _entity_direction(entity_id, entity_data)
_entity_facing[entity_id] = direction
var tex := _load_sprite_texture(direction)
if tex == null:
push_error("EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
entity_node.texture = tex
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
entity_node.self_modulate = _color_for_kind(entity_data)
add_child(entity_node)
entity_nodes[entity_id] = entity_node
@@ -121,12 +136,13 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
_update_entity_node(entity_id, entity_data)
# Update an existing entity node (target position, visibility dimming, facing)
# Update an existing entity node (target position, sprite direction, visibility dimming, facing)
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not entity_nodes.has(entity_id):
return
var entity_node = entity_nodes[entity_id]
var node = entity_nodes[entity_id]
# Update target position — the lerp in _process() will smoothly move there.
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
@@ -136,38 +152,42 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
# #540: Update sprite texture when facing direction changes
var new_dir := _entity_direction(entity_id, entity_data)
if new_dir != _entity_facing.get(entity_id, ""):
_entity_facing[entity_id] = new_dir
var new_tex := _load_sprite_texture(new_dir)
if new_tex != null:
(node as Sprite2D).texture = new_tex
# null: keep previous texture rather than going invisible mid-game
# #521: Detect relationship change → fade D-033 self_modulate (0.5s via _process)
var new_rel: String = entity_data.get("relationship", "Unknown")
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
if new_rel != old_rel:
if new_rel != _entity_relationships.get(entity_id, "Unknown"):
_entity_relationships[entity_id] = new_rel
var new_color := _color_for_kind(entity_data)
_entity_tweens[entity_id] = {
"from": entity_node.color,
"target": new_color,
"from": (node as Sprite2D).self_modulate,
"target": _color_for_kind(entity_data),
"elapsed": 0.0,
}
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
# are compositionally independent — both can change simultaneously without
# interference. If alpha tweening is added later, coordinate with color tween.
# v2: Peripheral vision dimming (D-015)
# null visibility (v1 backward compat) defaults to full alpha
# D-015: Peripheral vision dimming via modulate.a.
# Independent from self_modulate (D-033 tint) — both can change simultaneously.
var visibility: Variant = entity_data.get("visibility")
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
if not is_equal_approx(entity_node.modulate.a, target_alpha):
entity_node.modulate.a = target_alpha
if not is_equal_approx(node.modulate.a, target_alpha):
node.modulate.a = target_alpha
# D-054: Update facing indicator from client-side mouse angle (not server).
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
if entity_id == GameState.player_entity_id:
var indicator = entity_node.get_node_or_null("FacingIndicator")
var indicator = node.get_node_or_null("FacingIndicator")
if indicator != null:
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
# Rotate from North basis: add PI/2 to convert.
indicator.rotation = InputMapper.facing_angle + PI / 2.0
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
if not entity_nodes.has(entity_id):
@@ -179,13 +199,49 @@ func _remove_entity_node(entity_id: int) -> void:
_entity_targets.erase(entity_id)
_entity_relationships.erase(entity_id)
_entity_tweens.erase(entity_id)
_entity_facing.erase(entity_id)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
return Constants.color_for_entity_kind(entity_data)
# Add a facing direction indicator triangle to the player entity
func _add_facing_indicator(parent_node: Control) -> void:
# #540: Map entity to current 4-direction sprite key.
# Player uses GameState.player_facing (8-octant → 4-cardinal). NPCs default "south".
func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
if entity_id == GameState.player_entity_id:
return _octant_to_direction(GameState.player_facing)
# NPCs: no facing field in v1 entity format; south is viewer-facing (D-019 angle)
return "south"
# Map 8-direction octant string to nearest 4-direction sprite key.
# N/NW → north, NE/E → east, SE/S → south, SW/W → west
static func _octant_to_direction(octant: String) -> String:
match octant:
"North", "Northwest": return "north"
"Northeast", "East": return "east"
"Southeast", "South": return "south"
"Southwest", "West": return "west"
_:
push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant)
return "south"
# Load the sprite texture for the given 4-direction key.
# Falls back to null with a push_warning if the asset is missing.
static func _load_sprite_texture(direction: String) -> Texture2D:
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
if ResourceLoader.exists(path):
return load(path) as Texture2D
push_warning("EntityRenderer: sprite not found: %s" % path)
return null
# Add a facing direction indicator triangle to the player entity.
# Indicator position is in Sprite2D local space (64px texture before 0.5 scale → center at (32,32)).
func _add_facing_indicator(parent_node: Node2D) -> void:
var indicator := Polygon2D.new()
indicator.name = "FacingIndicator"
var s := Constants.FACING_INDICATOR_SIZE
@@ -197,6 +253,7 @@ func _add_facing_indicator(parent_node: Control) -> void:
Vector2(s * 0.6, -offset + s * 0.4),
])
indicator.color = Constants.ENTITY_COLOR_PLAYER
# Position at center of parent ColorRect — rotation around this point
indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
# Indicator rotates around this point to track player facing direction.
indicator.position = Vector2(32.0, 32.0)
parent_node.add_child(indicator)
@@ -0,0 +1 @@
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
+29 -27
View File
@@ -135,43 +135,43 @@ func test_npc_uses_relationship_color_unknown() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_npc_uses_relationship_color_friendly() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
renderer.queue_free()
func test_npc_uses_relationship_color_poi() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_POI)
renderer.queue_free()
func test_npc_uses_relationship_color_hostile() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_HOSTILE)
renderer.queue_free()
func test_player_color_ignores_relationship() -> void:
@@ -179,8 +179,8 @@ func test_player_color_ignores_relationship() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(1, "Player", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
var node: Sprite2D = renderer.entity_nodes[1] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_object_color_ignores_relationship() -> void:
@@ -188,8 +188,8 @@ func test_object_color_ignores_relationship() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(3, "Object", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
var node: Sprite2D = renderer.entity_nodes[3] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
@@ -204,20 +204,20 @@ func test_color_shift_not_instant() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities_before)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
# Change relationship to Hostile
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities_after)
# Immediately after update, color should NOT yet be the target
var color_after_immediate: Color = node.color
# Immediately after update, self_modulate should NOT yet be the target
var color_after_immediate: Color = node.self_modulate
if not _renderer_has_tween_support(renderer):
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
renderer.queue_free()
return
# The color should NOT be exactly the target yet (tween in progress)
# The self_modulate should NOT be exactly the target yet (tween in progress)
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
renderer.queue_free()
@@ -236,8 +236,8 @@ func test_color_shift_reaches_target() -> void:
while elapsed < 1.5:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_mid_transition_retrigger() -> void:
@@ -263,8 +263,8 @@ func test_color_shift_mid_transition_retrigger() -> void:
while elapsed < 1.0:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
@@ -275,10 +275,10 @@ func test_color_shift_same_relationship_no_tween() -> void:
return
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var color_first: Color = node.color
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
var color_first: Color = node.self_modulate
renderer.update_entities(entities)
var color_second: Color = node.color
var color_second: Color = node.self_modulate
assert_that(color_first).is_equal(color_second)
renderer.queue_free()
@@ -354,10 +354,12 @@ func _entity_uses_relationship(renderer: Node2D) -> bool:
renderer.update_entities([friendly, hostile])
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
return false
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
var f_color: Color = f_node.color
var h_color: Color = h_node.color
var f_node: Sprite2D = renderer.entity_nodes[10] as Sprite2D
var h_node: Sprite2D = renderer.entity_nodes[11] as Sprite2D
if not f_node or not h_node:
return false
var f_color: Color = f_node.self_modulate
var h_color: Color = h_node.self_modulate
var uses_rel: bool = not f_color.is_equal_approx(h_color)
renderer.update_entities([])
return uses_rel
+298
View File
@@ -0,0 +1,298 @@
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
## Tests for relationship color indicator in interaction_list.gd.
##
## Spec refs:
## D-033 (entity color = relationship to player)
## D-051 (diegetic insert display — insert-only overlay)
## D-057 (interaction list, z-layer 6)
##
## Phase 1 scope (2026-02-24):
## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending
## server protocol change (no known_attributes in wire protocol v13).
##
## Implementation: client/ui/interaction_list.gd
## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM`
## `func _cache_entity_relationship() -> void`
## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state()
class_name TestETalkOverlaySprint17
extends GdUnitTestSuite
func before_test() -> void:
SimBridge.reset_test_state()
GameState.nearby_interactions = []
GameState.visible_entities = []
GameState.player_stance = ""
func after_test() -> void:
GameState.nearby_interactions = []
GameState.visible_entities = []
GameState.player_stance = ""
# -------------------------------------------------------------------------
# D-033: Constants.color_for_relationship() — palette baseline (pure logic)
# -------------------------------------------------------------------------
func test_relationship_unknown_maps_to_teal() -> void:
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_friendly_maps_to_green() -> void:
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
func test_relationship_poi_maps_to_amber() -> void:
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI)
func test_relationship_hostile_maps_to_red() -> void:
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_relationship_known_falls_back_to_unknown_color() -> void:
# "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN
assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_unknown_string_falls_back_to_unknown() -> void:
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# -------------------------------------------------------------------------
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
# -------------------------------------------------------------------------
func test_ui_strings_has_relationship_unknown_label() -> void:
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
func test_ui_strings_has_relationship_known_label() -> void:
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
func test_ui_strings_has_relationship_friendly_label() -> void:
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
func test_ui_strings_has_relationship_poi_label() -> void:
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
func test_ui_strings_has_relationship_hostile_label() -> void:
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
# -------------------------------------------------------------------------
# _relationship_color initial state
# -------------------------------------------------------------------------
func test_relationship_color_default_is_implant_dim() -> void:
# Before any update, _relationship_color starts at IMPLANT_TEXT_DIM
var list = _make_list()
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
list.queue_free()
# -------------------------------------------------------------------------
# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core)
# -------------------------------------------------------------------------
func test_cache_relationship_unknown_sets_unknown_color() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
list.queue_free()
func test_cache_relationship_friendly_sets_green() -> void:
_setup_npc_interaction(2, "Friendly")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
list.queue_free()
func test_cache_relationship_poi_sets_amber() -> void:
_setup_npc_interaction(2, "PersonOfInterest")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI)
list.queue_free()
func test_cache_relationship_hostile_sets_red() -> void:
_setup_npc_interaction(2, "Hostile")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
list.queue_free()
func test_cache_relationship_absent_field_uses_unknown_color() -> void:
# Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
list.queue_free()
func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void:
# Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback
GameState.visible_entities = []
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
list.queue_free()
func test_cache_relationship_updates_when_relationship_changes() -> void:
# First call: Unknown
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# Relationship shifts → re-cache picks up new value
_setup_npc_interaction(2, "Hostile")
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
list.queue_free()
func test_cache_relationship_targets_correct_entity_by_id() -> void:
# Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly)
# Entity 3 (Hostile) must not pollute the color
GameState.visible_entities = [
{
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
},
{
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
},
]
GameState.nearby_interactions = [
{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
{
"entity_id": 3, "entity_type": "Npc", "distance": 2,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
]
var list = _make_list()
list.update_from_state()
# interaction[0] = entity 2 (Friendly) → green bar
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
list.queue_free()
func test_z_layer_is_insert_canvas() -> void:
# D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10)
var list = _make_list()
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -------------------------------------------------------------------------
# Regression: interaction_list public API unaffected by #537 changes
# -------------------------------------------------------------------------
func test_list_hides_when_no_interactions() -> void:
GameState.nearby_interactions = []
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
func test_list_shows_on_npc_interaction() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_true()
list.queue_free()
func test_list_get_selected_verb_returns_first_verb() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
],
}]
var list = _make_list()
assert_that(list.get_selected_verb()).is_equal("Talk")
list.queue_free()
func test_list_get_interaction_target_returns_entity_id() -> void:
GameState.visible_entities = [{
"entity_id": 5, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 5, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list.get_interaction_target()).is_equal(5)
list.queue_free()
func test_list_suppressed_when_insert_inactive() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.set_insert_active(false)
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
func test_list_hides_on_empty_verb_list() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
}]
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
# -------------------------------------------------------------------------
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
# -------------------------------------------------------------------------
func skip_test_npc_name_displayed_when_known() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
pass
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
func _make_list() -> Control:
var scene = load("res://ui/interaction_list.tscn")
var list = scene.instantiate()
add_child(list) # _ready() fires here — @onready var _vbox resolves
return list
## Set up GameState with a single NPC entity + matching interaction for tests.
func _setup_npc_interaction(entity_id: int, relationship: String) -> void:
GameState.visible_entities = [{
"entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": relationship, "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": entity_id, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
+211
View File
@@ -0,0 +1,211 @@
## Sprint 16 #543: GameState.current_zone_id population tests.
## Verifies that apply_snapshot() correctly extracts zone_id from the player's
## current tile after the O(N) → O(1) refactor. Behavior must be identical
## before and after the change (net-zero behavioral change per ticket spec).
## Spec refs: D-073, #543, #529.
class_name TestSnapshotZoneId
extends GdUnitTestSuite
func before_test() -> void:
GameState.current_zone_id = ""
GameState.player_position = Vector2.ZERO
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.visibility_sectors = {}
# -- Happy path (S16-Z01) ------------------------------------------------------
func test_zone_id_populated_when_player_on_zone_tile() -> void:
# S16-Z01: Player at (5,5), visible_tiles has zone_id "zone_alpha" at (5,5).
# D-073: current_zone_id must be set to the server-authoritative zone_id.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_foyer"},
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
{"x": 7, "y": 7, "z": 0, "type": "floor", "zone_id": "zone_beta"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"current_zone_id must match zone_id at player tile (5,5)"
).is_equal("zone_alpha")
assert_that(GameState.current_zone_id.is_empty()).override_failure_message(
"current_zone_id must be non-empty when player is on a zone-tagged tile"
).is_false()
# -- Missing zone_id field (S16-Z02) -------------------------------------------
func test_zone_id_empty_when_tile_lacks_zone_field() -> void:
# S16-Z02: Player's tile exists but has no zone_id key → empty string.
# Server may send tiles without zone_id when tile is unzoned.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Tile without zone_id field → current_zone_id must default to empty string"
).is_equal("")
# -- Player off-tile (S16-Z03) -------------------------------------------------
func test_zone_id_empty_when_player_not_on_any_tile() -> void:
# S16-Z03: Player at (99,99) but no tile at that position → empty string.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 99.0, "y": 99.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Player at (99,99) with no matching tile → current_zone_id must be empty"
).is_equal("")
# -- Empty tiles (S16-Z04) -----------------------------------------------------
func test_zone_id_empty_on_empty_visible_tiles() -> void:
# S16-Z04: No visible tiles at all → empty string, no crash.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Empty visible_tiles → current_zone_id must be empty string (no crash)"
).is_equal("")
# -- Correct tile selected among many (S16-Z05) --------------------------------
func test_zone_id_selects_correct_tile_among_many() -> void:
# S16-Z05: 10x10 tile grid, player at (6,4). Only zone_6_4 must be selected.
# Tests that the lookup doesn't accidentally match a neighboring tile.
var tiles: Array = []
for tx in range(10):
for ty in range(10):
tiles.append({"x": tx, "y": ty, "z": 0, "type": "floor",
"zone_id": "zone_%d_%d" % [tx, ty]})
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 6.0, "y": 4.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": tiles,
})
assert_that(GameState.current_zone_id).override_failure_message(
"Player at (6,4) must get zone_6_4, not a neighboring tile"
).is_equal("zone_6_4")
# -- Zone transition (S16-Z06) -------------------------------------------------
func test_zone_id_updates_on_zone_transition() -> void:
# S16-Z06: Player moves from zone_alpha (5,5) to zone_beta (6,5).
# current_zone_id must update on each snapshot.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
],
})
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
GameState.apply_snapshot({
"tick": 2,
"entities": [
{"entity_id": 1, "x": 6.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"After moving to (6,5), current_zone_id must update to zone_beta"
).is_equal("zone_beta")
# -- Regression: visible_positions unaffected (S16-Z07) -----------------------
func test_visible_positions_unaffected_by_zone_id_refactor() -> void:
# S16-Z07: The O(1) refactor must not break visible_positions population.
# Both zone_id and visible_positions derive from the same visible_tiles loop —
# verify both are correctly populated after a single apply_snapshot().
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha", "visibility": "Forward"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta", "visibility": "Peripheral"},
],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
"visible_positions must still contain (5,5) after zone_id refactor"
).is_true()
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
assert_that(GameState.current_zone_id).override_failure_message(
"current_zone_id must be zone_alpha after same apply_snapshot call"
).is_equal("zone_alpha")
# -- Fractional player position (S16-Z08) --------------------------------------
func test_zone_id_uses_int_truncation_of_player_position() -> void:
# S16-Z08: Player at (5.7, 5.9) → int(5.7)=5, int(5.9)=5 → matches tile (5,5).
# Server sends player coords as floats; zone lookup must truncate to tile index.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.7, "y": 5.9, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Player at (5.7,5.9) must match tile (5,5) — int() truncation applies"
).is_equal("zone_alpha")
# -- Test-mode tiles key (S16-Z09) --------------------------------------------
func test_zone_id_works_with_test_mode_tiles_key() -> void:
# S16-Z09: Test mode sends "tiles" key, not "visible_tiles".
# The O(1) refactor uses member visible_tiles (covers both paths).
# Regression guard: if _tile_by_coord is moved into the snapshot.visible_tiles
# block only, this test fails — catching the exact regression Tyre flagged.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Test-mode 'tiles' key must populate zone_id via member visible_tiles"
).is_equal("zone_alpha")
+292
View File
@@ -0,0 +1,292 @@
## Sprint 16 #540: Sprite integration tests.
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
## bounding box, sprite asset existence from #541, and fog shader independence.
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
class_name TestSpriteIntegration
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
func before_test() -> void:
GameState.player_entity_id = 1
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
# -- Helpers -------------------------------------------------------------------
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
func test_entity_footprint_matches_d044_spec() -> void:
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
"D-044: ENTITY_WIDTH must be 24px"
).is_equal(24)
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
"D-044: ENTITY_HEIGHT must be 32px"
).is_equal(32)
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
# range (2 sim tiles) accurate with the tilted perspective.
var tile_2x: int = Constants.TILE_SIZE * 2
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
EntityRenderer.ENTITY_WIDTH, tile_2x]
).is_true()
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
EntityRenderer.ENTITY_HEIGHT, tile_2x]
).is_true()
func test_entity_width_fits_within_single_tile() -> void:
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
# Ensures horizontal centering offset is positive and entity doesn't overflow.
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
"Entity width must be less than TILE_SIZE for centered layout"
).is_true()
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
).is_true()
# -- Pixel position (S16-S04) -------------------------------------------------
func test_entity_pixel_position_at_tile_3_7() -> void:
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[10]
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
assert_that(node.position.x).override_failure_message(
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
).is_equal_approx(expected_x, 0.1)
assert_that(node.position.y).override_failure_message(
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
).is_equal_approx(expected_y, 0.1)
renderer.queue_free()
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
# With tilted sprites, south-facing entity must visually overlap northern.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var north_node = renderer.entity_nodes[20]
var south_node = renderer.entity_nodes[21]
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
).is_true()
renderer.queue_free()
func test_z_sort_y_position_difference_equals_tile_size() -> void:
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
# Verifies position calculation is consistent for adjacent tiles.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var node3 = renderer.entity_nodes[30]
var node4 = renderer.entity_nodes[31]
var delta_y := node4.position.y - node3.position.y
assert_that(delta_y).override_failure_message(
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
renderer.queue_free()
func test_z_sort_same_y_different_x_no_y_difference() -> void:
# S16-S07: Two entities at same y but different x → same pixel.y.
# Horizontal position must not affect y-sort order.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var left_node = renderer.entity_nodes[40]
var right_node = renderer.entity_nodes[41]
assert_that(left_node.position.y).override_failure_message(
"Entities at same y-tile must have same pixel.y regardless of x"
).is_equal_approx(right_node.position.y, 0.1)
renderer.queue_free()
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
# entity_renderer.gd must be able to load these paths.
for direction in ["north", "east", "south", "west"]:
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
assert_that(ResourceLoader.exists(path)).override_failure_message(
"NPC sprite missing: %s" % path
).is_true()
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
for direction in ["north", "east", "south", "west"]:
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
assert_that(ResourceLoader.exists(path)).override_failure_message(
"Wall sprite missing: %s" % path
).is_true()
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
# the art-direction tilt baked into sprites.
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
"fog_shader.gd must load correctly — must not be affected by sprite changes"
).is_true()
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
"fog.gdshader must exist — fog is screen-space and sprite-independent"
).is_true()
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
var fog = get_node_or_null("/root/FogState")
if fog == null:
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
return
# Provide visibility data but no entity renderer context
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
GameState.visibility_sectors = {
Vector2i(5, 5): "Forward",
Vector2i(6, 5): "Peripheral",
}
if fog.has_method("update_from_state"):
fog.update_from_state()
assert_that(fog.visibility_texture).override_failure_message(
"FogState visibility_texture must be populated independently of sprite state"
).is_not_null()
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
func test_octant_north_maps_to_north() -> void:
# S16-S12: "North" → "north"
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
func test_octant_northwest_maps_to_north() -> void:
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
func test_octant_northeast_maps_to_east() -> void:
# S16-S14: "Northeast" → "east"
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
func test_octant_east_maps_to_east() -> void:
# S16-S15: "East" → "east"
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
func test_octant_southeast_maps_to_south() -> void:
# S16-S16: "Southeast" → "south"
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
func test_octant_south_maps_to_south() -> void:
# S16-S17: "South" → "south"
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
func test_octant_southwest_maps_to_west() -> void:
# S16-S18: "Southwest" → "west"
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
func test_octant_west_maps_to_west() -> void:
# S16-S19: "West" → "west"
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
func test_octant_unknown_string_falls_back_to_south() -> void:
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
func test_octant_empty_string_falls_back_to_south() -> void:
# S16-S21: Empty string → "south" fallback
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
func test_entity_direction_npc_always_south() -> void:
# S16-S22: NPC entity → always "south" regardless of any data field.
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
# entity_id 99 is not the player
var dir := renderer._entity_direction(99, {"entity_id": 99,
"kind": {"variant": "Npc", "data": null}})
assert_that(dir).override_failure_message(
"NPC entity must always return 'south'"
).is_equal("south")
renderer.queue_free()
func test_entity_direction_player_uses_player_facing() -> void:
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "North"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='North' must return 'north'"
).is_equal("north")
renderer.queue_free()
func test_entity_direction_player_facing_east() -> void:
# S16-S24: Player facing "East" → "east"
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "East"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='East' must return 'east'"
).is_equal("east")
renderer.queue_free()
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
# Diagonal octants map to one of the four cardinal sprite sets.
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "Northwest"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='Northwest' must return 'north'"
).is_equal("north")
renderer.queue_free()
+318
View File
@@ -0,0 +1,318 @@
## Sprint 17 — Time display on insert HUD (#263)
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
##
## Spec refs:
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
## D-051 (diegetic insert display)
##
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
##
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
## reading Label.text via public node paths instead.
class_name TestTimeDisplaySprint17
extends GdUnitTestSuite
var _clock: Control = null
func before_test() -> void:
SimBridge.reset_test_state()
GameState.game_time = {}
var ClockScript = load("res://ui/time_display.gd")
_clock = Control.new()
_clock.set_script(ClockScript)
add_child(_clock)
func after_test() -> void:
if _clock and is_instance_valid(_clock):
_clock.queue_free()
_clock = null
GameState.game_time = {}
# -------------------------------------------------------------------------
# Constants.format_game_time() — pure logic, D-031
# -------------------------------------------------------------------------
func test_format_midnight() -> void:
assert_that(Constants.format_game_time(0)).is_equal("00:00")
func test_format_morning_start() -> void:
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
assert_that(Constants.format_game_time(360)).is_equal("06:00")
func test_format_noon() -> void:
assert_that(Constants.format_game_time(720)).is_equal("12:00")
func test_format_evening_start() -> void:
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
func test_format_end_of_day() -> void:
# Last valid minute — must not wrap or overflow
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
func test_format_pads_single_digit_hour() -> void:
# 30 min = 00:30
assert_that(Constants.format_game_time(30)).is_equal("00:30")
func test_format_pads_single_digit_minute() -> void:
# 121 min = 02:01
assert_that(Constants.format_game_time(121)).is_equal("02:01")
func test_format_half_past_hour() -> void:
assert_that(Constants.format_game_time(90)).is_equal("01:30")
func test_format_arbitrary_midday() -> void:
# 835 min = 13:55
assert_that(Constants.format_game_time(835)).is_equal("13:55")
# -------------------------------------------------------------------------
# InsertClock initial state
# -------------------------------------------------------------------------
func test_insert_clock_initial_time_str_is_placeholder() -> void:
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
assert_that(_clock._time_str).is_equal("--:--")
func test_insert_clock_initial_phase_str_is_empty() -> void:
assert_that(_clock._phase_str).is_equal("")
func test_insert_clock_initial_day_str_is_empty() -> void:
assert_that(_clock._day_str).is_equal("")
# -------------------------------------------------------------------------
# InsertClock.update_from_state() — reads GameState.game_time
# -------------------------------------------------------------------------
func test_update_from_state_formats_time_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("12:00")
func test_update_from_state_sets_phase_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_str).is_equal("Evening")
func test_update_from_state_sets_day_str_one_indexed() -> void:
# Day 0 from server → "D1" display (1-indexed)
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D1")
func test_update_from_state_day_2() -> void:
GameState.game_time = {
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D2")
func test_update_from_state_midnight() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("00:00")
func test_update_from_state_end_of_day() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("23:59")
func test_update_from_state_skips_empty_game_time() -> void:
# Empty game_time must not overwrite --:-- (guard in update_from_state)
GameState.game_time = {}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("--:--")
func test_update_from_state_deduplicates_same_tick() -> void:
# Calling twice with identical data must produce same result (signature cache)
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
# Call again — result unchanged, no crash
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
func test_update_from_state_updates_on_new_tick() -> void:
# time_of_day changes → signature changes → _time_str updates
GameState.game_time = {
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("01:00")
GameState.game_time = {
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("02:00")
# -------------------------------------------------------------------------
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
# -------------------------------------------------------------------------
func test_phase_colors_has_morning() -> void:
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
func test_phase_colors_has_afternoon() -> void:
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
func test_phase_colors_has_evening() -> void:
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
func test_phase_colors_has_night() -> void:
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
func test_phase_color_applied_after_update() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
# -------------------------------------------------------------------------
# GameState: game_time field parsing (confirms apply_snapshot wiring)
# -------------------------------------------------------------------------
func test_game_time_populated_from_snapshot() -> void:
GameState.apply_snapshot({
"tick": 5, "entities": [],
"game_time": {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
func test_game_time_all_four_phases_store_correctly() -> void:
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
func test_game_time_zero_time_of_day_stored() -> void:
# time_of_day = 0 (midnight) must not be treated as falsy/missing
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
# -------------------------------------------------------------------------
# SimBridge test mode: game_time fields are valid
# -------------------------------------------------------------------------
func test_sim_bridge_snapshot_has_game_time() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.has("game_time")).is_true()
assert_that(snap.game_time is Dictionary).is_true()
func test_sim_bridge_game_time_has_required_fields() -> void:
var snap = SimBridge._test_snapshot()
var gt: Dictionary = snap.game_time
assert_that(gt.has("day")).is_true()
assert_that(gt.has("time_of_day")).is_true()
assert_that(gt.has("day_phase")).is_true()
assert_that(gt.has("tick_rate")).is_true()
func test_sim_bridge_time_of_day_is_non_negative() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
# D-031: 1440 game-minutes per day, valid range 0..1439
var snap = SimBridge._test_snapshot()
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
func test_sim_bridge_day_phase_is_valid() -> void:
var snap = SimBridge._test_snapshot()
var phase: String = snap.game_time.get("day_phase", "")
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
# -------------------------------------------------------------------------
# Scene: InsertClock node at InsertOverlay/TimeDisplay
# -------------------------------------------------------------------------
func test_insert_clock_exists_in_ui_layer() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
func test_insert_clock_time_str_updates_after_process() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
GameState.apply_snapshot({
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
})
instance._process(0.016)
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
assert_that(clock).is_not_null()
assert_that(clock._time_str).is_equal("12:00")
# -------------------------------------------------------------------------
# Regression: debug_overlay still reads game_time correctly (#511)
# -------------------------------------------------------------------------
func test_debug_overlay_reads_game_time_day_phase() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
func test_debug_overlay_reads_game_time_tick_rate() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
})
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
+26 -1
View File
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0
var _active_tween: Tween = null
var _verb_labels: Array[Label] = []
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
@onready var _vbox: VBoxContainer = $VBox
@@ -39,10 +41,14 @@ func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
var _last_screen_pos: Vector2 = Vector2.ZERO
func _process(_delta: float) -> void:
if _showing:
_update_screen_position()
queue_redraw()
if position != _last_screen_pos:
_last_screen_pos = position
queue_redraw()
func _draw() -> void:
@@ -52,6 +58,9 @@ func _draw() -> void:
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
draw_rect(bg_rect, INSERT_BG)
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
func update_from_state() -> void:
@@ -88,6 +97,7 @@ func update_from_state() -> void:
_verb_items = sorted
_selected_index = 0
_cache_entity_position()
_cache_entity_relationship()
_rebuild_labels()
_show()
@@ -127,6 +137,17 @@ func _cache_entity_position() -> void:
return
## #537: Cache relationship color for the target entity (D-033 palette).
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
func _cache_entity_relationship() -> void:
for entity in GameState.visible_entities:
if entity.get("entity_id") == _current_target_id:
var rel: String = str(entity.get("relationship", "Unknown"))
_relationship_color = Constants.color_for_relationship(rel)
return
_relationship_color = Constants.IMPLANT_TEXT_DIM
## Convert entity world position to screen coords and reposition this Control.
## Runs every frame while showing so the list tracks the entity as the camera moves.
func _update_screen_position() -> void:
@@ -187,6 +208,10 @@ func get_visible_verb_count() -> int:
return _verb_items.size()
func hide_list() -> void:
_hide()
func is_showing() -> bool:
return _showing
+97
View File
@@ -0,0 +1,97 @@
extends Control
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
## Shows station local time (HH:MM), day phase, and day number.
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
##
## Placeholder layout — position and style will be refined when #314 wireframe lands.
const FONT_SIZE_TIME: int = 15
const FONT_SIZE_META: int = 10
const PADDING := Vector2(10, 7)
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
# Day phase colors — station lighting cycle (D-031)
const PHASE_COLORS := {
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
"Night": Color("#4a7080"), # dark teal — station nightwatch
}
var _time_str: String = "--:--"
var _phase_str: String = ""
var _day_str: String = ""
var _day_text: String = ""
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
var _last_signature: String = ""
var _has_data: bool = false
# Cached geometry — recomputed in update_from_state(), used in _draw()
var _time_size: Vector2 = Vector2.ZERO
var _phase_size: Vector2 = Vector2.ZERO
var _day_size: Vector2 = Vector2.ZERO
var _meta_h: float = 0.0
var _box_w: float = 0.0
var _box_h: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
var gt: Dictionary = GameState.game_time
if gt.is_empty():
return
var tod: int = int(gt.get("time_of_day", 0))
var day: int = int(gt.get("day", 0))
var phase: String = str(gt.get("day_phase", ""))
var sig: String = "%d:%d:%s" % [tod, day, phase]
if sig == _last_signature:
return
_last_signature = sig
_time_str = Constants.format_game_time(tod)
_phase_str = phase
_day_str = "D%d" % (day + 1)
_day_text = " " + _day_str
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
_has_data = true
_cache_geometry()
queue_redraw()
func _cache_geometry() -> void:
var font := ThemeDB.fallback_font
_time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
_phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
var meta_w := _phase_size.x + _day_size.x
var content_w := max(_time_size.x, meta_w)
_box_w = content_w + PADDING.x * 2
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
func _draw() -> void:
if not _has_data:
return
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR)
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0)
var font := ThemeDB.fallback_font
# HH:MM (primary, full brightness)
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
# Phase + day number (secondary, dimmed + phase-tinted)
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
draw_string(font, Vector2(PADDING.x, meta_y),
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
+22
View File
@@ -0,0 +1,22 @@
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
; #263: Time display — top-left placeholder per D-013/D-051.
; Position and size will be refined when #314 wireframe lands.
; NOTE: draw-based content manages its own layout; Control rect is a
; minimum bounding box, not a clip rect. Increase if content grows.
[node name="TimeDisplay" type="Control"]
anchors_preset = 0
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
offset_left = 16.0
offset_top = 16.0
offset_right = 200.0
offset_bottom = 70.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_tdisplay")
+14 -14
View File
@@ -253,7 +253,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
| Tag | Type | Format | Description |
|-----|------|--------|-------------|
| `id` | string | `{location}_{d}_{###}` | Stable line ID. Machine-parseable. Never reused. |
| `id` | string | `{npc-slug}_d_{###}` | Stable line ID. Machine-parseable. Never reused. NPC-scoped per D-035 Sprint 15 amendment. |
| `text` | string | The line | The authored dialogue text |
| `role` | enum | lowercase-slug | Template role, not NPC name. NPCs fill roles at runtime. |
| `access` | list | `[public, insider, ...]` | D-028 Layer 1 hard filter. Who can hear this. |
@@ -265,7 +265,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
| Tag | Type | Format | Description |
|-----|------|--------|-------------|
| `topic` | list | `[colleague, cargo, ...]` | D-028 Layer 4 weighted selection. Defaults to `[routine]`. |
| `mood` | list | `[comfortable, worried, ...]` | D-028 Layer 4 weighted selection. Defaults to `[comfortable]`. |
| `mood` | list | `[content, anxious, ...]` | D-028 Layer 4 weighted selection. Defaults to untagged (neutral). |
| `tags` | list | freeform strings | Escape hatch for author intent. |
#### Authoring-Only Tags (Not Consumed by Engine)
@@ -352,7 +352,7 @@ Same trigger, different pool. That's how mirror moments work.
| Tag | Type | Description |
|-----|------|-------------|
| `id` | string | `{location}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective |
| `id` | string | `{npc-slug}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective. NPC-scoped per D-035 Sprint 15 amendment. |
| `text` | string | The monologue line (160 char max) |
| `character` | enum | `smuggler` or `detective` |
| `trigger` | enum | What causes this line to fire (see trigger types below) |
@@ -600,9 +600,9 @@ See `content/global/enums/topics.yaml`. Note: `crime` is deliberately excluded.
### Moods (8 values)
`fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`
`anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`
See `content/global/enums/moods.yaml`.
See `content/global/enums/moods.yaml`. (Updated Sprint 14 amendment to D-035.)
### Monologue Triggers (9 values)
@@ -630,7 +630,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
### Dialogue Lines
- [ ] **HARD:** `id` matches pattern `{location}_d_{###}` (lowercase, underscore-separated)
- [ ] **HARD:** `id` matches pattern `{npc-slug}_d_{###}` (e.g. `kael-davan_d_001`) — NPC-scoped per D-035 Sprint 15 amendment
- [ ] **HARD:** `role` is a valid lowercase slug matching a template role
- [ ] **HARD:** `access` is a non-empty list of valid access tier enums
- [ ] **HARD:** `trust` is one of `surface`, `real`, `secret`
@@ -643,7 +643,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
### Monologue Lines
- [ ] **HARD:** `id` matches pattern `{location}_m_{s|d}_{###}`
- [ ] **HARD:** `id` matches pattern `{npc-slug}_m_{s|d}_{###}` (e.g. `pc-smuggler_m_s_001`) — NPC-scoped per D-035 Sprint 15 amendment
- [ ] **HARD:** `text` is 160 characters or fewer
- [ ] **HARD:** `trigger` is a valid trigger enum
- [ ] **HARD:** Character partition: smuggler lines in smuggler files, detective in detective files
@@ -755,9 +755,9 @@ All YAML content files must validate against their JSON Schema:
| Content Type | Pattern | Example |
|-------------|---------|---------|
| Dialogue | `{location}_d_{###}` | `transit_d_044` |
| Monologue (smuggler) | `{location}_m_s_{###}` | `transit_m_s_011` |
| Monologue (detective) | `{location}_m_d_{###}` | `transit_m_d_042` |
| Dialogue | `{npc-slug}_d_{###}` | `kael-davan_d_001` |
| Monologue (smuggler) | `{npc-slug}_m_s_{###}` | `pc-smuggler_m_s_001` |
| Monologue (detective) | `{npc-slug}_m_d_{###}` | `pc-detective_m_d_001` |
IDs are stable identifiers. Once assigned, never reused or reassigned. Number gaps are acceptable.
@@ -865,26 +865,26 @@ Every generated line must pass:
### Step 2: Assign Tags
```yaml
- id: transit_d_071
- id: kael-davan_d_076
text: "Morning. Voss kept the rotation thin -- we've got a clear window after 14:00."
role: dock-worker
access: [insider]
trust: real
situation: [shift_start]
topic: [cargo, routine]
mood: [comfortable]
mood: [content]
tags: [ring-operational]
```
### Step 3: Validate
- `id`: `transit_d_071` -- valid pattern.
- `id`: `kael-davan_d_076` -- valid pattern (NPC-scoped per D-035 Sprint 15 amendment). Continues from kael-davan's terminal sequence (_034-_075).
- `role`: `dock-worker` -- valid slug.
- `access: [insider]` -- only ring members hear this. Detective never sees it. Correct.
- `trust: real` -- requires earned trust. Not surface-level small talk. Correct for operational ring dialogue.
- `situation: [shift_start]` -- morning shift context. Correct.
- `topic: [cargo, routine]` -- covers both operational and schedule content. Correct.
- `mood: [comfortable]` -- Phase 1, baseline warm. Correct.
- `mood: [content]` -- Phase 1, baseline warm. Correct.
### Step 4: Dual-Lens Check
+1 -1
View File
@@ -10,7 +10,7 @@
"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."
"description": "Stable machine-parseable line ID: {npc-slug}_{d|m}_{###} (e.g. kael-davan_d_001, pc-detective_m_d_001). d = dialogue, m = monologue. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
},
"text": {
"type": "string",
+1 -1
View File
@@ -32,7 +32,7 @@
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_d_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_d_{###}"
"description": "Stable line ID: {npc-slug}_d_{###} (e.g. kael-davan_d_001). NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
},
"text": {
"type": "string",
+1 -1
View File
@@ -32,7 +32,7 @@
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
"description": "Stable line ID: {npc-slug}_m_{s|d}_{###} (e.g. pc-smuggler_m_s_001, pc-detective_m_d_001). s = smuggler, d = detective. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
},
"text": {
"type": "string",
@@ -13,7 +13,7 @@ lines:
# RING OPERATIONS — corridor handoffs
# ========================================
- id: maintenance-corridors_d_001
- id: kael-davan_d_001
text: "Package is in the junction locker. Renn picks it up in ten."
role: dock-worker
access: [insider]
@@ -23,7 +23,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, renn, operational]
- id: maintenance-corridors_d_002
- id: kael-davan_d_002
text: "B-7 is clear. Camera cycles in forty seconds — move fast."
role: dock-worker
access: [insider]
@@ -35,7 +35,7 @@ lines:
fact_id: investigation.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_003
- id: kael-davan_d_003
text: "Chalk marks are fresh. Renn came through already. We're on schedule."
role: dock-worker
access: [insider]
@@ -45,7 +45,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, renn, operational]
- id: maintenance-corridors_d_004
- id: kael-davan_d_004
text: "Don't use the C-section hatch tonight. Maintenance crew's working late."
role: dock-worker
access: [insider]
@@ -60,7 +60,7 @@ lines:
# When the smuggler finds Kael with the unknown contact
# ========================================
- id: maintenance-corridors_d_005
- id: kael-davan_d_005
text: "What are you — wait. How long have you been standing there?"
role: dock-worker
access: [insider]
@@ -70,7 +70,7 @@ lines:
topic: [danger]
tags: [kael, contradiction, phase-3]
- id: maintenance-corridors_d_006
- id: kael-davan_d_006
text: "That was nobody. Maintenance contact. It's handled."
role: dock-worker
access: [insider]
@@ -80,7 +80,7 @@ lines:
topic: [routine]
tags: [kael, deflection, lying, phase-4]
- id: maintenance-corridors_d_007
- id: kael-davan_d_007
text: "I don't have to explain every conversation I have. Drop it."
role: dock-worker
access: [insider]
@@ -94,7 +94,7 @@ lines:
# SECRET — corridor confessions
# ========================================
- id: maintenance-corridors_d_008
- id: kael-davan_d_008
text: "I'm trying to get out. That's all. I just want out."
role: dock-worker
access: [insider]
@@ -104,7 +104,7 @@ lines:
topic: [personal, trust]
tags: [kael, confession, phase-5]
- id: maintenance-corridors_d_009
- id: kael-davan_d_009
text: "Nils will kill me if he finds out. You know that. You know him."
role: dock-worker
access: [insider]
@@ -114,7 +114,7 @@ lines:
topic: [danger, trust]
tags: [kael, confession, nils, phase-5]
- id: maintenance-corridors_d_010
- id: kael-davan_d_010
text: "I found someone who can get me new papers. Off-station. Clean break."
role: dock-worker
access: [insider]
@@ -136,7 +136,7 @@ lines:
# ========================================
# first_meeting: InteractionMemory.count == 0. Any stranger in restricted corridors is a problem.
- id: maintenance-corridors_d_011
- id: kael-davan_d_011
text: "This is a restricted section. Who authorized you back here?"
role: dock-worker
access: [public]
@@ -148,7 +148,7 @@ lines:
notes: "Layer 2 greeting — first meeting in restricted corridors. Immediate challenge. Any stranger back here is suspicious regardless of intent."
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
- id: maintenance-corridors_d_012
- id: kael-davan_d_012
text: "You're on time. Clear all the way to the junction."
role: dock-worker
access: [peer, insider]
@@ -160,7 +160,7 @@ lines:
notes: "Layer 2 greeting — established, operational. All business. Kael treats a trusted repeat visitor as crew — no pleasantries, just the essential handoff information."
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
- id: maintenance-corridors_d_013
- id: kael-davan_d_013
text: "You keep showing up back here. You have clearance for this section?"
role: dock-worker
access: [authority]
@@ -172,7 +172,7 @@ lines:
notes: "Layer 2 greeting — repeat with authority figure in restricted section. Professionally defensive. Kael challenges them but can't bar entry if they have clearance."
# post-confrontation: confrontation logged. Kael is cornered on his own operational ground.
- id: maintenance-corridors_d_014
- id: kael-davan_d_014
text: "You followed me here."
role: dock-worker
access: [peer, insider]
@@ -21,7 +21,7 @@ lines:
# SURFACE COVER — sounds like maintenance work
# ==========================================
- id: maintenance-corridors_d_100
- id: ring-operative_d_001
text: "Running maintenance on the B-7 conduit. Could be a while."
role: ring-operative
access: [public, peer]
@@ -35,7 +35,7 @@ lines:
detective: "Maintenance worker. Conduit repair. Routine."
notes: "'Running maintenance' is the canonical ring cover phrase."
- id: maintenance-corridors_d_101
- id: ring-operative_d_002
text: "Bay four access from here is faster than going around. If you're in a hurry."
role: ring-operative
access: [public, peer]
@@ -48,7 +48,7 @@ lines:
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
- id: ring-operative_d_003
text: "Quiet in here tonight. Good working conditions."
role: ring-operative
access: [public, peer]
@@ -61,7 +61,7 @@ lines:
smuggler: "Dead air confirmed. Meridian's not picking this up."
detective: "Routine corridor comment."
- id: maintenance-corridors_d_103
- id: ring-operative_d_004
text: "Pael was through here earlier. Seal replacement on the ventilation unit."
role: ring-operative
access: [public, peer]
@@ -74,7 +74,7 @@ lines:
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
- id: ring-operative_d_005
text: "C-4 access is at the far end. About three minutes from here."
role: ring-operative
access: [public, peer]
@@ -94,7 +94,7 @@ lines:
# OPERATIONAL COORDINATION — insider/real only
# ==========================================
- id: maintenance-corridors_d_105
- id: ring-operative_d_006
text: "Cans are ready. Bay four, per the schedule."
role: ring-operative
access: [insider]
@@ -107,7 +107,7 @@ lines:
smuggler: "Containers cleared. Bay four. We're on."
detective: "Never hears this line."
- id: maintenance-corridors_d_106
- id: ring-operative_d_007
text: "Manifest is clean. Window opens in eighteen minutes."
role: ring-operative
access: [insider]
@@ -120,7 +120,7 @@ lines:
smuggler: "Paperwork's set. Eighteen minutes. That's earlier than planned — adjust."
detective: "Never hears this line."
- id: maintenance-corridors_d_107
- id: ring-operative_d_008
text: "Usual pickup. Route's the same."
role: ring-operative
access: [insider]
@@ -133,7 +133,7 @@ lines:
smuggler: "Same route, same handoff. No variations."
detective: "Never hears this line."
- id: maintenance-corridors_d_108
- id: ring-operative_d_009
text: "The gap's clear. Scan's done at 14:20. Next window's yours."
role: ring-operative
access: [insider]
@@ -146,10 +146,10 @@ lines:
smuggler: "14:20 scan, then the window opens. Twenty-five minutes clear."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
fact_id: investigation.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_109
- id: ring-operative_d_010
text: "Drin was asking about B-7. Twice today."
role: ring-operative
access: [insider]
@@ -165,7 +165,7 @@ lines:
fact_id: investigation.drin_inspection_pattern
confidence: suspects
- id: maintenance-corridors_d_110
- id: ring-operative_d_011
text: "Dead air through here to the C-4 junction. No coverage."
role: ring-operative
access: [insider]
@@ -178,10 +178,10 @@ lines:
smuggler: "Full confirmation. Meridian doesn't reach this section."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
fact_id: investigation.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_111
- id: ring-operative_d_012
text: "Voss confirmed the hold clears at 1400. He'll run the bay himself."
role: ring-operative
access: [insider]
@@ -194,7 +194,7 @@ lines:
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
- id: ring-operative_d_013
text: "Kael's on dock. He knows."
role: ring-operative
access: [insider]
@@ -211,7 +211,7 @@ lines:
# PRESSURE / WARNING SIGNALS
# ==========================================
- id: maintenance-corridors_d_113
- id: ring-operative_d_014
text: "Commission eyes on the terminal floor. Not here yet."
role: ring-operative
access: [insider]
@@ -224,7 +224,7 @@ lines:
smuggler: "Detective is visible on the main floor. Corridors are still clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_114
- id: ring-operative_d_015
text: "Hold the run. Drin's in the corridor section."
role: ring-operative
access: [insider]
@@ -237,7 +237,7 @@ lines:
smuggler: "Stop. Drin's in position. The window's closed."
detective: "Never hears this line."
- id: maintenance-corridors_d_115
- id: ring-operative_d_016
text: "Maret filed again. Voss has it. Don't move anything tonight."
role: ring-operative
access: [insider]
@@ -250,7 +250,7 @@ lines:
smuggler: "Maret flagged something. Voss is managing it. Stand down tonight."
detective: "Never hears this line."
- id: maintenance-corridors_d_116
- id: ring-operative_d_017
text: "We're clean. Same time tomorrow."
role: ring-operative
access: [insider]
@@ -267,7 +267,7 @@ lines:
# IF DETECTIVE SOMEHOW ENTERS — surface only
# ==========================================
- id: maintenance-corridors_d_117
- id: ring-operative_d_018
text: "Restricted access back here. Service personnel only."
role: ring-operative
access: [authority, public]
@@ -277,7 +277,7 @@ lines:
mood: [suspicious]
tags: [ring-operative, detective-path, access-denial]
- id: maintenance-corridors_d_118
- id: ring-operative_d_019
text: "I'm running maintenance. If there's a Commission access request, file it through Voss."
role: ring-operative
access: [authority]
@@ -290,7 +290,7 @@ lines:
smuggler: "Never encounters this."
detective: "Operative redirects to Voss. Everyone redirects to Voss. That's structural, not coincidence."
- id: maintenance-corridors_d_119
- id: ring-operative_d_020
text: "Nothing unusual back here. Conduit work. Same every cycle."
role: ring-operative
access: [authority, public]
@@ -305,7 +305,7 @@ lines:
# Surface-only: covers routine corridor presence without operational tells.
# ==========================================
- id: maintenance-corridors_d_120
- id: ring-operative_d_021
text: "Power relay's been cycling weird. I'm watching it."
role: ring-operative
access: [public, peer]
@@ -315,7 +315,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_121
- id: ring-operative_d_022
text: "Pael was supposed to handle B-7. He didn't show."
role: ring-operative
access: [public, peer]
@@ -325,7 +325,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, pael, generation-pass]
- id: maintenance-corridors_d_122
- id: ring-operative_d_023
text: "Watch the floor here. Drain panel's been loose three cycles."
role: ring-operative
access: [public, peer]
@@ -335,7 +335,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_123
- id: ring-operative_d_024
text: "Airflow's better down the B-section. C-section's got the recycler issue."
role: ring-operative
access: [public, peer]
@@ -345,7 +345,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, spatial, generation-pass]
- id: maintenance-corridors_d_124
- id: ring-operative_d_025
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]
@@ -358,7 +358,7 @@ lines:
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
- id: ring-operative_d_026
text: "Long shift. These corridors look the same after a while."
role: ring-operative
access: [public, peer]
@@ -368,7 +368,7 @@ lines:
mood: [frustrated]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_126
- id: ring-operative_d_027
text: "Scan's clear. I checked it an hour ago."
role: ring-operative
access: [public, peer]
@@ -1,7 +1,7 @@
# 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
# KEY LINE: transit-worker_d_001 — sounds like surveillance positioning inquiry
# Actually: Tev missed Kosse at the corridor junction and is looking for them
# Ticket: #307 | Sprint: 12
@@ -15,7 +15,7 @@ lines:
# Actually: Tev missed his partner at the regular meeting spot.
# ==========================================
- id: maintenance-corridors_d_001
- id: transit-worker_d_001
text: "You see which way Kosse went? Should've been through here twenty minutes ago."
role: transit-worker
access: [public]
@@ -36,7 +36,7 @@ lines:
# ROUTINE LINES
# ==========================================
- id: maintenance-corridors_d_002
- id: transit-worker_d_002
text: "They shifted the freight route again. Always forget which junction."
role: transit-worker
access: [public]
@@ -46,7 +46,7 @@ lines:
mood: [content]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_003
- id: transit-worker_d_003
text: "Just on break. Out of your way in a minute."
role: transit-worker
access: [public]
@@ -56,7 +56,7 @@ lines:
mood: [content]
tags: [tev, incidental]
- id: maintenance-corridors_d_004
- id: transit-worker_d_004
text: "Late freight route runs long. Can't do anything about it."
role: transit-worker
access: [public]
@@ -66,7 +66,7 @@ lines:
mood: [content]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_005
- id: transit-worker_d_005
text: "Waiting for someone. Shouldn't be long."
role: transit-worker
access: [public]
@@ -1,5 +1,5 @@
# Dialogue: bar-owner at The Last Shift
# NPC: Lera Osk (Tier 2, SYSTEM/OPERATOR)
# NPC: Lera Sessik (Tier 2, ANCHOR/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.
@@ -13,7 +13,7 @@ lines:
# GREETINGS — territorial welcome
# ==========================================
- id: the-last-shift_d_100
- id: bar-owner_d_001
text: "Usual?"
role: bar-owner
access: [public, peer, insider]
@@ -23,7 +23,7 @@ lines:
mood: [content]
tags: [lera, greeting, economy-of-words]
- id: the-last-shift_d_101
- id: bar-owner_d_002
text: "Long shift?"
role: bar-owner
access: [public, peer]
@@ -33,7 +33,7 @@ lines:
mood: [content]
tags: [lera, greeting]
- id: the-last-shift_d_102
- id: bar-owner_d_003
text: "Bar's clean, drink's cold. That's the deal."
role: bar-owner
access: [public]
@@ -43,7 +43,7 @@ lines:
mood: [content]
tags: [lera, greeting, house-rules]
- id: the-last-shift_d_103
- id: bar-owner_d_004
text: "Sess'll get you. I've got something in the back."
role: bar-owner
access: [public, peer]
@@ -57,7 +57,7 @@ lines:
# BAR MANAGEMENT — house rules, operations
# ==========================================
- id: the-last-shift_d_104
- id: bar-owner_d_005
text: "Fights go outside. First and last warning."
role: bar-owner
access: [public, peer, insider]
@@ -67,7 +67,7 @@ lines:
mood: [content]
tags: [lera, house-rules, authority]
- id: the-last-shift_d_105
- id: bar-owner_d_006
text: "Tab closes at end of cycle. Not at end of your memory."
role: bar-owner
access: [public, peer]
@@ -77,7 +77,7 @@ lines:
mood: [content]
tags: [lera, tab, house-rules]
- id: the-last-shift_d_106
- id: bar-owner_d_007
text: "Torek's running a tab. That's fine. Don't ask me why."
role: bar-owner
access: [peer]
@@ -91,7 +91,7 @@ lines:
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
- id: bar-owner_d_008
text: "Lattice stays off at the bar. Sign's on the wall."
role: bar-owner
access: [public, peer, insider]
@@ -102,7 +102,7 @@ lines:
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
- id: bar-owner_d_009
text: "Grain spirit's back. Limited run. Don't make it last two nights."
role: bar-owner
access: [public, peer, insider]
@@ -116,7 +116,7 @@ lines:
# PEOPLE — Lera knows everyone
# ==========================================
- id: the-last-shift_d_109
- id: bar-owner_d_010
text: "Kael's in the back. If you're looking."
role: bar-owner
access: [peer, insider]
@@ -129,7 +129,7 @@ lines:
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
- id: bar-owner_d_011
text: "Naia's been in early the last few nights. Doesn't say much."
role: bar-owner
access: [peer]
@@ -142,7 +142,7 @@ lines:
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
- id: bar-owner_d_012
text: "Ren? Been here two months. Quiet, pays their tab. That's all I can tell you."
role: bar-owner
access: [public, peer]
@@ -155,7 +155,7 @@ lines:
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
- id: bar-owner_d_013
text: "Tev? Good worker. Three years. Minds their own."
role: bar-owner
access: [public, peer]
@@ -172,7 +172,7 @@ lines:
# RING-ADJACENT — Lera's knowledge edge
# ==========================================
- id: the-last-shift_d_113
- id: bar-owner_d_014
text: "Whatever's happening in the back corner isn't my business. Don't make it my business."
role: bar-owner
access: [insider]
@@ -186,7 +186,7 @@ lines:
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
- id: bar-owner_d_015
text: "Kael's got my number. He's been a regular long enough that that means something."
role: bar-owner
access: [peer]
@@ -203,7 +203,7 @@ lines:
# DETECTIVE ENCOUNTER — neutral, unwelcoming
# ==========================================
- id: the-last-shift_d_115
- id: bar-owner_d_016
text: "Commission's welcome. Like everyone else. Bar rules apply."
role: bar-owner
access: [authority, public]
@@ -213,7 +213,7 @@ lines:
mood: [content]
tags: [lera, detective-path, neutral-welcome]
- id: the-last-shift_d_116
- id: bar-owner_d_017
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]
@@ -223,8 +223,8 @@ lines:
mood: [content]
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."
- id: bar-owner_d_018
text: "I've run this bar eighteen years without a Commission complaint. I'd like to keep it that way."
role: bar-owner
access: [authority]
trust: surface
@@ -233,7 +233,7 @@ lines:
mood: [suspicious]
tags: [lera, detective-path, authority-assertion]
- id: the-last-shift_d_118
- id: bar-owner_d_019
text: "I see a lot. I don't always remember all of it."
role: bar-owner
access: [authority]
@@ -250,7 +250,7 @@ lines:
# ELEVATED TRUST — what Lera actually says
# ==========================================
- id: the-last-shift_d_119
- id: bar-owner_d_020
text: "Naia's been asking questions I can't answer. Someone should."
role: bar-owner
access: [peer, insider]
@@ -267,7 +267,7 @@ lines:
# SIGN-OFF / CLOSING TIME
# ==========================================
- id: the-last-shift_d_120
- id: bar-owner_d_021
text: "Last call. Finish up."
role: bar-owner
access: [public, peer, insider]
@@ -277,7 +277,7 @@ lines:
mood: [content]
tags: [lera, closing]
- id: the-last-shift_d_121
- id: bar-owner_d_022
text: "Bar's closed. Come back next cycle."
role: bar-owner
access: [public, peer, insider]
@@ -287,7 +287,7 @@ lines:
mood: [content]
tags: [lera, closing]
- id: the-last-shift_d_122
- id: bar-owner_d_023
text: "Good night. Stay out of trouble."
role: bar-owner
access: [public, peer, insider]
@@ -301,7 +301,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_123
- id: bar-owner_d_024
text: "You look tired. Sit."
role: bar-owner
access: [peer, insider]
@@ -311,7 +311,7 @@ lines:
mood: [content]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_124
- id: bar-owner_d_025
text: "Same as yesterday?"
role: bar-owner
access: [peer, insider]
@@ -321,7 +321,7 @@ lines:
mood: [content]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_125
- id: bar-owner_d_026
text: "New face. Bar rules are on the wall."
role: bar-owner
access: [public]
@@ -331,7 +331,7 @@ lines:
mood: [content]
tags: [lera, greeting, newcomer, generation-pass]
- id: the-last-shift_d_126
- id: bar-owner_d_027
text: "No trouble tonight. I mean it."
role: bar-owner
access: [public, peer, insider]
@@ -341,7 +341,7 @@ lines:
mood: [content]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_127
- id: bar-owner_d_028
text: "Sess'll get you settled. I'm in the back."
role: bar-owner
access: [public, peer]
@@ -351,7 +351,7 @@ lines:
mood: [content]
tags: [lera, redirect, generation-pass]
- id: the-last-shift_d_128
- id: bar-owner_d_029
text: "We close at 2300. Don't make me say it twice."
role: bar-owner
access: [public, peer]
@@ -361,7 +361,7 @@ lines:
mood: [content]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_129
- id: bar-owner_d_030
text: "Good crowd tonight. Quiet enough."
role: bar-owner
access: [peer, insider]
@@ -371,7 +371,7 @@ lines:
mood: [content]
tags: [lera, ambient, generation-pass]
- id: the-last-shift_d_130
- id: bar-owner_d_031
text: "Bar's yours. I've got accounts."
role: bar-owner
access: [public, peer, insider]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — familiar, community register
# ==========================================
- id: the-last-shift_d_300
- id: bar-regular_d_001
text: "Long one today?"
role: bar-regular
access: [public, peer]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting]
- id: the-last-shift_d_301
- id: bar-regular_d_002
text: "You're in early. Good shift or bad shift?"
role: bar-regular
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting]
- id: the-last-shift_d_302
- id: bar-regular_d_003
text: "Grain spirit's back. I already had two. Don't judge."
role: bar-regular
access: [public, peer]
@@ -46,7 +46,7 @@ lines:
# TERMINAL FLOOR — off-duty opinions
# ==========================================
- id: the-last-shift_d_303
- id: bar-regular_d_004
text: "Push today. Bay six was backed up until 2000. Voss wasn't happy."
role: bar-regular
access: [public, peer]
@@ -56,7 +56,7 @@ lines:
mood: [content]
tags: [bar-regular, terminal, voss]
- id: the-last-shift_d_304
- id: bar-regular_d_005
text: "Maret flagged another one. Nobody knows what Voss does with them."
role: bar-regular
access: [peer]
@@ -69,7 +69,7 @@ lines:
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
- id: bar-regular_d_006
text: "Bay four's been held all week. Three cycles. Nobody's said what's in it."
role: bar-regular
access: [peer]
@@ -82,7 +82,7 @@ lines:
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
- id: bar-regular_d_007
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]
@@ -95,7 +95,7 @@ lines:
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
- id: bar-regular_d_008
text: "Commission audit's coming cycle fifteen. Everyone's running around double-checking everything."
role: bar-regular
access: [public, peer]
@@ -108,7 +108,7 @@ lines:
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
- id: bar-regular_d_009
text: "Voss has been on the floor more than usual this cycle. Watching everything."
role: bar-regular
access: [peer]
@@ -125,7 +125,7 @@ lines:
# COMMUNITY — the full life of this district
# ==========================================
- id: the-last-shift_d_309
- id: bar-regular_d_010
text: "Drifters lost again. Four-nil this time. I'm done watching until they rebuild."
role: bar-regular
access: [public, peer]
@@ -135,7 +135,7 @@ lines:
mood: [content]
tags: [bar-regular, sports, drifters]
- id: the-last-shift_d_310
- id: bar-regular_d_011
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]
@@ -145,49 +145,49 @@ lines:
mood: [content]
tags: [bar-regular, infrastructure, grievance]
- id: the-last-shift_d_311
- id: bar-regular_d_012
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]
topic: [personal, routine]
mood: [content]
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
- id: bar-regular_d_013
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]
topic: [personal, routine]
mood: [content]
tags: [bar-regular, clinic, community]
- id: the-last-shift_d_313
- id: bar-regular_d_014
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]
topic: [institution, personal]
mood: [content]
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
- id: bar-regular_d_015
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]
topic: [institution, personal]
mood: [suspicious]
tags: [bar-regular, workers-association, voss, transition-window]
dual_lens:
@@ -198,7 +198,7 @@ lines:
# DETECTIVE ENCOUNTER — community wariness
# ==========================================
- id: the-last-shift_d_315
- id: bar-regular_d_016
text: "You new? Haven't seen you before."
role: bar-regular
access: [public]
@@ -208,7 +208,7 @@ lines:
mood: [suspicious]
tags: [bar-regular, detective-path, recognition]
- id: the-last-shift_d_316
- id: bar-regular_d_017
text: "Commission? I just drink here. Whatever you're after, I probably can't help."
role: bar-regular
access: [authority]
@@ -218,7 +218,7 @@ lines:
mood: [suspicious]
tags: [bar-regular, detective-path, deflection]
- id: the-last-shift_d_317
- id: bar-regular_d_018
text: "Ask Lera. She knows what happens in this bar better than anyone."
role: bar-regular
access: [authority, public]
@@ -232,7 +232,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_318
- id: bar-regular_d_019
text: "Another cycle done. Same tomorrow."
role: bar-regular
access: [public, peer]
@@ -242,7 +242,7 @@ lines:
mood: [content]
tags: [bar-regular, closing]
- id: the-last-shift_d_319
- id: bar-regular_d_020
text: "Safe home. Watch the corridors after 2200."
role: bar-regular
access: [public, peer]
@@ -256,7 +256,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_320
- id: bar-regular_d_021
text: "Pull up a chair. Floor's full otherwise."
role: bar-regular
access: [public, peer]
@@ -266,7 +266,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_321
- id: bar-regular_d_022
text: "You're in late. Voss kept you?"
role: bar-regular
access: [peer]
@@ -276,7 +276,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting, voss, generation-pass]
- id: the-last-shift_d_322
- id: bar-regular_d_023
text: "Just got off. Bay six all night. My back knows it."
role: bar-regular
access: [public, peer]
@@ -286,7 +286,7 @@ lines:
mood: [frustrated]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_323
- id: bar-regular_d_024
text: "Drifters are playing Mirren next cycle. Home game. Might actually go."
role: bar-regular
access: [public, peer]
@@ -296,17 +296,17 @@ lines:
mood: [content]
tags: [bar-regular, sports, community, generation-pass]
- id: the-last-shift_d_324
- id: bar-regular_d_025
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]
topic: [institution, personal]
mood: [content]
tags: [bar-regular, infrastructure, grievance, generation-pass]
- id: the-last-shift_d_325
- id: bar-regular_d_026
text: "New hire's asking questions on the floor again. Good. Someone should."
role: bar-regular
access: [peer]
@@ -316,7 +316,7 @@ lines:
mood: [content]
tags: [bar-regular, new-hire, community, generation-pass]
- id: the-last-shift_d_326
- id: bar-regular_d_027
text: "Span gate was loud today. Three cycles now. That's not normal."
role: bar-regular
access: [public, peer]
@@ -326,7 +326,7 @@ lines:
mood: [suspicious]
tags: [bar-regular, infrastructure, generation-pass]
- id: the-last-shift_d_327
- id: bar-regular_d_028
text: "Kael's not in tonight. Unusual."
role: bar-regular
access: [peer]
@@ -339,7 +339,7 @@ lines:
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
- id: bar-regular_d_029
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]
@@ -349,7 +349,7 @@ lines:
mood: [content]
tags: [bar-regular, lera, humor, generation-pass]
- id: the-last-shift_d_329
- id: bar-regular_d_030
text: "Good night. Don't let the Commission ruin your morning."
role: bar-regular
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — service register
# ==========================================
- id: the-last-shift_d_200
- id: bartender_d_001
text: "What'll it be?"
role: bartender
access: [public, peer, insider]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [sess, greeting]
- id: the-last-shift_d_201
- id: bartender_d_002
text: "Back early tonight. Long shift?"
role: bartender
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [sess, greeting, social]
- id: the-last-shift_d_202
- id: bartender_d_003
text: "Grain spirit's back. Lera's running it at cycle price for two more days."
role: bartender
access: [public, peer, insider]
@@ -46,7 +46,7 @@ lines:
# BAR OBSERVATIONS — social intelligence
# ==========================================
- id: the-last-shift_d_203
- id: bartender_d_004
text: "Kael's been in most nights this week. Quiet though. Not his usual."
role: bartender
access: [peer]
@@ -59,7 +59,7 @@ lines:
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
- id: bartender_d_005
text: "Torek tipped well again tonight. Always does. Nicer than you'd expect for Commission."
role: bartender
access: [peer, public]
@@ -72,7 +72,7 @@ lines:
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
- id: bartender_d_006
text: "Naia was in earlier. Didn't stay. Left something on the table — looked like a note. Lera has it."
role: bartender
access: [peer]
@@ -85,7 +85,7 @@ lines:
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
- id: bartender_d_007
text: "Ren? Yeah, they're always in the morning crowd. Don't say much. Good for the morning."
role: bartender
access: [public, peer]
@@ -95,7 +95,7 @@ lines:
mood: [content]
tags: [sess, ren, drifter]
- id: the-last-shift_d_207
- id: bartender_d_008
text: "Tev watches the door. Every shift. Lera likes it that way."
role: bartender
access: [peer]
@@ -108,7 +108,7 @@ lines:
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
- id: bartender_d_009
text: "Sera was in earlier. She usually stays later. She left when Torek came in."
role: bartender
access: [peer]
@@ -125,7 +125,7 @@ lines:
# SOCIAL / COMMUNITY
# ==========================================
- id: the-last-shift_d_209
- id: bartender_d_010
text: "Drifters lost again. Tev took it hard. Drank two too many."
role: bartender
access: [public, peer]
@@ -135,7 +135,7 @@ lines:
mood: [content]
tags: [sess, tev, sports, community]
- id: the-last-shift_d_210
- id: bartender_d_011
text: "Workers' Association meeting's Thursday. Half the regulars will be here Friday in a mood either way."
role: bartender
access: [public, peer]
@@ -145,7 +145,7 @@ lines:
mood: [content]
tags: [sess, workers-association, community]
- id: the-last-shift_d_211
- id: bartender_d_012
text: "Quiet night. Good for everyone."
role: bartender
access: [public, peer, insider]
@@ -159,7 +159,7 @@ lines:
# DETECTIVE ENCOUNTER — helpful, unfocused
# ==========================================
- id: the-last-shift_d_212
- id: bartender_d_013
text: "Commission comes in sometimes. Not usually this early in the cycle."
role: bartender
access: [authority, public]
@@ -169,7 +169,7 @@ lines:
mood: [content]
tags: [sess, detective-path, observation]
- id: the-last-shift_d_213
- id: bartender_d_014
text: "I know most of the regulars. Ask me anything. I probably know something."
role: bartender
access: [authority, public]
@@ -182,7 +182,7 @@ lines:
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
- id: bartender_d_015
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]
@@ -196,7 +196,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_215
- id: bartender_d_016
text: "Last round's on the board. After that, Lera calls it."
role: bartender
access: [public, peer, insider]
@@ -206,7 +206,7 @@ lines:
mood: [content]
tags: [sess, closing]
- id: the-last-shift_d_216
- id: bartender_d_017
text: "Good night. Safe walk."
role: bartender
access: [public, peer, insider]
@@ -220,7 +220,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_217
- id: bartender_d_018
text: "The usual? Or trying something different tonight?"
role: bartender
access: [peer, insider]
@@ -230,7 +230,7 @@ lines:
mood: [content]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_218
- id: bartender_d_019
text: "Busy one tonight. Give me a second."
role: bartender
access: [public, peer, insider]
@@ -240,7 +240,7 @@ lines:
mood: [content]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_219
- id: bartender_d_020
text: "Good shift or bad shift? Your face says one thing, your order'll confirm it."
role: bartender
access: [peer]
@@ -250,7 +250,7 @@ lines:
mood: [content]
tags: [sess, greeting, social, generation-pass]
- id: the-last-shift_d_220
- id: bartender_d_021
text: "Lera says the specials board is final. Don't ask me to substitute."
role: bartender
access: [public, peer]
@@ -260,7 +260,7 @@ lines:
mood: [content]
tags: [sess, lera, house-rules, generation-pass]
- id: the-last-shift_d_221
- id: bartender_d_022
text: "Torek's in his usual spot. Corner table. Second drink already."
role: bartender
access: [peer]
@@ -270,7 +270,7 @@ lines:
mood: [content]
tags: [sess, torek, observation, generation-pass]
- id: the-last-shift_d_222
- id: bartender_d_023
text: "Ren's been here since this morning. Still on the same drink."
role: bartender
access: [peer]
@@ -280,7 +280,7 @@ lines:
mood: [content]
tags: [sess, ren, observation, generation-pass]
- id: the-last-shift_d_223
- id: bartender_d_024
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]
@@ -290,7 +290,7 @@ lines:
mood: [content]
tags: [sess, sports, community, generation-pass]
- id: the-last-shift_d_224
- id: bartender_d_025
text: "Slow night for a change. Enjoy it."
role: bartender
access: [public, peer, insider]
@@ -300,7 +300,7 @@ lines:
mood: [content]
tags: [sess, ambient, generation-pass]
- id: the-last-shift_d_225
- id: bartender_d_026
text: "Tab's cleared. Lera'll be pleased."
role: bartender
access: [peer]
@@ -1,7 +1,7 @@
# 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
# KEY LINE: day-worker_d_001 — sounds like intel gathering on night operations
# Actually: asking about after-hours freight work availability
# Ticket: #307 | Sprint: 12
@@ -15,7 +15,7 @@ lines:
# Actually: asking if there's paid after-hours freight work available.
# ==========================================
- id: the-last-shift_d_001
- id: day-worker_d_001
text: "Night rotation still short-handed? Heard the after-hours freight pays decent."
role: day-worker
access: [public]
@@ -36,7 +36,7 @@ lines:
# ROUTINE LINES
# ==========================================
- id: the-last-shift_d_002
- id: day-worker_d_002
text: "Bay seven any better this cycle? The super was a problem last time."
role: day-worker
access: [public]
@@ -46,7 +46,7 @@ lines:
mood: [content]
tags: [ren, job-seeking]
- id: the-last-shift_d_003
- id: day-worker_d_003
text: "Sova's decent. Station layout makes sense once you learn it."
role: day-worker
access: [public]
@@ -56,7 +56,7 @@ lines:
mood: [content]
tags: [ren, atmospheric]
- id: the-last-shift_d_004
- id: day-worker_d_004
text: "Just passing through. Maybe not. Depends on the work."
role: day-worker
access: [public]
@@ -66,7 +66,7 @@ lines:
mood: [content]
tags: [ren, drifter]
- id: the-last-shift_d_005
- id: day-worker_d_005
text: "Cheap drink here's better than most stations I've stopped at."
role: day-worker
access: [public]
@@ -76,7 +76,7 @@ lines:
mood: [content]
tags: [ren, atmospheric]
- id: the-last-shift_d_006
- id: day-worker_d_006
text: "Any idea when the manifest deadline runs? Trying to time when the work window opens."
role: day-worker
access: [public]
@@ -12,7 +12,7 @@ lines:
# SOCIAL — off-shift casual
# ========================================
- id: the-last-shift_d_001
- id: kael-davan_d_015
text: "Saved you a seat. Lera's got the spiced rice tonight."
role: dock-worker
access: [insider, peer]
@@ -22,7 +22,7 @@ lines:
topic: [personal, colleague]
tags: [kael, greeting, phase-1]
- id: the-last-shift_d_002
- id: kael-davan_d_016
text: "First round's mine. Don't argue — you covered me last week."
role: dock-worker
access: [insider, peer]
@@ -32,7 +32,7 @@ lines:
topic: [personal]
tags: [kael, social, phase-1]
- id: the-last-shift_d_003
- id: kael-davan_d_017
text: "Voss tried to dock my overtime again. Lera told him off. Wish I'd seen it."
role: dock-worker
access: [insider, peer]
@@ -42,7 +42,7 @@ lines:
topic: [colleague, routine]
tags: [kael, humor, voss, lera]
- id: the-last-shift_d_004
- id: kael-davan_d_018
text: "Torek's holding court at the bar again. How does he afford to drink like that?"
role: dock-worker
access: [insider, peer]
@@ -55,7 +55,7 @@ lines:
fact_id: investigation.torek_spending_pattern
confidence: suspects
- id: the-last-shift_d_005
- id: kael-davan_d_019
text: "Quiet night. I like these ones. No drama, just people winding down."
role: dock-worker
access: [peer]
@@ -65,7 +65,7 @@ lines:
topic: [personal]
tags: [kael, atmospheric]
- id: the-last-shift_d_006
- id: kael-davan_d_020
text: "Lera asked about my overtime schedule. Told her I'm trying to cut back."
role: dock-worker
access: [insider, peer]
@@ -79,7 +79,7 @@ lines:
# NAIA REFERENCES — personal, unprompted
# ========================================
- id: the-last-shift_d_007
- id: kael-davan_d_021
text: "Naia's meeting me here after her shift. Try not to embarrass me."
role: dock-worker
access: [insider, peer]
@@ -89,7 +89,7 @@ lines:
topic: [personal]
tags: [kael, unprompted, naia-reference]
- id: the-last-shift_d_008
- id: kael-davan_d_022
text: "Naia wants to plan a trip. Off-station. Somewhere with actual sky. Sounds expensive."
role: dock-worker
access: [insider, peer]
@@ -99,7 +99,7 @@ lines:
topic: [personal]
tags: [kael, unprompted, naia-reference]
- id: the-last-shift_d_009
- id: kael-davan_d_023
text: "She worries. I tell her it's just dock work. She doesn't buy it anymore."
role: dock-worker
access: [insider]
@@ -113,7 +113,7 @@ lines:
# RING OPS — bar context, trust-gated
# ========================================
- id: the-last-shift_d_010
- id: kael-davan_d_024
text: "Nils wants to talk. Tomorrow, bay side. Said it's about volume."
role: dock-worker
access: [insider]
@@ -123,7 +123,7 @@ lines:
topic: [danger]
tags: [kael, ring-ops, nils]
- id: the-last-shift_d_011
- id: kael-davan_d_025
text: "Keep it light tonight. Torek's listening and he doesn't know when to stop."
role: dock-worker
access: [insider]
@@ -133,7 +133,7 @@ lines:
topic: [danger, colleague]
tags: [kael, ring-ops, torek, caution]
- id: the-last-shift_d_012
- id: kael-davan_d_026
text: "Lera knows more than she lets on. She won't say anything — but don't test it."
role: dock-worker
access: [insider]
@@ -150,7 +150,7 @@ lines:
# POST-CONTRADICTION — at the bar, damaged trust
# ========================================
- id: the-last-shift_d_013
- id: kael-davan_d_027
text: "I saved your seat. ...Force of habit."
role: dock-worker
access: [insider, peer]
@@ -160,7 +160,7 @@ lines:
topic: [personal]
tags: [kael, contaminated-trust, phase-5]
- id: the-last-shift_d_014
- id: kael-davan_d_028
text: "Naia keeps asking why I'm quiet. I told her work stress. Not wrong."
role: dock-worker
access: [insider]
@@ -170,7 +170,7 @@ lines:
topic: [personal]
tags: [kael, contaminated-trust, naia-reference, phase-5]
- id: the-last-shift_d_015
- id: kael-davan_d_029
text: "We're good, right? You and me. We're still good."
role: dock-worker
access: [insider]
@@ -187,7 +187,7 @@ lines:
# ========================================
# first_meeting: InteractionMemory.count == 0. RelationshipState: Unknown → Public access only.
- id: the-last-shift_d_016
- id: kael-davan_d_030
text: "Evening. Friend of someone's, or first time at Lera's?"
role: dock-worker
access: [public]
@@ -199,7 +199,7 @@ lines:
notes: "Layer 2 greeting — first meeting at the bar. Kael is curious about a stranger but not suspicious. Off-shift, looser than at the terminal."
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
- id: the-last-shift_d_017
- id: kael-davan_d_031
text: "You made it. I was starting to think something came up."
role: dock-worker
access: [peer, insider]
@@ -211,7 +211,7 @@ lines:
notes: "Layer 2 greeting — established, bar context. Warm, they were expected. Kael was watching for them."
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
- id: the-last-shift_d_018
- id: kael-davan_d_032
text: "Still in the district. Thought you'd be done by now."
role: dock-worker
access: [authority]
@@ -223,7 +223,7 @@ lines:
notes: "Layer 2 greeting — repeat with authority figure, bar context. Politely questioning their continued presence. The bar is supposed to be off-limits from work."
# post-confrontation: confrontation in InteractionMemory.notable_events. NPC mood shifted to suspicious/frustrated.
- id: the-last-shift_d_019
- id: kael-davan_d_033
text: "You came back."
role: dock-worker
access: [peer, insider]
@@ -9,7 +9,7 @@ location: the-last-shift
role: commission-liaison
lines:
- id: the-last-shift_d_204
- id: pc-detective_d_001
text: "Good evening. Off the clock, more or less. Enjoying the local hospitality."
role: commission-liaison
access: [public]
@@ -19,7 +19,7 @@ lines:
topic: [personal]
tags: [pc-detective, greeting, public-tier]
- id: the-last-shift_d_205
- id: pc-detective_d_002
text: "Ms. Venn recommended the ale. She was right. This district has its charms."
role: commission-liaison
access: [public]
@@ -28,7 +28,7 @@ lines:
topic: [routine, colleague]
tags: [pc-detective, social, public-tier, sera]
- id: the-last-shift_d_206
- id: pc-detective_d_003
text: "Just unwinding. The review can wait until morning. Have a good evening."
role: commission-liaison
access: [public]
@@ -9,7 +9,7 @@ location: the-last-shift
role: dock-worker
lines:
- id: the-last-shift_d_201
- id: pc-smuggler_d_001
text: "Evening. Just having a drink with friends. Can I help you?"
role: dock-worker
access: [public]
@@ -19,7 +19,7 @@ lines:
topic: [personal]
tags: [pc-smuggler, greeting, public-tier]
- id: the-last-shift_d_202
- id: pc-smuggler_d_002
text: "Lera's ale is the one good thing about a ten-hour shift. Don't let anyone tell you different."
role: dock-worker
access: [public]
@@ -29,7 +29,7 @@ lines:
topic: [routine]
tags: [pc-smuggler, social, public-tier]
- id: the-last-shift_d_203
- id: pc-smuggler_d_003
text: "Off the clock, thanks. Whatever it is, it can wait until morning."
role: dock-worker
access: [authority]
@@ -38,7 +38,7 @@ lines:
# Phase 3-5: same warmth, but the detective is now watching
# ============================================================
- id: the-last-shift_d_101
- id: sera-venn_d_001
text: "There you are. Grab a seat — Lera's doing that thing with the grain spirit again. Don't ask, just trust me and order the ale."
role: bar-regular
access: [peer]
@@ -48,7 +48,7 @@ lines:
mood: [warm, content]
tags: [greeting, phase-1, establishes-warmth]
- id: the-last-shift_d_102
- id: sera-venn_d_002
text: "Corner booth's free. I saved it — force of habit."
role: bar-regular
access: [peer]
@@ -58,7 +58,7 @@ lines:
mood: [warm]
tags: [greeting, phase-1, spatial-anchor]
- id: the-last-shift_d_103
- id: sera-venn_d_003
text: "You look like you've been reading manifests all day. Sit. I'll get you something that isn't recycled air."
role: bar-regular
access: [peer]
@@ -68,7 +68,7 @@ lines:
mood: [warm, content]
tags: [greeting, phase-1-2]
- id: the-last-shift_d_104
- id: sera-venn_d_004
text: "Evening. Same booth, same drink?"
role: bar-regular
access: [peer]
@@ -78,7 +78,7 @@ lines:
mood: [content]
tags: [greeting, phase-2-5, repeatable]
- id: the-last-shift_d_105
- id: sera-venn_d_005
text: "Good timing. Naia was here earlier — you just missed her."
role: bar-regular
access: [peer]
@@ -88,7 +88,7 @@ lines:
mood: [content]
tags: [greeting, phase-2, naia-reference]
- id: the-last-shift_d_106
- id: sera-venn_d_006
text: "Grab a seat. Quiet night so far — Lera's in a good mood, which means the ale's fresh."
role: bar-regular
access: [peer, public]
@@ -98,7 +98,7 @@ lines:
mood: [content]
tags: [greeting, phase-1-3]
- id: the-last-shift_d_107
- id: sera-venn_d_007
text: "Hey. Long day?"
role: bar-regular
access: [peer]
@@ -108,7 +108,7 @@ lines:
mood: [frustrated]
tags: [greeting, phase-3-5, post-pattern, shorter]
- id: the-last-shift_d_108
- id: sera-venn_d_008
text: "Same time, same place. You're becoming a regular."
role: bar-regular
access: [peer, public]
@@ -123,7 +123,7 @@ lines:
# What Sera says during normal bar evenings
# ============================================================
- id: the-last-shift_d_110
- id: sera-venn_d_009
text: "The Last Shift. Official name. Everyone calls it Lera's, though. Eighteen years behind that counter."
role: bar-regular
access: [peer]
@@ -133,7 +133,7 @@ lines:
mood: [content]
tags: [orientation, district-background]
- id: the-last-shift_d_111
- id: sera-venn_d_010
text: "See the card game in the corner? That's Harek's table. Security officer, off-duty. Harmless — just loud."
role: bar-regular
access: [peer]
@@ -143,7 +143,7 @@ lines:
mood: [content]
tags: [orientation, npc-introduction]
- id: the-last-shift_d_112
- id: sera-venn_d_011
text: "Most of the after-shift crowd comes from the Terminal. Dock workers, scheduler types. They drink hard and leave early."
role: bar-regular
access: [peer]
@@ -153,7 +153,7 @@ lines:
mood: [content]
tags: [orientation, district-background]
- id: the-last-shift_d_113
- id: sera-venn_d_012
text: "Sova's not bad, once you know the rhythms. Shift change is when things move. Between shifts is when you can breathe."
role: bar-regular
access: [peer]
@@ -163,7 +163,7 @@ lines:
mood: [content]
tags: [setting-atmosphere]
- id: the-last-shift_d_114
- id: sera-venn_d_013
text: "I do lattice calibration, mostly. Commission-standard equipment checks. Relay nodes, terminal diagnostics. Exciting stuff."
role: bar-regular
access: [peer]
@@ -173,7 +173,7 @@ lines:
mood: [content]
tags: [self-introduction, institutional-background]
- id: the-last-shift_d_115
- id: sera-venn_d_014
text: "I transferred here fourteen months ago. Border-system relay posting before this. Sova was supposed to be the quiet one."
role: bar-regular
access: [peer]
@@ -183,7 +183,7 @@ lines:
mood: [content]
tags: [backstory, phase-1]
- id: the-last-shift_d_116
- id: sera-venn_d_015
text: "Lera remembers every regular's order. Eighteen years of that. Don't ask how she does it — she'll just pour you one and prove it."
role: bar-regular
access: [peer, public]
@@ -193,7 +193,7 @@ lines:
mood: [warm]
tags: [community-texture]
- id: the-last-shift_d_117
- id: sera-venn_d_016
text: "The shift crowd clears out by 19:00. After that it's just us — regulars, a few stragglers. The good hours."
role: bar-regular
access: [peer]
@@ -203,7 +203,7 @@ lines:
mood: [content]
tags: [setting-atmosphere, routine]
- id: the-last-shift_d_118
- id: sera-venn_d_017
text: "You adapting to station gravity yet? Point-nine-three standard takes a week to stop noticing."
role: bar-regular
access: [peer]
@@ -213,7 +213,7 @@ lines:
mood: [content]
tags: [setting-detail, phase-1]
- id: the-last-shift_d_119
- id: sera-venn_d_018
text: "I like this booth. Corner. You can see the door and the bar. Force of institutional habit, I suppose."
role: bar-regular
access: [peer]
@@ -232,7 +232,7 @@ lines:
# --- Surface trust ---
- id: the-last-shift_d_120
- id: sera-venn_d_019
text: "The Commission runs maintenance checks quarterly. I handle the field side — equipment calibration, relay diagnostics. Standard."
role: bar-regular
access: [peer, authority]
@@ -242,7 +242,7 @@ lines:
mood: [content]
tags: [institutional-background]
- id: the-last-shift_d_121
- id: sera-venn_d_020
text: "Naia teaches the station kids. Primary education, mostly. She's good at it — patient. The kids adore her."
role: bar-regular
access: [peer]
@@ -252,7 +252,7 @@ lines:
mood: [warm]
tags: [naia-reference, phase-2]
- id: the-last-shift_d_122
- id: sera-venn_d_021
text: "The district runs on freight. Terminal handles most of it — container processing, manifest filing. Bread and butter."
role: bar-regular
access: [peer, public]
@@ -262,7 +262,7 @@ lines:
mood: [content]
tags: [district-context]
- id: the-last-shift_d_123
- id: sera-venn_d_022
text: "I don't know the dock workers well. I'm Commission, they're logistics. Different worlds, same station."
role: bar-regular
access: [peer, authority]
@@ -272,7 +272,7 @@ lines:
mood: [content]
tags: [deflection-surface, distance-from-hub]
- id: the-last-shift_d_124
- id: sera-venn_d_023
text: "Most people here are decent. Working shifts, paying bills, coming to Lera's. It's a community."
role: bar-regular
access: [peer, public]
@@ -282,7 +282,7 @@ lines:
mood: [content]
tags: [community-framing]
- id: the-last-shift_d_125
- id: sera-venn_d_024
text: "I can look things up if you need. Commission terminals access compliance records, shift logs, equipment histories. Part of the job."
role: bar-regular
access: [peer, authority]
@@ -294,7 +294,7 @@ lines:
# --- Real trust ---
- id: the-last-shift_d_130
- id: sera-venn_d_025
text: "Naia's been worried lately. About Kael — her partner. He's a dock worker at the Terminal. Late nights, evasive answers. She confided in me."
role: bar-regular
access: [peer]
@@ -307,7 +307,7 @@ lines:
fact_id: "relationship.kael_naia_connection"
confidence: "knows_of"
- id: the-last-shift_d_131
- id: sera-venn_d_026
text: "The Commission kiosk on Level 3 logs every compliance check. If someone ran an unauthorized scan, it would show up there. Hypothetically."
role: bar-regular
access: [peer, authority]
@@ -317,7 +317,7 @@ lines:
mood: [focused]
tags: [institutional-access, phase-2-3, self-incriminating-hint]
- id: the-last-shift_d_132
- id: sera-venn_d_027
text: "I worry about Naia. She doesn't deserve whatever's happening. She just wants Kael to come home at a reasonable hour."
role: bar-regular
access: [peer]
@@ -327,7 +327,7 @@ lines:
mood: [frustrated, anxious]
tags: [motivation-reveal, phase-2-3]
- id: the-last-shift_d_133
- id: sera-venn_d_028
text: "Compliance data goes back eighteen months on the Commission systems. Anyone with field-tech access could cross-reference shift records with manifest filings."
role: bar-regular
access: [peer, authority]
@@ -340,7 +340,7 @@ lines:
fact_id: "investigation.manifest_discrepancy"
confidence: "suspects"
- id: the-last-shift_d_134
- id: sera-venn_d_029
text: "I'm not an investigator. I calibrate equipment and file reports. But I notice things. Patterns in the data, mostly."
role: bar-regular
access: [peer]
@@ -350,7 +350,7 @@ lines:
mood: [focused]
tags: [self-awareness, capability-reveal]
- id: the-last-shift_d_135
- id: sera-venn_d_030
text: "You know me. I wouldn't sit on something if I thought it mattered. But not everything that looks irregular IS irregular."
role: bar-regular
access: [peer]
@@ -360,7 +360,7 @@ lines:
mood: [anxious]
tags: [deflection-real-tier, phase-4, the-lie]
- id: the-last-shift_d_136
- id: sera-venn_d_031
text: "Some of the regulars here have hard lives. Grey economy, tight margins. I'm not judging. I'm just a field tech who likes the ale."
role: bar-regular
access: [peer]
@@ -372,7 +372,7 @@ lines:
# --- Secret trust ---
- id: the-last-shift_d_140
- id: sera-venn_d_032
text: "I ran a compliance check. Unauthorized. Six weeks ago. Naia was so worried about Kael — I thought I could help. I found manifest discrepancies tied to his shifts."
role: bar-regular
access: [peer]
@@ -385,7 +385,7 @@ lines:
fact_id: "investigation.manifest_discrepancy"
confidence: "knows_details"
- id: the-last-shift_d_141
- id: sera-venn_d_033
text: "If I report it, Naia's life falls apart. Kael gets investigated. Maybe arrested. She didn't ask me to find this. I did it on my own."
role: bar-regular
access: [peer]
@@ -395,7 +395,7 @@ lines:
mood: [anxious]
tags: [motivation-confession, phase-5]
- id: the-last-shift_d_142
- id: sera-venn_d_034
text: "The irregularities correlate with Torek Lintar's inspection shifts. That's why I can't — I can't be near him. He doesn't know I know."
role: bar-regular
access: [peer]
@@ -408,7 +408,7 @@ lines:
fact_id: "investigation.shift_mismatch"
confidence: "knows_details"
- id: the-last-shift_d_143
- id: sera-venn_d_035
text: "I don't know what you're looking for. I told you everything I know. If you're asking whether I'm hiding something — I'm a field tech, not an analyst."
role: bar-regular
access: [peer, authority]
@@ -418,7 +418,7 @@ lines:
mood: [suspicious, anxious]
tags: [denial-under-pressure, phase-4-5, the-wall]
- id: the-last-shift_d_144
- id: sera-venn_d_036
text: "Every tip I gave you was real. The district background, the shift patterns, the regulars. I didn't steer you wrong. I just... didn't give you everything."
role: bar-regular
access: [peer]
@@ -434,7 +434,7 @@ lines:
# Phase 1-2: genuinely helpful. Phase 3-5: overcompensation.
# ============================================================
- id: the-last-shift_d_150
- id: sera-venn_d_037
text: "Oh — before I forget. The dock scheduler, Maret? She's sharp. If you need freight records context, she's the one to ask."
role: bar-regular
access: [peer]
@@ -447,7 +447,7 @@ lines:
fact_id: "relationship.trust_network"
confidence: "suspects"
- id: the-last-shift_d_151
- id: sera-venn_d_038
text: "I checked the relay calibration logs for the Terminal this morning. Nothing flagged. But the data throughput was higher than standard — more manifests processed than usual."
role: bar-regular
access: [peer, authority]
@@ -457,7 +457,7 @@ lines:
mood: [focused]
tags: [unprompted, overcompensation, phase-3-4]
- id: the-last-shift_d_152
- id: sera-venn_d_039
text: "I asked around — quietly — about the shift rotation schedule. Voss has been adjusting it more than usual this cycle. Might mean nothing."
role: bar-regular
access: [peer]
@@ -467,7 +467,7 @@ lines:
mood: [focused, frustrated]
tags: [unprompted, overcompensation, phase-3-4]
- id: the-last-shift_d_153
- id: sera-venn_d_040
text: "The maintenance corridor access logs are Commission-maintained. I can pull them if you need. Just say the word."
role: bar-regular
access: [peer, authority]
@@ -477,7 +477,7 @@ lines:
mood: [content]
tags: [unprompted, information-offer, overcompensation, phase-3-5]
- id: the-last-shift_d_154
- id: sera-venn_d_041
text: "Naia mentioned Kael came home late again last night. Third time this week. She's trying not to make it a thing."
role: bar-regular
access: [peer]
@@ -490,7 +490,7 @@ lines:
fact_id: "relationship.kael_naia_connection"
confidence: "knows_details"
- id: the-last-shift_d_155
- id: sera-venn_d_042
text: "You should know — Lera watches out for her regulars. If people have been asking about you, she'd know. She just won't volunteer it."
role: bar-regular
access: [peer]
@@ -505,7 +505,7 @@ lines:
# Phase 3-4: avoidance excuses, topic deflection, controlled evasion
# ============================================================
- id: the-last-shift_d_160
- id: sera-venn_d_043
text: "Actually, I should head out. Early shift tomorrow — calibration rounds start at 07:30."
role: bar-regular
access: [peer, public]
@@ -515,7 +515,7 @@ lines:
mood: [content]
tags: [avoidance-excuse-1, torek-trigger, phase-3]
- id: the-last-shift_d_161
- id: sera-venn_d_044
text: "Sorry — headache coming on. Station air does that sometimes. I'll catch you tomorrow."
role: bar-regular
access: [peer, public]
@@ -525,7 +525,7 @@ lines:
mood: [content]
tags: [avoidance-excuse-2, torek-trigger, phase-3]
- id: the-last-shift_d_162
- id: sera-venn_d_045
text: "I need to check something at the Commission kiosk. Forgot to file a diagnostic report. I'll be quick — or not. You know how the system is."
role: bar-regular
access: [peer]
@@ -535,7 +535,7 @@ lines:
mood: [content]
tags: [avoidance-excuse-3, torek-trigger, phase-3]
- id: the-last-shift_d_163
- id: sera-venn_d_046
text: "Torek? He's just loud. I don't love loud drunks. Nothing personal — I just like a quieter evening."
role: bar-regular
access: [peer]
@@ -545,7 +545,7 @@ lines:
mood: [content]
tags: [torek-deflection, phase-4, the-deflection]
- id: the-last-shift_d_164
- id: sera-venn_d_047
text: "Manifests? I wouldn't know — I'm on the equipment side, not the freight side. Different department entirely."
role: bar-regular
access: [peer, authority]
@@ -555,7 +555,7 @@ lines:
mood: [content]
tags: [topic-deflection, cargo-avoidance, phase-3-5]
- id: the-last-shift_d_165
- id: sera-venn_d_048
text: "Anyway — did you try the ale? Lera got a new supply run in. Much better than last week's batch."
role: bar-regular
access: [peer]
@@ -565,7 +565,7 @@ lines:
mood: [content]
tags: [redirect, warmth-recovery, phase-4]
- id: the-last-shift_d_166
- id: sera-venn_d_049
text: "I'm just a field tech. I maintain equipment. I don't track people or shipments — that's your department."
role: bar-regular
access: [peer, authority]
@@ -575,7 +575,7 @@ lines:
mood: [suspicious]
tags: [deflection-firm, phase-4-5, the-wall]
- id: the-last-shift_d_167
- id: sera-venn_d_050
text: "Let's talk about something else. How's the investigation going — generally? Making progress?"
role: bar-regular
access: [peer]
@@ -591,7 +591,7 @@ lines:
# Same surface warmth, but calibrated. Trust is contaminated.
# ============================================================
- id: the-last-shift_d_170
- id: sera-venn_d_051
text: "Same booth. Drink's on me tonight."
role: bar-regular
access: [peer]
@@ -601,7 +601,7 @@ lines:
mood: [warm]
tags: [post-discovery, surface-warmth, phase-5]
- id: the-last-shift_d_171
- id: sera-venn_d_052
text: "I pulled the relay diagnostic data you asked about. Clean. No anomalies in the terminal equipment this quarter."
role: bar-regular
access: [peer, authority]
@@ -611,7 +611,7 @@ lines:
mood: [content]
tags: [post-discovery, overcompensation, phase-5]
- id: the-last-shift_d_172
- id: sera-venn_d_053
text: "You've been quiet tonight. Everything all right with the case?"
role: bar-regular
access: [peer]
@@ -621,7 +621,7 @@ lines:
mood: [frustrated]
tags: [post-discovery, awareness, phase-5]
- id: the-last-shift_d_173
- id: sera-venn_d_054
text: "I know you're good at what you do. That's why I'm glad you're here. This district needed someone thorough."
role: bar-regular
access: [peer]
@@ -631,7 +631,7 @@ lines:
mood: [warm, anxious]
tags: [post-discovery, genuine-but-contaminated, phase-5]
- id: the-last-shift_d_174
- id: sera-venn_d_055
text: "If there's anything I can help with — anything at all — you just ask. You know that."
role: bar-regular
access: [peer]
@@ -650,7 +650,7 @@ lines:
# first_meeting (detective path): RelationshipState Unknown → Public/Authority access.
# Sera clocks them as Commission immediately. Makes them welcome anyway.
- id: the-last-shift_d_175
- id: sera-venn_d_056
text: "Commission? Don't worry, I don't bite. I'm Sera. Corner booth's better — you can see the whole room."
role: bar-regular
access: [public, authority]
@@ -663,7 +663,7 @@ lines:
# established (detective path): count >= 3, RelationshipState: Known/Friendly → Peer access.
# Warmer, more personal. Sera has been waiting for them.
- id: the-last-shift_d_176
- id: sera-venn_d_057
text: "I was hoping you'd come in. Saved the corner booth. How'd today go?"
role: bar-regular
access: [peer]
@@ -676,7 +676,7 @@ lines:
# first_meeting (smuggler path): smuggler is dock crew, Sera knows the district crowd.
# Peer/insider access from the start — Sera has social network, not institutional distance.
- id: the-last-shift_d_177
- id: sera-venn_d_058
text: "Terminal crew? Grab a seat. Kael usually sits in the back if you're looking for him."
role: bar-regular
access: [peer, insider]
@@ -689,7 +689,7 @@ lines:
# post-confrontation: confrontation logged. Sera's surface warmth is present but qualified.
# She doesn't know where they stand. The welcome is real but uncertain.
- id: the-last-shift_d_178
- id: sera-venn_d_059
text: "You're back. I wasn't sure you would be."
role: bar-regular
access: [peer]
@@ -13,7 +13,7 @@ lines:
# ARRIVALS — checking in
# ==========================================
- id: the-terminal_d_500
- id: courier_d_001
text: "Route's clear. Moving on schedule."
role: courier
access: [public, peer]
@@ -23,7 +23,7 @@ lines:
mood: [content]
tags: [renn, greeting, operational]
- id: the-terminal_d_501
- id: courier_d_002
text: "Manifest for bay three. Needs a scheduler stamp."
role: courier
access: [public, peer]
@@ -33,7 +33,7 @@ lines:
mood: [content]
tags: [renn, greeting, procedural]
- id: the-terminal_d_502
- id: courier_d_003
text: "Inbound from Havara. Three cans, all matched."
role: courier
access: [public, peer]
@@ -47,7 +47,7 @@ lines:
# SURFACE ROUTINE — low-information, on-script
# ==========================================
- id: the-terminal_d_503
- id: courier_d_004
text: "Not staying. Just the drop."
role: courier
access: [public, peer]
@@ -57,7 +57,7 @@ lines:
mood: [content]
tags: [renn, operational, brief]
- id: the-terminal_d_504
- id: courier_d_005
text: "Paperwork's clean. You can check."
role: courier
access: [public, peer, authority]
@@ -67,7 +67,7 @@ lines:
mood: [content]
tags: [renn, procedural, documentation]
- id: the-terminal_d_505
- id: courier_d_006
text: "Pickup's scheduled at 1600. I'll be back."
role: courier
access: [public, peer]
@@ -77,7 +77,7 @@ lines:
mood: [content]
tags: [renn, operational]
- id: the-terminal_d_506
- id: courier_d_007
text: "Corridor's clear from my end. No delays."
role: courier
access: [public, peer]
@@ -94,7 +94,7 @@ lines:
# RING-ADJACENT — insider/real trust only
# ==========================================
- id: the-terminal_d_507
- id: courier_d_008
text: "Documentation's handled. Bay four, per the note."
role: courier
access: [insider]
@@ -107,7 +107,7 @@ lines:
smuggler: "Renn's confirming the paperwork trail. Bay four, as arranged."
detective: "Never hears this line."
- id: the-terminal_d_508
- id: courier_d_009
text: "Transit stamp's current. Nothing flagged at the gate."
role: courier
access: [insider]
@@ -117,7 +117,7 @@ lines:
mood: [focused]
tags: [renn, ring-ops, documentation]
- id: the-terminal_d_509
- id: courier_d_010
text: "Pickup window is the same as last cycle. Twenty minutes."
role: courier
access: [insider]
@@ -130,7 +130,7 @@ lines:
smuggler: "Twenty minutes. Transition window confirmation."
detective: "Never hears this line."
- id: the-terminal_d_510
- id: courier_d_011
text: "Tell Voss the hold note clears at 1400."
role: courier
access: [insider]
@@ -147,7 +147,7 @@ lines:
# DETECTIVE ENCOUNTER — surface cooperation
# ==========================================
- id: the-terminal_d_511
- id: courier_d_012
text: "Licensed freight courier. Manifest's available on request."
role: courier
access: [authority]
@@ -157,7 +157,7 @@ lines:
mood: [content]
tags: [renn, detective-path, procedural]
- id: the-terminal_d_512
- id: courier_d_013
text: "I drop documentation. What's inside the containers isn't my business."
role: courier
access: [authority]
@@ -170,7 +170,7 @@ lines:
smuggler: "That's the answer. Clean and correct."
detective: "Classic courier deflection. Legally accurate. Operationally suspicious."
- id: the-terminal_d_513
- id: courier_d_014
text: "I've been running this route for two years. Nothing unusual."
role: courier
access: [authority, public]
@@ -184,7 +184,7 @@ lines:
# DEPARTURES
# ==========================================
- id: the-terminal_d_514
- id: courier_d_015
text: "That's the drop. I'm out."
role: courier
access: [public, peer]
@@ -194,7 +194,7 @@ lines:
mood: [content]
tags: [renn, closing]
- id: the-terminal_d_515
- id: courier_d_016
text: "Next run's tomorrow. Early."
role: courier
access: [public, peer]
@@ -208,7 +208,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_516
- id: courier_d_017
text: "Two stops today. First one's done."
role: courier
access: [public, peer]
@@ -218,7 +218,7 @@ lines:
mood: [content]
tags: [renn, greeting, generation-pass]
- id: the-terminal_d_517
- id: courier_d_018
text: "Outbound for Havara. Needs a bay assignment."
role: courier
access: [public, peer]
@@ -228,7 +228,7 @@ lines:
mood: [content]
tags: [renn, greeting, procedural, generation-pass]
- id: the-terminal_d_518
- id: courier_d_019
text: "Gate's backed up. Running twenty minutes late."
role: courier
access: [public, peer]
@@ -238,7 +238,7 @@ lines:
mood: [content]
tags: [renn, delay, generation-pass]
- id: the-terminal_d_519
- id: courier_d_020
text: "Paperwork's in order. Same as always."
role: courier
access: [public, peer, authority]
@@ -248,7 +248,7 @@ lines:
mood: [content]
tags: [renn, procedural, generation-pass]
- id: the-terminal_d_520
- id: courier_d_021
text: "Quick turnaround today. Back out by 1300."
role: courier
access: [public, peer]
@@ -258,7 +258,7 @@ lines:
mood: [content]
tags: [renn, operational, generation-pass]
- id: the-terminal_d_521
- id: courier_d_022
text: "Signed off on bay three. That's me done here."
role: courier
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_200
- id: dock-worker_d_001
text: "Push today. Bay six's backed up."
role: dock-worker
access: [public, peer]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [dock-worker, greeting]
- id: the-terminal_d_201
- id: dock-worker_d_002
text: "Late night last cycle. Bay eight ran until 2300."
role: dock-worker
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [frustrated]
tags: [dock-worker, greeting]
- id: the-terminal_d_202
- id: dock-worker_d_003
text: "Voss is on already. Floor's locked down tight."
role: dock-worker
access: [peer]
@@ -49,7 +49,7 @@ lines:
# FLOOR TALK — routine, cargo, observation
# ==========================================
- id: the-terminal_d_203
- id: dock-worker_d_004
text: "Bay four's been on hold three days. Nobody says why."
role: dock-worker
access: [public, peer]
@@ -62,7 +62,7 @@ lines:
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
- id: dock-worker_d_005
text: "Manifest looks clean. Three inbounds, two outbounds. Light day."
role: dock-worker
access: [public, peer]
@@ -72,7 +72,7 @@ lines:
mood: [content]
tags: [dock-worker, manifest]
- id: the-terminal_d_205
- id: dock-worker_d_006
text: "Maret's been at that terminal all morning. Never good."
role: dock-worker
access: [peer]
@@ -85,7 +85,7 @@ lines:
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
- id: dock-worker_d_007
text: "Drin ran bay six again last cycle. Third inspection this week."
role: dock-worker
access: [public, peer]
@@ -98,7 +98,7 @@ lines:
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
- id: dock-worker_d_008
text: "Transition window's twice as long with Commission eyes on the floor."
role: dock-worker
access: [peer]
@@ -111,7 +111,7 @@ lines:
smuggler: "Commission presence during transition. Window's compromised."
detective: "Workers slow down when Commission is visible. Normal — or trained."
- id: the-terminal_d_208
- id: dock-worker_d_009
text: "Good crew this cycle. Linn and Pael are both on."
role: dock-worker
access: [public, peer]
@@ -121,7 +121,7 @@ lines:
mood: [content]
tags: [dock-worker, pael, community]
- id: the-terminal_d_209
- id: dock-worker_d_010
text: "Cargo mark's off by forty, according to Maret. Voss logged it and moved on."
role: dock-worker
access: [peer]
@@ -137,7 +137,7 @@ lines:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_210
- id: dock-worker_d_011
text: "C-4 containers haven't been checked in three days. Not our problem, apparently."
role: dock-worker
access: [peer]
@@ -154,7 +154,7 @@ lines:
# COMMUNITY / CASUAL
# ==========================================
- id: the-terminal_d_211
- id: dock-worker_d_012
text: "You heading to the Last Shift after? Lera's got the grain spirit back."
role: dock-worker
access: [public, peer]
@@ -164,7 +164,7 @@ lines:
mood: [content]
tags: [dock-worker, social, the-last-shift]
- id: the-terminal_d_212
- id: dock-worker_d_013
text: "Workers' Association meeting's cycle twenty-two. Handoff protocol's back on the agenda."
role: dock-worker
access: [public, peer]
@@ -174,7 +174,7 @@ lines:
mood: [content]
tags: [dock-worker, workers-association, community]
- id: the-terminal_d_213
- id: dock-worker_d_014
text: "Drifters lost again. Four-three. Pael seemed to take it personally."
role: dock-worker
access: [public, peer]
@@ -188,7 +188,7 @@ lines:
# DETECTIVE ENCOUNTER — deflection, redirect
# ==========================================
- id: the-terminal_d_214
- id: dock-worker_d_015
text: "Manifest questions go to the scheduler. I'm floor-side."
role: dock-worker
access: [authority, public]
@@ -198,7 +198,7 @@ lines:
mood: [suspicious]
tags: [dock-worker, detective-path, redirect]
- id: the-terminal_d_215
- id: dock-worker_d_016
text: "Commission's asked before. Same answers. We run a clean floor."
role: dock-worker
access: [authority]
@@ -208,7 +208,7 @@ lines:
mood: [suspicious]
tags: [dock-worker, detective-path, deflection]
- id: the-terminal_d_216
- id: dock-worker_d_017
text: "Voss handles discrepancies. That's above my level."
role: dock-worker
access: [authority, peer]
@@ -222,7 +222,7 @@ lines:
# PEER CANDOR — elevated trust
# ==========================================
- id: the-terminal_d_217
- id: dock-worker_d_018
text: "Maret's going to push it. She doesn't leave a number sitting."
role: dock-worker
access: [peer]
@@ -235,7 +235,7 @@ lines:
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
- id: dock-worker_d_019
text: "Voss runs a tight floor. Maybe too tight, if you take my meaning."
role: dock-worker
access: [peer]
@@ -252,7 +252,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-terminal_d_219
- id: dock-worker_d_020
text: "Bay's clear. Good shift."
role: dock-worker
access: [public, peer]
@@ -262,7 +262,7 @@ lines:
mood: [content]
tags: [dock-worker, closing]
- id: the-terminal_d_220
- id: dock-worker_d_021
text: "Night crew's here. We're done."
role: dock-worker
access: [public, peer]
@@ -276,7 +276,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_221
- id: dock-worker_d_022
text: "Early start. Bay two's already loaded."
role: dock-worker
access: [public, peer]
@@ -286,7 +286,7 @@ lines:
mood: [content]
tags: [dock-worker, greeting, generation-pass]
- id: the-terminal_d_222
- id: dock-worker_d_023
text: "Air recyclers are loud this morning. Must be the outer ring issue again."
role: dock-worker
access: [public, peer]
@@ -296,7 +296,7 @@ lines:
mood: [content]
tags: [dock-worker, station, generation-pass]
- id: the-terminal_d_223
- id: dock-worker_d_024
text: "Pael's in early. Something must be broken in B-7 again."
role: dock-worker
access: [peer]
@@ -306,7 +306,7 @@ lines:
mood: [content]
tags: [dock-worker, pael, generation-pass]
- id: the-terminal_d_224
- id: dock-worker_d_025
text: "Running light today. Maybe I'll actually make it to the Last Shift before close."
role: dock-worker
access: [public, peer]
@@ -316,7 +316,7 @@ lines:
mood: [content]
tags: [dock-worker, social, generation-pass]
- id: the-terminal_d_225
- id: dock-worker_d_026
text: "Bay three's got a scan hold. Not my problem. Not my bay."
role: dock-worker
access: [public, peer]
@@ -326,7 +326,7 @@ lines:
mood: [content]
tags: [dock-worker, scan-hold, generation-pass]
- id: the-terminal_d_226
- id: dock-worker_d_027
text: "New hire's back. Asking questions again. Good kid."
role: dock-worker
access: [peer]
@@ -336,7 +336,7 @@ lines:
mood: [content]
tags: [dock-worker, new-hire, generation-pass]
- id: the-terminal_d_227
- id: dock-worker_d_028
text: "Handoff's in ten. Don't disappear on me."
role: dock-worker
access: [peer]
@@ -346,7 +346,7 @@ lines:
mood: [content]
tags: [dock-worker, handoff, generation-pass]
- id: the-terminal_d_228
- id: dock-worker_d_029
text: "Span gate cycled early. All inbounds are going to be late. Plan around it."
role: dock-worker
access: [public, peer]
@@ -356,7 +356,7 @@ lines:
mood: [suspicious]
tags: [dock-worker, span-gate, generation-pass]
- id: the-terminal_d_229
- id: dock-worker_d_030
text: "Linn's not in today. We're running two short on bay six."
role: dock-worker
access: [public, peer]
@@ -366,7 +366,7 @@ lines:
mood: [frustrated]
tags: [dock-worker, staffing, generation-pass]
- id: the-terminal_d_230
- id: dock-worker_d_031
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]
@@ -376,7 +376,7 @@ lines:
mood: [content]
tags: [dock-worker, pael, humor, generation-pass]
- id: the-terminal_d_231
- id: dock-worker_d_032
text: "Overtime's posted if you want it. Bay eight, 1800 to 2200."
role: dock-worker
access: [public, peer]
@@ -386,7 +386,7 @@ lines:
mood: [content]
tags: [dock-worker, overtime, generation-pass]
- id: the-terminal_d_232
- id: dock-worker_d_033
text: "Off on time today. First time this cycle."
role: dock-worker
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — Phase 1: Comfort (0-10 min)
# ========================================
- id: the-terminal_d_001
- id: kael-davan_d_034
text: "There you are. Good — I was starting to wonder."
role: dock-worker
access: [insider, peer]
@@ -22,7 +22,7 @@ lines:
topic: [colleague]
tags: [kael, greeting, phase-1]
- id: the-terminal_d_002
- id: kael-davan_d_035
text: "Morning. Freight's stacked clean today. Should be a smooth one."
role: dock-worker
access: [insider, peer]
@@ -32,7 +32,7 @@ lines:
topic: [routine]
tags: [kael, greeting, phase-1]
- id: the-terminal_d_003
- id: kael-davan_d_036
text: "Voss has the schedule posted already. Keen today. That's suspicious."
role: dock-worker
access: [insider, peer]
@@ -42,7 +42,7 @@ lines:
topic: [colleague, routine]
tags: [kael, greeting, humor]
- id: the-terminal_d_004
- id: kael-davan_d_037
text: "Hey. Grab a coffee before Voss finds something to complain about."
role: dock-worker
access: [insider, peer]
@@ -52,7 +52,7 @@ lines:
topic: [colleague]
tags: [kael, greeting, phase-1]
- id: the-terminal_d_005
- id: kael-davan_d_038
text: "You look like you slept about as well as I did."
role: dock-worker
access: [peer]
@@ -63,7 +63,7 @@ lines:
tags: [kael, greeting, casual]
# Public greeting for detective / outsiders
- id: the-terminal_d_006
- id: kael-davan_d_039
text: "Can I help you? If you're looking for the supervisor, Voss is by the schedule board."
role: dock-worker
access: [public, authority]
@@ -77,7 +77,7 @@ lines:
# SHIFT TALK — routine dialogue
# ========================================
- id: the-terminal_d_007
- id: kael-davan_d_040
text: "Bay three's running behind. If we don't clear it by shift end, Voss is going to lose it."
role: dock-worker
access: [insider, peer]
@@ -87,7 +87,7 @@ lines:
topic: [routine, cargo]
tags: [kael, operational]
- id: the-terminal_d_008
- id: kael-davan_d_041
text: "Manifest says this one's forty kilos over. Logging error, probably."
role: dock-worker
access: [public, peer]
@@ -100,7 +100,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_009
- id: kael-davan_d_042
text: "Night shift left the dock in decent shape for once. Miracles happen."
role: dock-worker
access: [insider, peer]
@@ -110,7 +110,7 @@ lines:
topic: [routine]
tags: [kael, humor]
- id: the-terminal_d_010
- id: kael-davan_d_043
text: "Loading arm three's been grinding all week. Someone should file a maintenance ticket."
role: dock-worker
access: [public, peer]
@@ -120,7 +120,7 @@ lines:
topic: [routine]
tags: [kael, environmental]
- id: the-terminal_d_011
- id: kael-davan_d_044
text: "Maret rerouted the bay four queue. Tight schedule today."
role: dock-worker
access: [insider, peer]
@@ -130,7 +130,7 @@ lines:
topic: [cargo, colleague]
tags: [kael, operational, maret]
- id: the-terminal_d_012
- id: kael-davan_d_045
text: "Shift transition in twenty. After that it's Drin's problem."
role: dock-worker
access: [insider, peer]
@@ -140,7 +140,7 @@ lines:
topic: [routine]
tags: [kael, operational]
- id: the-terminal_d_013
- id: kael-davan_d_046
text: "Naia's got a school event tonight. I'm heading home straight after shift."
role: dock-worker
access: [insider, peer]
@@ -150,7 +150,7 @@ lines:
topic: [personal]
tags: [kael, naia-reference, casual]
- id: the-terminal_d_014
- id: kael-davan_d_047
text: "New hire started on bay seven. Keen. Reminds me of my first week."
role: dock-worker
access: [public, peer]
@@ -164,7 +164,7 @@ lines:
# RING COORDINATION — insider, real trust
# ========================================
- id: the-terminal_d_015
- id: kael-davan_d_048
text: "Container 4471 is flagged. I'll reroute it during shift transition."
role: dock-worker
access: [insider]
@@ -174,7 +174,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, operational, phase-2]
- id: the-terminal_d_016
- id: kael-davan_d_049
text: "Voss kept the window clear. Twenty minutes, maybe twenty-five."
role: dock-worker
access: [insider]
@@ -184,7 +184,7 @@ lines:
topic: [cargo, routine]
tags: [kael, ring-ops, operational, phase-2]
- id: the-terminal_d_017
- id: kael-davan_d_050
text: "Renn's on the corridor side. Package moves when I give the signal."
role: dock-worker
access: [insider]
@@ -194,7 +194,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, renn, phase-2]
- id: the-terminal_d_018
- id: kael-davan_d_051
text: "Manifest's clean on my end. Maret processed it without questions."
role: dock-worker
access: [insider]
@@ -204,7 +204,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, maret, phase-2]
- id: the-terminal_d_019
- id: kael-davan_d_052
text: "Nils wants higher volume next cycle. I told him we're already tight."
role: dock-worker
access: [insider]
@@ -214,7 +214,7 @@ lines:
topic: [danger, trust]
tags: [kael, ring-ops, nils, tension, phase-2]
- id: the-terminal_d_020
- id: kael-davan_d_053
text: "Drin waved through bay four without checking. At least he's reliable."
role: dock-worker
access: [insider]
@@ -227,7 +227,7 @@ lines:
fact_id: investigation.drin_inspection_pattern
confidence: knows_details
- id: the-terminal_d_021
- id: kael-davan_d_054
text: "Keep an eye on Maret. She's been looking at the manifests longer than usual."
role: dock-worker
access: [insider]
@@ -237,7 +237,7 @@ lines:
topic: [colleague, danger]
tags: [kael, ring-ops, maret, caution, phase-2]
- id: the-terminal_d_022
- id: kael-davan_d_055
text: "There's a sealed one in the batch tonight. Nils says components. Don't open it."
role: dock-worker
access: [insider]
@@ -251,7 +251,7 @@ lines:
# TRUST-GATED — deep trust, secret tier
# ========================================
- id: the-terminal_d_023
- id: kael-davan_d_056
text: "Some days I wonder how long we can keep this up."
role: dock-worker
access: [insider]
@@ -261,7 +261,7 @@ lines:
topic: [trust, personal]
tags: [kael, vulnerability, friend-arc]
- id: the-terminal_d_024
- id: kael-davan_d_057
text: "Naia asked me again last night. Where I go. Why I'm late. I hate lying to her."
role: dock-worker
access: [insider]
@@ -271,7 +271,7 @@ lines:
topic: [personal, trust]
tags: [kael, naia-reference, vulnerability]
- id: the-terminal_d_025
- id: kael-davan_d_058
text: "If something happens to me — look out for Naia. Promise me that."
role: dock-worker
access: [insider]
@@ -285,7 +285,7 @@ lines:
# UNPROMPTED — Kael volunteers or warns
# ========================================
- id: the-terminal_d_026
- id: kael-davan_d_059
text: "Heads up — Voss is in a mood. Keep your head down this shift."
role: dock-worker
access: [insider, peer]
@@ -295,7 +295,7 @@ lines:
topic: [colleague, danger]
tags: [kael, unprompted, warning]
- id: the-terminal_d_027
- id: kael-davan_d_060
text: "I'm buying at Lera's after shift. You're coming. No arguments."
role: dock-worker
access: [insider, peer]
@@ -305,7 +305,7 @@ lines:
topic: [personal, colleague]
tags: [kael, unprompted, social]
- id: the-terminal_d_028
- id: kael-davan_d_061
text: "Torek's been running his mouth at the bar. You might want to have a word."
role: dock-worker
access: [insider]
@@ -319,7 +319,7 @@ lines:
# DEFLECTION — Phase 2-4: Post-contradiction
# ========================================
- id: the-terminal_d_029
- id: kael-davan_d_062
text: "It's nothing. Just work stuff."
role: dock-worker
access: [insider, peer]
@@ -329,7 +329,7 @@ lines:
topic: [personal]
tags: [kael, deflection, phase-4]
- id: the-terminal_d_030
- id: kael-davan_d_063
text: "Don't worry about it. I've got it handled."
role: dock-worker
access: [insider, peer]
@@ -339,7 +339,7 @@ lines:
topic: [personal, trust]
tags: [kael, deflection, phase-4]
- id: the-terminal_d_031
- id: kael-davan_d_064
text: "I was checking on a maintenance issue. That's all."
role: dock-worker
access: [insider, peer]
@@ -349,7 +349,7 @@ lines:
topic: [routine]
tags: [kael, deflection, lying, phase-4]
- id: the-terminal_d_032
- id: kael-davan_d_065
text: "You're reading too much into it. Come on — shift's starting."
role: dock-worker
access: [insider, peer]
@@ -359,7 +359,7 @@ lines:
topic: [routine, trust]
tags: [kael, deflection, subject-change, phase-4]
- id: the-terminal_d_033
- id: kael-davan_d_066
text: "Can we not do this here? Voss is watching."
role: dock-worker
access: [insider]
@@ -374,7 +374,7 @@ lines:
# ========================================
# Path A: Gentle confrontation
- id: the-terminal_d_034
- id: kael-davan_d_067
text: "I... yeah. I need to tell you something. But not here."
role: dock-worker
access: [insider]
@@ -385,7 +385,7 @@ lines:
tags: [kael, confession-path, phase-5]
# Path B: Harsh pressure
- id: the-terminal_d_035
- id: kael-davan_d_068
text: "You want to report me? Go ahead. See what Nils does to both of us."
role: dock-worker
access: [insider]
@@ -396,7 +396,7 @@ lines:
tags: [kael, hostile-path, phase-5]
# If smuggler pushes further (gentle)
- id: the-terminal_d_036
- id: kael-davan_d_069
text: "Naia can't keep living like this. Neither can I. I'm looking for a way out."
role: dock-worker
access: [insider]
@@ -407,7 +407,7 @@ lines:
tags: [kael, confession, naia-reference, phase-5]
# If detective confronts (authority path)
- id: the-terminal_d_037
- id: kael-davan_d_070
text: "I don't know what you're talking about. I load containers. That's my job."
role: dock-worker
access: [public, authority]
@@ -416,7 +416,7 @@ lines:
topic: [routine]
tags: [kael, deflection, detective-path]
- id: the-terminal_d_038
- id: kael-davan_d_071
text: "You want to ask questions, talk to Voss. He runs the schedule."
role: dock-worker
access: [public, authority]
@@ -432,7 +432,7 @@ lines:
# ========================================
# first_meeting: InteractionMemory.count == 0. RelationshipState: Unknown → Public access only.
- id: the-terminal_d_050
- id: kael-davan_d_072
text: "This section is dock crew and logistics. If you're Commission or admin, check-in's back that way."
role: dock-worker
access: [public]
@@ -444,7 +444,7 @@ lines:
notes: "Layer 2 greeting — first meeting. Kael is helpful but orienting the stranger away from his work."
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
- id: the-terminal_d_051
- id: kael-davan_d_073
text: "You're back. Good — bay three's been quiet. I'll fill you in."
role: dock-worker
access: [peer, insider]
@@ -456,7 +456,7 @@ lines:
notes: "Layer 2 greeting — established. Warm, practical, skips pleasantries. Treats them as crew."
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
- id: the-terminal_d_052
- id: kael-davan_d_074
text: "Back again. I'm on shift — if you have questions, make them quick."
role: dock-worker
access: [authority]
@@ -468,7 +468,7 @@ lines:
notes: "Layer 2 greeting — repeat with authority figure. Professional, clipped, not hostile. Kael can't refuse but won't volunteer."
# post-confrontation: confrontation in InteractionMemory.notable_events. NPC mood shifted to suspicious/frustrated.
- id: the-terminal_d_053
- id: kael-davan_d_075
text: "Didn't expect you today."
role: dock-worker
access: [peer, insider]
@@ -1,7 +1,7 @@
# 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
# KEY LINE: maintenance-tech_d_001 — sounds like surveillance knowledge, is a maintenance complaint
# Ticket: #307 | Sprint: 12
location: the-terminal
@@ -14,7 +14,7 @@ lines:
# Actually: the B-7 seal fails when people use the corridor too much. Pael notices load.
# ==========================================
- id: the-terminal_d_040
- id: maintenance-tech_d_001
text: "B-7's had three seal failures this cycle. Someone's been through there more than they should."
role: maintenance-tech
access: [public]
@@ -35,7 +35,7 @@ lines:
# ROUTINE LINES
# ==========================================
- id: the-terminal_d_041
- id: maintenance-tech_d_002
text: "Air recycler's running hot again. Third deferred ticket this week."
role: maintenance-tech
access: [public]
@@ -45,7 +45,7 @@ lines:
mood: [content]
tags: [pael, atmospheric]
- id: the-terminal_d_042
- id: maintenance-tech_d_003
text: "Loading arm three's been on my list for a month. Management keeps deferring it."
role: maintenance-tech
access: [public]
@@ -55,7 +55,7 @@ lines:
mood: [content]
tags: [pael, atmospheric]
- id: the-terminal_d_043
- id: maintenance-tech_d_004
text: "If the gate tone shifts, stay clear of bay six. She needs recalibration."
role: maintenance-tech
access: [public]
@@ -65,7 +65,7 @@ lines:
mood: [content]
tags: [pael, warning, helpful]
- id: the-terminal_d_044
- id: maintenance-tech_d_005
text: "Not me. I just fix them."
role: maintenance-tech
access: [public]
@@ -75,7 +75,7 @@ lines:
mood: [content]
tags: [pael, response]
- id: the-terminal_d_045
- id: maintenance-tech_d_006
text: "Gate hum's been three cycles off-spec. Nobody cares until it stops completely."
role: maintenance-tech
access: [public]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — early days
# ==========================================
- id: the-terminal_d_400
- id: new-hire_d_001
text: "Still figuring out the bay numbering. Six and eight are in the wrong order."
role: new-hire
access: [public, peer]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [new-hire, greeting, orientation]
- id: the-terminal_d_401
- id: new-hire_d_002
text: "Voss told me to check in with you. Is that the standard thing?"
role: new-hire
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [new-hire, greeting, voss]
- id: the-terminal_d_402
- id: new-hire_d_003
text: "First full cycle on the floor. Trying to stay out of the way."
role: new-hire
access: [public, peer]
@@ -46,7 +46,7 @@ lines:
# FRESH OBSERVATIONS — the dangerous innocence
# ==========================================
- id: the-terminal_d_403
- id: new-hire_d_004
text: "Does bay four usually get held that long? The other bays turned over twice already."
role: new-hire
access: [public, peer]
@@ -59,7 +59,7 @@ lines:
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
- id: new-hire_d_005
text: "I saw someone go into C-4 last night without a scan log. Is that normal?"
role: new-hire
access: [public, peer]
@@ -76,7 +76,7 @@ lines:
confidence: suspects
notes: "High-value inadvertent witness line. Operationally dangerous for the ring."
- id: the-terminal_d_405
- id: new-hire_d_006
text: "Why does the transition window only get twenty minutes? The stations I trained at ran thirty."
role: new-hire
access: [public, peer]
@@ -89,7 +89,7 @@ lines:
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
- id: new-hire_d_007
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]
@@ -105,7 +105,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_407
- id: new-hire_d_008
text: "Maintenance corridor access is just off C-4, right? I took the wrong turn twice."
role: new-hire
access: [public, peer]
@@ -122,7 +122,7 @@ lines:
# LEARNING / QUESTIONS
# ==========================================
- id: the-terminal_d_408
- id: new-hire_d_009
text: "What's the Commission kiosk actually for? Nobody seems to use it."
role: new-hire
access: [public, peer]
@@ -132,7 +132,7 @@ lines:
mood: [content]
tags: [new-hire, commission, curiosity]
- id: the-terminal_d_409
- id: new-hire_d_010
text: "Maret seems stressed. Is that always the case or is something going on?"
role: new-hire
access: [peer]
@@ -145,7 +145,7 @@ lines:
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
- id: new-hire_d_011
text: "Voss seems really serious. Is he always like that?"
role: new-hire
access: [public, peer]
@@ -155,7 +155,7 @@ lines:
mood: [content]
tags: [new-hire, voss, curiosity]
- id: the-terminal_d_411
- id: new-hire_d_012
text: "How long before Voss trusts you to run a bay on your own?"
role: new-hire
access: [peer]
@@ -169,7 +169,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-terminal_d_412
- id: new-hire_d_013
text: "That was a full shift. I'll have the bay numbers down by next cycle."
role: new-hire
access: [public, peer]
@@ -179,7 +179,7 @@ lines:
mood: [content]
tags: [new-hire, closing]
- id: the-terminal_d_413
- id: new-hire_d_014
text: "Good day. Lot to learn. But good."
role: new-hire
access: [public, peer]
@@ -193,7 +193,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_414
- id: new-hire_d_015
text: "Is there a trick to the manifest terminal or does it always lag like this?"
role: new-hire
access: [public, peer]
@@ -203,7 +203,7 @@ lines:
mood: [content]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_415
- id: new-hire_d_016
text: "How long has Maret been here? She seems like she knows everything."
role: new-hire
access: [peer]
@@ -213,7 +213,7 @@ lines:
mood: [content]
tags: [new-hire, maret, curiosity, generation-pass]
- id: the-terminal_d_416
- id: new-hire_d_017
text: "Nobody told me about the span gate frequency. I kept thinking something was wrong."
role: new-hire
access: [public, peer]
@@ -223,7 +223,7 @@ lines:
mood: [content]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_417
- id: new-hire_d_018
text: "The Last Shift — is that actually a bar? I haven't been yet."
role: new-hire
access: [public, peer]
@@ -233,7 +233,7 @@ lines:
mood: [content]
tags: [new-hire, social, generation-pass]
- id: the-terminal_d_418
- id: new-hire_d_019
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]
@@ -243,7 +243,7 @@ lines:
mood: [content]
tags: [new-hire, orientation, humor, generation-pass]
- id: the-terminal_d_419
- id: new-hire_d_020
text: "Who's Drin? I keep hearing the name but haven't met them."
role: new-hire
access: [public, peer]
@@ -253,7 +253,7 @@ lines:
mood: [content]
tags: [new-hire, drin, curiosity, generation-pass]
- id: the-terminal_d_420
- id: new-hire_d_021
text: "Three cycles in and I still can't tell when Voss is satisfied. Is there a tell?"
role: new-hire
access: [peer]
@@ -17,7 +17,7 @@ lines:
# Second playthrough: "That's me."
# ========================================
- id: the-terminal_d_044
- id: pc-detective_d_004
text: "Good morning. Commission Liaison Office. I'm conducting a routine compliance review."
role: commission-liaison
access: [public]
@@ -27,7 +27,7 @@ lines:
topic: [routine]
tags: [pc-detective, greeting, public-tier]
- id: the-terminal_d_045
- id: pc-detective_d_005
text: "Standard review. Manifest reconciliation, scheduling compliance. Nothing unusual."
role: commission-liaison
access: [public]
@@ -36,7 +36,7 @@ lines:
topic: [routine]
tags: [pc-detective, purpose-question, public-tier]
- id: the-terminal_d_046
- id: pc-detective_d_006
text: "Only been here a few days. Seems like a well-run operation."
role: commission-liaison
access: [public]
@@ -45,7 +45,7 @@ lines:
topic: [routine]
tags: [pc-detective, district-question, public-tier]
- id: the-terminal_d_047
- id: pc-detective_d_007
text: "Ms. Venn? Colleague. We've worked in the same division."
role: commission-liaison
access: [public]
@@ -54,7 +54,7 @@ lines:
topic: [colleague]
tags: [pc-detective, sera-question, public-tier]
- id: the-terminal_d_048
- id: pc-detective_d_008
text: "I appreciate the interest, but I'm sure you have cargo to route. I won't keep you."
role: commission-liaison
access: [public, authority]
@@ -17,7 +17,7 @@ lines:
# Second playthrough: "That's me."
# ========================================
- id: the-terminal_d_039
- id: pc-smuggler_d_004
text: "Morning. Can I help you with something?"
role: dock-worker
access: [public]
@@ -27,7 +27,7 @@ lines:
topic: [routine]
tags: [pc-smuggler, greeting, public-tier]
- id: the-terminal_d_040
- id: pc-smuggler_d_005
text: "Cargo routing. Containers come in, I process them out. Standard stuff."
role: dock-worker
access: [public, authority]
@@ -36,7 +36,7 @@ lines:
topic: [routine, cargo]
tags: [pc-smuggler, work-question, public-tier]
- id: the-terminal_d_041
- id: pc-smuggler_d_006
text: "It's a freight hub. Busy during shifts, quiet after. Same as anywhere."
role: dock-worker
access: [public]
@@ -45,7 +45,7 @@ lines:
topic: [routine]
tags: [pc-smuggler, hub-question, public-tier]
- id: the-terminal_d_042
- id: pc-smuggler_d_007
text: "Good crew. We do our shifts, go home. Nothing exciting."
role: dock-worker
access: [public, authority]
@@ -54,7 +54,7 @@ lines:
topic: [colleague, routine]
tags: [pc-smuggler, colleagues-question, public-tier]
- id: the-terminal_d_043
- id: pc-smuggler_d_008
text: "Look, I just move containers. You'd want to talk to Voss about scheduling."
role: dock-worker
access: [authority]
@@ -13,7 +13,7 @@ lines:
# GREETINGS — scheduler's opening read
# ==========================================
- id: the-terminal_d_300
- id: scheduler_d_001
text: "Manifest's current. Bay assignments are posted."
role: scheduler
access: [public, peer]
@@ -23,7 +23,7 @@ lines:
mood: [content]
tags: [maret, procedural, greeting]
- id: the-terminal_d_301
- id: scheduler_d_002
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]
@@ -39,7 +39,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_302
- id: scheduler_d_003
text: "Three inbounds today. All verified against the manifest. No gaps so far."
role: scheduler
access: [public, peer]
@@ -53,7 +53,7 @@ lines:
# DISCREPANCY PATTERN — the core arc
# ==========================================
- id: the-terminal_d_303
- id: scheduler_d_004
text: "Container 4474 shows a forty-seven kilo discrepancy. I've flagged it twice."
role: scheduler
access: [public, peer]
@@ -69,7 +69,7 @@ lines:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_304
- id: scheduler_d_005
text: "It was logged as administrative. I don't know what that means for a weight discrepancy."
role: scheduler
access: [public, peer]
@@ -82,7 +82,7 @@ lines:
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
- id: scheduler_d_006
text: "The transition window anomalies cluster at shift change. I've run the numbers four times. It's not random."
role: scheduler
access: [peer]
@@ -98,7 +98,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_306
- id: scheduler_d_007
text: "Three separate manifest entries show weight variations under ten kilos. Within rounding, technically."
role: scheduler
access: [peer]
@@ -111,7 +111,7 @@ lines:
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
- id: scheduler_d_008
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]
@@ -131,7 +131,7 @@ lines:
# ROUTINE SCHEDULING — grounded work
# ==========================================
- id: the-terminal_d_308
- id: scheduler_d_009
text: "Bay eight's running on reduced capacity until 1700. Route inbounds through six."
role: scheduler
access: [public, peer]
@@ -141,7 +141,7 @@ lines:
mood: [content]
tags: [maret, operational]
- id: the-terminal_d_309
- id: scheduler_d_010
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]
@@ -154,7 +154,7 @@ lines:
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
- id: scheduler_d_011
text: "Overtime requests go through Meridian. I can't process them on paper anymore."
role: scheduler
access: [public, peer]
@@ -164,7 +164,7 @@ lines:
mood: [content]
tags: [maret, administrative]
- id: the-terminal_d_311
- id: scheduler_d_012
text: "Manifest update at 1300. If your containers aren't posted by then, they don't make the cycle."
role: scheduler
access: [public, peer]
@@ -178,7 +178,7 @@ lines:
# DETECTIVE ENCOUNTER — access and resistance
# ==========================================
- id: the-terminal_d_312
- id: scheduler_d_013
text: "Commission manifest access requires a formal request through Voss. I can tell you what's on the public record."
role: scheduler
access: [authority]
@@ -188,7 +188,7 @@ lines:
mood: [content]
tags: [maret, detective-path, procedural]
- id: the-terminal_d_313
- id: scheduler_d_014
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]
@@ -201,7 +201,7 @@ lines:
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
- id: scheduler_d_015
text: "I'm not in a position to speak to why certain reviews were closed the way they were."
role: scheduler
access: [authority]
@@ -218,7 +218,7 @@ lines:
# ELEVATED TRUST — Maret breaks professional distance
# ==========================================
- id: the-terminal_d_315
- id: scheduler_d_016
text: "The pattern I'm seeing doesn't make sense for equipment failure. It's too consistent."
role: scheduler
access: [peer, insider]
@@ -234,7 +234,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_316
- id: scheduler_d_017
text: "I filed a second formal notice two cycles ago. It's been under review since then. By Voss."
role: scheduler
access: [peer]
@@ -250,7 +250,7 @@ lines:
fact_id: behavioral.voss_awareness
confidence: knows_details
- id: the-terminal_d_317
- id: scheduler_d_018
text: "I don't think I should be the one telling you this."
role: scheduler
access: [insider]
@@ -265,7 +265,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-terminal_d_318
- id: scheduler_d_019
text: "Manifest posted for the evening shift. I'm off at 1500."
role: scheduler
access: [public, peer]
@@ -275,7 +275,7 @@ lines:
mood: [content]
tags: [maret, closing]
- id: the-terminal_d_319
- id: scheduler_d_020
text: "Numbers don't lie. People lie about numbers."
role: scheduler
access: [peer]
@@ -290,7 +290,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_320
- id: scheduler_d_021
text: "Bay assignments updated as of 0900. Check before you touch anything."
role: scheduler
access: [public, peer]
@@ -300,7 +300,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_321
- id: scheduler_d_022
text: "Inbound from the Havara corridor is delayed six hours. Adjust your bay queue."
role: scheduler
access: [public, peer]
@@ -310,7 +310,7 @@ lines:
mood: [content]
tags: [maret, operational, generation-pass]
- id: the-terminal_d_322
- id: scheduler_d_023
text: "Scan log for bay six is still open from last cycle. Close it before end of shift."
role: scheduler
access: [public, peer]
@@ -320,7 +320,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_323
- id: scheduler_d_024
text: "Overtime requests need scheduler approval now. Voss changed the process."
role: scheduler
access: [public, peer]
@@ -330,7 +330,7 @@ lines:
mood: [content]
tags: [maret, voss, administrative, generation-pass]
- id: the-terminal_d_324
- id: scheduler_d_025
text: "If a container's not logged by 1300, it doesn't make the cycle. That's the rule."
role: scheduler
access: [public, peer]
@@ -340,7 +340,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_325
- id: scheduler_d_026
text: "End-of-cycle reconciliation is at 1430. Be available."
role: scheduler
access: [public, peer]
@@ -350,7 +350,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_326
- id: scheduler_d_027
text: "Weight tolerance is plus or minus five kilos. Anything outside that gets a flag."
role: scheduler
access: [public, peer]
@@ -360,7 +360,7 @@ lines:
mood: [content]
tags: [maret, procedural, manifest, generation-pass]
- id: the-terminal_d_327
- id: scheduler_d_028
text: "Commission audit prep starts next cycle. Get your documentation current."
role: scheduler
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_100
- id: shift-supervisor_d_001
text: "You're late. Don't be late."
role: shift-supervisor
access: [public, peer, insider]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [voss, greeting, authority]
- id: the-terminal_d_101
- id: shift-supervisor_d_002
text: "Manifest's posted. Bay assignments are final — don't renegotiate them with me."
role: shift-supervisor
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_102
- id: shift-supervisor_d_003
text: "Bay 4's running a review hold. Nobody touches that freight until I say."
role: shift-supervisor
access: [public, peer]
@@ -45,7 +45,7 @@ lines:
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
- id: shift-supervisor_d_004
text: "Commission liaison's in the district this cycle. Keep the floor clean."
role: shift-supervisor
access: [public, peer]
@@ -62,7 +62,7 @@ lines:
# SHIFT AUTHORITY — routine management
# ==========================================
- id: the-terminal_d_104
- id: shift-supervisor_d_005
text: "Drin was supposed to run the bay six inspection. Tell him I'm asking."
role: shift-supervisor
access: [public, peer]
@@ -72,7 +72,7 @@ lines:
mood: [content]
tags: [voss, drin, authority]
- id: the-terminal_d_105
- id: shift-supervisor_d_006
text: "Transition window's twenty minutes. Not twenty-five, not thirty. Twenty."
role: shift-supervisor
access: [public, peer, insider]
@@ -82,7 +82,7 @@ lines:
mood: [content]
tags: [voss, procedural, transition-window]
- id: the-terminal_d_106
- id: shift-supervisor_d_007
text: "Overtime requests go through the Meridian form. Don't bring them to me."
role: shift-supervisor
access: [public, peer]
@@ -92,7 +92,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_107
- id: shift-supervisor_d_008
text: "Bay eight's going to downtime at 1300. Plan around it."
role: shift-supervisor
access: [public, peer]
@@ -102,7 +102,7 @@ lines:
mood: [content]
tags: [voss, operational]
- id: the-terminal_d_108
- id: shift-supervisor_d_009
text: "Maret flagged a discrepancy. I've reviewed it. It's logged. Move on."
role: shift-supervisor
access: [public, peer]
@@ -118,7 +118,7 @@ lines:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_109
- id: shift-supervisor_d_010
text: "Freight's moving. That's what matters. Questions slow things down."
role: shift-supervisor
access: [public, peer]
@@ -132,7 +132,7 @@ lines:
# SCHEDULING COVER — insider, real trust
# ==========================================
- id: the-terminal_d_110
- id: shift-supervisor_d_011
text: "Bay four is clear for transition. I've adjusted the inspection schedule."
role: shift-supervisor
access: [insider]
@@ -145,7 +145,7 @@ lines:
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
- id: shift-supervisor_d_012
text: "Drin's on bay six. Bay four's mine tonight."
role: shift-supervisor
access: [insider]
@@ -155,7 +155,7 @@ lines:
mood: [focused]
tags: [voss, ring-ops, scheduling-cover, drin]
- id: the-terminal_d_112
- id: shift-supervisor_d_013
text: "The hold note is administrative. Don't treat it as anything else."
role: shift-supervisor
access: [insider]
@@ -170,7 +170,7 @@ lines:
# DETECTIVE ENCOUNTER — authority tier
# ==========================================
- id: the-terminal_d_113
- id: shift-supervisor_d_014
text: "You'll need to submit a formal access request through Commission channels for any restricted data."
role: shift-supervisor
access: [authority]
@@ -180,7 +180,7 @@ lines:
mood: [content]
tags: [voss, detective-path, institutional-resistance]
- id: the-terminal_d_114
- id: shift-supervisor_d_015
text: "The manifest's available to any Commission officer. Ask the scheduler."
role: shift-supervisor
access: [authority]
@@ -190,7 +190,7 @@ lines:
mood: [content]
tags: [voss, detective-path, redirect]
- id: the-terminal_d_115
- id: shift-supervisor_d_016
text: "My workers perform their duties. If there's an irregularity, it'll show in the log."
role: shift-supervisor
access: [authority]
@@ -200,7 +200,7 @@ lines:
mood: [content]
tags: [voss, detective-path, deflection]
- id: the-terminal_d_116
- id: shift-supervisor_d_017
text: "I've been running this floor for six years. The numbers work out."
role: shift-supervisor
access: [authority, public]
@@ -214,7 +214,7 @@ lines:
# PRESSURE / CONFRONTATION
# ==========================================
- id: the-terminal_d_117
- id: shift-supervisor_d_018
text: "Close the door."
role: shift-supervisor
access: [insider, peer]
@@ -224,7 +224,7 @@ lines:
mood: [frustrated]
tags: [voss, confrontation, private]
- id: the-terminal_d_118
- id: shift-supervisor_d_019
text: "Maret keeps flagging the discrepancies. That needs to stop being her problem."
role: shift-supervisor
access: [insider]
@@ -237,7 +237,7 @@ lines:
fact_id: behavioral.voss_awareness
confidence: knows_of
- id: the-terminal_d_119
- id: shift-supervisor_d_020
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]
@@ -251,7 +251,7 @@ lines:
# SIGN-OFF / END OF SHIFT
# ==========================================
- id: the-terminal_d_120
- id: shift-supervisor_d_021
text: "Sign-off is at 1400. Be done."
role: shift-supervisor
access: [public, peer]
@@ -261,7 +261,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_121
- id: shift-supervisor_d_022
text: "Night shift takes over at 2200. I don't want to hear about the handoff tomorrow."
role: shift-supervisor
access: [public, peer]
@@ -271,7 +271,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_122
- id: shift-supervisor_d_023
text: "Good shift. Tomorrow at 0600."
role: shift-supervisor
access: [public, peer, insider]
@@ -285,7 +285,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_123
- id: shift-supervisor_d_024
text: "Assignments don't change once they're posted. Read the board."
role: shift-supervisor
access: [public, peer]
@@ -295,7 +295,7 @@ lines:
mood: [content]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_124
- id: shift-supervisor_d_025
text: "If there's a discrepancy, it goes to the scheduler. Not to me directly."
role: shift-supervisor
access: [public, peer]
@@ -305,7 +305,7 @@ lines:
mood: [content]
tags: [voss, procedural, maret, generation-pass]
- id: the-terminal_d_125
- id: shift-supervisor_d_026
text: "Span gate's cycling at 1100. Clear the bay six access before then."
role: shift-supervisor
access: [public, peer]
@@ -315,7 +315,7 @@ lines:
mood: [content]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_126
- id: shift-supervisor_d_027
text: "I don't repeat myself on safety calls. If you missed it, ask someone who was listening."
role: shift-supervisor
access: [public, peer]
@@ -325,7 +325,7 @@ lines:
mood: [content]
tags: [voss, authority, generation-pass]
- id: the-terminal_d_127
- id: shift-supervisor_d_028
text: "End-of-day log goes to Meridian before you leave. No exceptions."
role: shift-supervisor
access: [public, peer]
@@ -335,7 +335,7 @@ lines:
mood: [content]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_128
- id: shift-supervisor_d_029
text: "Inbounds from Havara are on the revised schedule. Check the update."
role: shift-supervisor
access: [public, peer]
@@ -345,7 +345,7 @@ lines:
mood: [content]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_129
- id: shift-supervisor_d_030
text: "Clean floor, clean manifest. That's the job."
role: shift-supervisor
access: [public, peer, insider]
@@ -47,8 +47,8 @@ items:
notes: >
Physical proof of cargo routing irregularities. The manifest shows
three containers rerouted during shift transition with weights that
don't reconcile. Cross-ref: Kael dialogue the-terminal_d_008
("Manifest says this one's forty kilos over") and the-terminal_d_018
don't reconcile. Cross-ref: Kael dialogue kael-davan_d_041
("Manifest says this one's forty kilos over") and kael-davan_d_051
("Manifest's clean on my end").
- fact_id: "investigation.cargo_anomaly"
relationship: "corroborates"
@@ -24,7 +24,7 @@ lines:
# --- Arrival ---
- id: commission-kiosk_m_d_001
- id: pc-detective_m_d_001
text: "Commission terminal. Authorized access, full import logs. Let's see what the system knows."
role: player_character
access: [public]
@@ -33,7 +33,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, tutorial, orientation]
- id: commission-kiosk_m_d_002
- id: pc-detective_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]
@@ -42,7 +42,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, atmospheric]
- id: commission-kiosk_m_d_003
- id: pc-detective_m_d_003
text: "Filtered air. A Commission smell — sterile, precise, faintly antiseptic. Familiar."
role: player_character
access: [public]
@@ -51,7 +51,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, atmospheric, sensory]
- id: commission-kiosk_m_d_004
- id: pc-detective_m_d_004
text: "Back at the kiosk. The terminal logged my last access thirty-two minutes ago."
role: player_character
access: [public]
@@ -60,7 +60,7 @@ lines:
situation: [arrival]
tags: [arrival, orientation]
- id: commission-kiosk_m_d_005
- id: pc-detective_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]
@@ -71,7 +71,7 @@ lines:
# --- Perception / Sound ---
- id: commission-kiosk_m_d_006
- id: pc-detective_m_d_006
text: "The terminal hum is different from the freight equipment. Cleaner. Higher frequency."
role: player_character
access: [public]
@@ -80,7 +80,7 @@ lines:
situation: [routine]
tags: [sensory, environmental]
- id: commission-kiosk_m_d_007
- id: pc-detective_m_d_007
text: "Footsteps at the transit corridor entrance. Not Commission — wrong pace."
role: player_character
access: [public]
@@ -89,7 +89,7 @@ lines:
situation: [routine, observation]
tags: [sensory, caution]
- id: commission-kiosk_m_d_008
- id: pc-detective_m_d_008
text: "Someone's running queries at the main manifest board. Loud keystrokes — frustrated."
role: player_character
access: [public]
@@ -100,7 +100,7 @@ lines:
# --- Tutorial / Evidence ---
- id: commission-kiosk_m_d_009
- id: pc-detective_m_d_009
text: "Import logs go back forty days. Weight discrepancies flagged automatically — unless someone cleared the flag."
role: player_character
access: [public]
@@ -109,7 +109,7 @@ lines:
situation: [routine, investigation]
tags: [tutorial, investigation, orientation]
- id: commission-kiosk_m_d_010
- id: pc-detective_m_d_010
text: "Personnel movement log. Every authorized access to every restricted space, timestamped."
role: player_character
access: [public]
@@ -120,7 +120,7 @@ lines:
# --- Time Idle / Ruminative ---
- id: commission-kiosk_m_d_011
- id: pc-detective_m_d_011
text: "Pattern is forming. Three manifests, same routing anomaly, different filing dates. That's deliberate."
role: player_character
access: [public]
@@ -129,7 +129,7 @@ lines:
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_012
- id: pc-detective_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]
@@ -138,7 +138,7 @@ lines:
situation: [routine]
tags: [atmospheric, investigation]
- id: commission-kiosk_m_d_013
- id: pc-detective_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]
@@ -147,7 +147,7 @@ lines:
situation: [routine]
tags: [atmospheric, analytical]
- id: commission-kiosk_m_d_014
- id: pc-detective_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]
@@ -156,7 +156,7 @@ lines:
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_015
- id: pc-detective_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]
@@ -167,7 +167,7 @@ lines:
# --- Anomaly ---
- id: commission-kiosk_m_d_016
- id: pc-detective_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]
@@ -177,7 +177,7 @@ lines:
priority: 7
tags: [investigation, caution, analytical]
- id: commission-kiosk_m_d_017
- id: pc-detective_m_d_017
text: "Container 4471 has three separate manifest entries with different timestamps. One of them is false."
role: player_character
access: [public]
@@ -191,7 +191,7 @@ lines:
priority: 8
tags: [investigation, contraband, analytical]
- id: commission-kiosk_m_d_018
- id: pc-detective_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]
@@ -202,7 +202,7 @@ lines:
# --- Knowledge-Gated: Sera Arc ---
- id: commission-kiosk_m_d_019
- id: pc-detective_m_d_019
text: "Venn, S. — last calibration event logged here: 0615 this morning. Standard. Her schedule is precise."
role: player_character
access: [public]
@@ -216,7 +216,7 @@ lines:
priority: 5
tags: [npc, sera, routine, friend-arc]
- id: commission-kiosk_m_d_020
- id: pc-detective_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]
@@ -230,7 +230,7 @@ lines:
priority: 6
tags: [npc, sera, investigation, friend-arc, dual-lens]
- id: commission-kiosk_m_d_021
- id: pc-detective_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]
@@ -244,7 +244,7 @@ lines:
priority: 7
tags: [npc, sera, tell, investigation, friend-arc]
- id: commission-kiosk_m_d_022
- id: pc-detective_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]
@@ -260,7 +260,7 @@ lines:
priority: 9
tags: [npc, sera, investigation, contraband, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_023
- id: pc-detective_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]
@@ -278,7 +278,7 @@ lines:
# --- Post-Conversation ---
- id: commission-kiosk_m_d_024
- id: pc-detective_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]
@@ -292,7 +292,7 @@ lines:
priority: 9
tags: [npc, sera, post-conversation, tell, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_025
- id: pc-detective_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]
@@ -0,0 +1,236 @@
character: detective
location: contradiction
lines:
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
#
# Trigger: contradiction_detected fires when the detective observes Kael in Corridor B-7
# while the KG holds Sera's claim that Kael was at dock intake during second shift.
# Event payload: source_display_name = "Sera Venn", subject_display_name = "Kael Davan"
#
# FRIEND-pattern NPCs (Sera, Kael) always use hand-authored lines — generic template fallback
# does not fire for this arc.
#
# Authoring principle: cognitive dissonance, not accusation. The detective doesn't know
# who or what is wrong. Uncertainty first, suspicion second, accusation never.
#
# Phase 2 lines: trust established but not deep — the blindsiding.
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Sera Venn)
# Phase 3 lines: additional context accumulated — Sera's avoidance of Torek observed 3x.
# Prerequisite: Phase 2 + Sera's avoidance pattern has fired (three departures logged).
#
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
# fires once per KG state change), not by a cooldown value.
#
# Note: contradiction_detected is a new trigger type — requires server-side implementation
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
# -----------------------------------------------------------------------
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_001
text: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
notes: >
Paula's canonical primary line — preserved verbatim. Flat factual statement: source named,
subject named, specific location claim vs. direct observation. The 'I'm' contraction is
natural in a surprise moment even for the analytical detective — the observation breaks
through the professional register. No interpretation, no accusation.
- id: pc-detective_m_d_con_002
text: "Venn said Kael was at dock intake through second shift. Kael is in B-7. Those aren't compatible."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
notes: >
Alternate primary — fully analytical register: surname-initial for both (institutional
reflex), no contractions, plain declarative close. 'Those aren't compatible' is the
detective's version of stating contradiction — clinical, process-language. Fires as
alternate to con_001 for variety on replay.
- id: pc-detective_m_d_con_003
text: "She placed Kael at the dock. He's not at the dock."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2]
notes: >
Stripped-down alternate. 'She placed' is the informal of 'Venn stated' — personal register
triggered by Sera being THE FRIEND. 'He's not at the dock' — the barest possible statement
of contradiction. The brevity is the weight: six words and the investigation changes shape.
# -----------------------------------------------------------------------
# PHASE 2 — Secondary beat (fires after short delay, cognitive processing)
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_004
text: "One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [anxious, focused]
priority: 7
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, cognitive-dissonance, secondary-beat]
notes: >
Paula's canonical secondary beat — fires 3-5 seconds after the primary beat.
Epistemic neutrality held across three options: Sera is wrong, observation is wrong, or
context is missing. No option is dismissed. 'I'm missing something I don't have yet' is
the key line — it holds accusation off even against evidence. The detective files it open.
Lower priority (7) ensures primary lines fire first; server team should implement
delay_after_trigger_ticks for proper sequencing.
- id: pc-detective_m_d_con_005
text: "File it. Don't close it. Either Venn's information was wrong when she gave it, or it was wrong on purpose. That's a different question."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 6
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical, cognitive-dissonance, secondary-beat]
notes: >
Alternate secondary beat. 'File it, don't close it' is the detective's internal procedure
for unresolved data. The critical distinction: wrong information vs. deliberately wrong
information — the detective separates these without assuming either. 'That's a different
question' closes the processing loop, marking it unresolved, not dismissed.
# -----------------------------------------------------------------------
# PHASE 3 — With accumulated context (Sera's avoidance pattern observed)
# Requires: three Sera departures correlated with Torek Lintar arrivals observed.
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_006
text: "Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, pattern-recognition]
notes: >
Paula's canonical Phase 3 line — preserved verbatim. Three facts in sequence, no explicit
connection drawn between them: Sera's testimony about Kael ('doesn't make mistakes'),
Kael's actual location, Sera's avoidance behavior. The detective is connecting dots but
not announcing the conclusion. The emotional weight is in 'Sera told me' — personal register
for the friend relationship — vs. the clinical observation about Lintar.
- id: pc-detective_m_d_con_007
text: "Two facts: Venn placed Kael at dock intake, second shift. Kael is in B-7. One new observation: Venn leaves every time Lintar enters the bar. Worth seeing if those connect."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical, pattern-recognition]
notes: >
Alternate Phase 3 — analytical register throughout, no personal register even for Sera
(surname only). 'Worth seeing if those connect' is the detective's verbal tic of flagging
without concluding. The numbered format ('Two facts... One new observation') is the
detective's internal case-building style. Fully non-accusatory: the connection is not
stated, only the intent to look for it.
- id: pc-detective_m_d_con_008
text: "Venn's been managing her exits around Lintar. And she put Kael somewhere he isn't. Let's see what sits at the intersection of those two."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 8
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical]
notes: >
Alternate Phase 3. 'Let's see what' verbal tic, no accusation. 'Managing her exits' is
the detective naming Sera's avoidance behavior — behavioral description, not motive
attribution. 'Put Kael somewhere he isn't' — plain contradiction framing. The intersection
remains unstated. Detective is following the thread, not announcing what's on it.
@@ -1,7 +1,7 @@
character: detective
location: general
lines:
- id: general_m_d_001
- id: pc-detective_m_d_001
text: "Sova Transit District. Population twelve thousand and change. Let's narrow that down."
role: player_character
access: [public]
@@ -10,7 +10,7 @@ lines:
trigger: enter_location
tags: [arrival, analytical]
- id: general_m_d_002
- id: pc-detective_m_d_002
text: "The span gate resonance is constant here. Background radiation of a freight hub."
role: player_character
access: [public]
@@ -19,7 +19,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: general_m_d_003
- id: pc-detective_m_d_003
text: "First week on station. The case file is thin. The manifest discrepancies are not."
role: player_character
access: [public]
@@ -28,7 +28,7 @@ lines:
trigger: time_idle
tags: [investigation, orientation]
- id: general_m_d_004
- id: pc-detective_m_d_004
text: "Everyone knows I'm Commission. The conversations stop when I walk in. Noted."
role: player_character
access: [public]
@@ -37,7 +37,7 @@ lines:
trigger: time_idle
tags: [investigation, social]
- id: general_m_d_005
- id: pc-detective_m_d_005
text: "Station gravity's point-nine-three standard. Enough to notice after a long shift."
role: player_character
access: [public]
@@ -46,7 +46,7 @@ lines:
trigger: time_idle
tags: [environmental, personal]
- id: general_m_d_006
- id: pc-detective_m_d_006
text: "The previous liaison rotated out three months ago. That gap matters."
role: player_character
access: [public]
@@ -55,7 +55,7 @@ lines:
trigger: time_idle
tags: [investigation, analytical]
- id: general_m_d_007
- id: pc-detective_m_d_007
text: "Back in the district. Let's see what changed while I wasn't watching."
role: player_character
access: [public]
@@ -64,7 +64,7 @@ lines:
trigger: return_visit
tags: [arrival, analytical]
- id: general_m_d_008
- id: pc-detective_m_d_008
text: "Recycled air, industrial residue, and that constant gate hum. Sova in three notes."
role: player_character
access: [public]
@@ -73,7 +73,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: general_m_d_009
- id: pc-detective_m_d_009
text: "Communities like this close ranks around outsiders. Patient work."
role: player_character
access: [public]
@@ -82,7 +82,7 @@ lines:
trigger: time_idle
tags: [reflection, social]
- id: general_m_d_010
- id: pc-detective_m_d_010
text: "The manifest data goes back eighteen months. The pattern starts around month six."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: maintenance-corridors
lines:
# --- Arrival + Environmental Flavor ---
- id: maintenance-corridors_m_d_001
- id: pc-detective_m_d_001
text: "Maintenance level. Low lighting, exposed conduit, restricted access nobody enforces."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, environmental]
- id: maintenance-corridors_m_d_002
- id: pc-detective_m_d_002
text: "The air's different down here. Cooler. Damper. Less filtered."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_d_003
- id: pc-detective_m_d_003
text: "Service corridor B-section. Not on the public transit map. Interesting routing options."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, investigation]
- id: maintenance-corridors_m_d_004
- id: pc-detective_m_d_004
text: "Water recycling conduits. The station's circulatory system, hidden from view."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: maintenance-corridors_m_d_005
- id: pc-detective_m_d_005
text: "Pipe network branching three directions. Good for maintenance. Better for privacy."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: enter_location
tags: [environmental, analytical]
- id: maintenance-corridors_m_d_006
- id: pc-detective_m_d_006
text: "Emergency lighting only. Visibility reduced, but so is surveillance coverage."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: enter_location
tags: [environmental, investigation]
- id: maintenance-corridors_m_d_007
- id: pc-detective_m_d_007
text: "The rust smell is stronger near junction B-7. Older section. Less maintenance."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_d_008
- id: pc-detective_m_d_008
text: "Back in the corridors. Different route this time — mapping the alternatives."
role: player_character
access: [public]
@@ -77,7 +77,7 @@ lines:
# --- Investigation Observations ---
- id: maintenance-corridors_m_d_009
- id: pc-detective_m_d_009
text: "Scuff marks on the floor. Recent, heavy. Cargo weight, not foot traffic."
role: player_character
access: [public]
@@ -86,7 +86,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_010
- id: pc-detective_m_d_010
text: "The service hatch has been used recently. Oil on the handle, dust disturbed."
role: player_character
access: [public]
@@ -95,7 +95,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_011
- id: pc-detective_m_d_011
text: "Restricted access, but the lock plate shows wear. Regular use by someone with clearance."
role: player_character
access: [public]
@@ -104,7 +104,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_012
- id: pc-detective_m_d_012
text: "Sound carries differently down here. Voices from two junctions away, if you're patient."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
trigger: hear_sound
tags: [environmental, investigation]
- id: maintenance-corridors_m_d_013
- id: pc-detective_m_d_013
text: "Chalk marks on the wall. Low, deliberate. Not maintenance notation — wrong color."
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_014
- id: pc-detective_m_d_014
text: "This corridor connects bay four to temp storage. Bypasses the main freight floor entirely."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: enter_location
tags: [investigation, operational]
- id: maintenance-corridors_m_d_015
- id: pc-detective_m_d_015
text: "Footsteps ahead. Single set, moving fast. Someone who knows where they're going."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: hear_sound
tags: [investigation, caution]
- id: maintenance-corridors_m_d_016
- id: pc-detective_m_d_016
text: "The temp storage access from this side isn't logged on the manifest system. Blind spot."
role: player_character
access: [public]
@@ -151,7 +151,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: maintenance-corridors_m_d_017
- id: pc-detective_m_d_017
text: "The cargo scuff pattern matches bay four containers. Dock to corridor to storage — the route."
role: player_character
access: [public]
@@ -165,7 +165,7 @@ lines:
priority: 8
tags: [investigation, evidence, breakthrough]
- id: maintenance-corridors_m_d_018
- id: pc-detective_m_d_018
text: "Those chalk marks match the courier's timing. Renn's route markers, if I had to guess."
role: player_character
access: [public]
@@ -179,7 +179,7 @@ lines:
priority: 7
tags: [npc, renn, investigation, evidence]
- id: maintenance-corridors_m_d_019
- id: pc-detective_m_d_019
text: "Camera blind spot at junction C-2. Coincidence, or someone mapped the coverage?"
role: player_character
access: [public]
@@ -192,7 +192,7 @@ lines:
min_confidence: suspects
tags: [investigation, operational]
- id: maintenance-corridors_m_d_020
- id: pc-detective_m_d_020
text: "Standard container, no manifest checkpoint along this route. That's the gap."
role: player_character
access: [public]
@@ -206,7 +206,7 @@ lines:
priority: 9
tags: [investigation, breakthrough]
- id: maintenance-corridors_m_d_021
- id: pc-detective_m_d_021
text: "Hatch lubricant is fresh. Someone's maintaining this access point. Not station maintenance."
role: player_character
access: [public]
@@ -219,7 +219,7 @@ lines:
min_confidence: knows_of
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_022
- id: pc-detective_m_d_022
text: "Davan was seen near B-7 during off-shift. Cross-reference with container movements."
role: player_character
access: [public]
@@ -233,7 +233,7 @@ lines:
priority: 7
tags: [npc, kael, investigation]
- id: maintenance-corridors_m_d_023
- id: pc-detective_m_d_023
text: "Two access routes, one manifest checkpoint. Someone designed this gap. Or discovered it."
role: player_character
access: [public]
@@ -246,7 +246,7 @@ lines:
min_confidence: knows_details
tags: [investigation, analytical]
- id: maintenance-corridors_m_d_024
- id: pc-detective_m_d_024
text: "Rosta's gambling debt gives me leverage. But using it makes me the authority they resent."
role: player_character
access: [public]
@@ -260,7 +260,7 @@ lines:
priority: 6
tags: [npc, drin, investigation, moral]
- id: maintenance-corridors_m_d_025
- id: pc-detective_m_d_025
text: "Evidence is accumulating. Names, routes, timing. But I still can't see the full picture."
role: player_character
access: [public]
@@ -10,7 +10,7 @@ lines:
# Contraction discipline: analytical lines = uncontracted; personal lines (Sera) = contracted.
# All lines fully schema-compliant per D-035 (role, access, trust, situation present).
- id: opening_m_d_001
- id: pc-detective_m_d_001
text: "Sova Transit. Mid-morning shift, dock lighting at half-cycle. Consistent with the Commission pre-arrival briefing."
role: player_character
access: [public]
@@ -23,7 +23,7 @@ lines:
tags: [opening-hook, tutorial-arc, analytical, orientation]
notes: "Opening line. Reality matches the briefing — he arrived prepared. Establishes: detective as institutional outsider sent here. Cool-lit corridors per D-046 expressed through 'half-cycle' lighting observation. No contractions (analytical)."
- id: opening_m_d_002
- id: pc-detective_m_d_002
text: "Cargo lubricant, recycled atmosphere. Same mixture on every freight hub from here to the span gate hum. First observation: consistent with Commission briefing profile."
role: player_character
access: [public]
@@ -36,7 +36,7 @@ lines:
tags: [opening-hook, tutorial-arc, atmospheric, analytical]
notes: "Environmental sensory in analytical register. Colon usage (detective verbal pattern, section 2.2). No contractions. 'span gate' is canonical terminology (D-036); 'hum' is sensory detail. Contrast with smuggler's 'Home sweet home' — same smell, opposite emotional valence. He's working, not home."
- id: opening_m_d_003
- id: pc-detective_m_d_003
text: "Lattice overlay initializing. Four identity flags in immediate range — cross-referencing against Commission case file."
role: player_character
access: [public]
@@ -49,7 +49,7 @@ lines:
tags: [opening-hook, tutorial-arc, lattice, perception]
notes: "Teaches insert channel. The analytical lattice is active on entry — it flags and cross-references automatically. Teaches: detective's insert HUD is denser and diegetically active (D-048). Players learn the lattice is a tool, not decoration."
- id: opening_m_d_004
- id: pc-detective_m_d_004
text: "Commission kiosk confirmed my credentials on entry. Authority access logged. The floor knows someone's here."
role: player_character
access: [public]
@@ -62,7 +62,7 @@ lines:
tags: [opening-hook, tutorial-arc, institutional, authority]
notes: "Commission kiosk visible, institutional standing established. Sardonic close — dry awareness of his effect on the social environment. Confidence register: wry humor (section 2.8). Teaches: Authority access tier, institutional role is visible to NPCs."
- id: opening_m_d_005
- id: pc-detective_m_d_005
text: "Visual range terminates at bay nine. Approximately fifteen meters. Consistent with district atmo settings."
role: player_character
access: [public]
@@ -74,7 +74,7 @@ lines:
tags: [opening-hook, tutorial-arc, fog, perception]
notes: "Fog boundary in analytical register. He clocks the measurement, notes 'approximately' — precision with honest calibration. Contrast with smuggler's 'Can't see the far dock' — same fog, different framing: limitation vs. measurement."
- id: opening_m_d_006
- id: pc-detective_m_d_006
text: "Dock workers distributed across nine visible bays. Lattice flagging each on contact. Names from the case file, faces from the floor."
role: player_character
access: [public]
@@ -87,7 +87,7 @@ lines:
tags: [opening-hook, tutorial-arc, lattice, orientation]
notes: "Spatial overview with lattice overlay tutorial. Explains how the insert channel works: lattice matches observed faces against the pre-loaded case file. Parallel structure ('Names from... faces from...'). Teaches: NPC identification comes through the lattice."
- id: opening_m_d_007
- id: pc-detective_m_d_007
text: "Venn, S. at the far terminal. She said she'd come in early."
role: player_character
access: [public]
@@ -100,7 +100,7 @@ lines:
tags: [opening-hook, tutorial-arc, sera, friend-arc]
notes: "First Sera sighting. Surname-initial filing first (institutional reflex) — then breaks to personal contracted voice ('she'd') in the same beat. Teaches contrast: case-file subjects get 'Davan, K.'; Sera gets a different register. D-033 Unknown/Neutral teal entity."
- id: opening_m_d_008
- id: pc-detective_m_d_008
text: "She's got the manifest logs open already. Field tech who knows how to pull records — she knows what I need before I ask."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
tags: [opening-hook, tutorial-arc, sera, friend-arc]
notes: "Sera warmth. Fully contracted throughout ('She's', 'she knows') — personal mode, no analytical distance. Em-dash. Professional respect established without implying prior district visits. Contrast with how he processes everyone else. Teaches: some NPCs are in a different relational category (THE FRIEND pattern, D-034)."
- id: opening_m_d_009
- id: pc-detective_m_d_009
text: "Central manifest board: real-time container routing, weight declarations, bay assignments. Everything routes through here."
role: player_character
access: [public]
@@ -126,7 +126,7 @@ lines:
tags: [opening-hook, tutorial-arc, manifest, investigation]
notes: "Manifest board labeled and cataloged. Colon usage (section 2.2). No contractions (analytical). Teaches: the manifest board is the information hub — and therefore the investigation's starting point."
- id: opening_m_d_010
- id: pc-detective_m_d_010
text: "Container 4471. Temp storage flag since yesterday. Standard turnaround is sub-eight hours — that's not standard."
role: player_character
access: [public]
@@ -139,7 +139,7 @@ lines:
tags: [opening-hook, tutorial-arc, manifest, investigation, anomaly]
notes: "First manifest anomaly. Specific container ID from case file. 'That's not standard' — only contraction in an analytical line, marking it as personal reaction to the finding. Sets the investigation hook: something is wrong before he asks a single question."
- id: opening_m_d_011
- id: pc-detective_m_d_011
text: "Worth flagging. Let's see what the shift logs say about that container's overnight routing."
role: player_character
access: [public]
@@ -152,7 +152,7 @@ lines:
tags: [opening-hook, tutorial-arc, investigation, next-step]
notes: "'Worth flagging' + 'Let's see what' — both detective verbal tics in sequence (section 2.7). Frames the next investigative step. Teaches: investigation is incremental — observe, flag, investigate deeper."
- id: opening_m_d_012
- id: pc-detective_m_d_012
text: "Lattice stable. Nine identity flags and counting. Let's see what this shift tells me."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
# --- Tell Observations (5 core tells from pc-as-npc-spec.md section 8) ---
- id: general_m_d_011
- id: pc-detective_m_d_001
text: "That dock worker checks the corridor before turning. Habitual. Military? Or just careful."
role: player_character
access: [public]
@@ -25,7 +25,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, corridor-check]
- id: general_m_d_012
- id: pc-detective_m_d_002
text: "Movement pattern changed at shift change. More direct. Less idle. Interesting."
role: player_character
access: [public]
@@ -38,7 +38,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, shift-transition]
- id: general_m_d_013
- id: pc-detective_m_d_003
text: "Slight acceleration past the checkpoint. Most people do it. But this one does it every time."
role: player_character
access: [public]
@@ -51,7 +51,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, security-proximity]
- id: general_m_d_014
- id: pc-detective_m_d_004
text: "Same three people at the same table. Every shift. Consistent social group."
role: player_character
access: [public]
@@ -64,7 +64,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, social-clustering]
- id: general_m_d_015
- id: pc-detective_m_d_005
text: "That worker checked container 4471 twice. The same container that's in my manifest discrepancy file."
role: player_character
access: [public]
@@ -83,7 +83,7 @@ lines:
# --- Accumulated Observation (gates on multiple tell sightings) ---
- id: general_m_d_016
- id: pc-detective_m_d_006
text: "Three behavioral flags on one dock worker. Corridor awareness, shift-transition alertness, cargo fixation. That's a pattern."
role: player_character
access: [public]
@@ -98,7 +98,7 @@ lines:
priority: 7
tags: [npc, pc-smuggler, investigation, analytical, pattern-recognition]
- id: general_m_d_017
- id: pc-detective_m_d_007
text: "Cross-referencing the dock worker's shift schedule with manifest anomalies. Correlation: moderate."
role: player_character
access: [public]
@@ -115,7 +115,7 @@ lines:
priority: 8
tags: [npc, pc-smuggler, investigation, evidence]
- id: general_m_d_018
- id: pc-detective_m_d_008
text: "The dock worker and Davan eat together every shift. Same courier joins them. Tight operational cluster."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: the-last-shift
lines:
# --- Arrival + Environmental Flavor ---
- id: the-last-shift_m_d_001
- id: pc-detective_m_d_001
text: "The Last Shift. Only place in this district that doesn't smell like freight lubricant."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: the-last-shift_m_d_002
- id: pc-detective_m_d_002
text: "Grain spirit and low conversation. The social nexus of Sova Transit."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [arrival, analytical]
- id: the-last-shift_m_d_003
- id: pc-detective_m_d_003
text: "Bar's busy tonight. Good — crowds talk. Bad — they notice outsiders."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, investigation]
- id: the-last-shift_m_d_004
- id: pc-detective_m_d_004
text: "Standard district bar. Prefab fixtures, aftermarket lighting, a menu board nobody reads."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: enter_location
tags: [arrival, environmental]
- id: the-last-shift_m_d_005
- id: pc-detective_m_d_005
text: "The Meridian feed on the wall display. Local news, freight indices, Assembly coverage."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: enter_location
tags: [environmental]
- id: the-last-shift_m_d_006
- id: pc-detective_m_d_006
text: "The overhead vent rattles every thirty seconds. Like clockwork. Nobody else notices."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: the-last-shift_m_d_007
- id: pc-detective_m_d_007
text: "The conversation volume dropped when I walked in. Recovering now. They're adjusting."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [social, investigation]
- id: the-last-shift_m_d_008
- id: pc-detective_m_d_008
text: "Back at the bar. Same booth. Let's see who's here and who isn't."
role: player_character
access: [public]
@@ -75,7 +75,7 @@ lines:
trigger: return_visit
tags: [arrival, analytical]
- id: the-last-shift_m_d_009
- id: pc-detective_m_d_009
text: "Kitchen's running. Fried protein and something spiced. The bar's other currency: hot food."
role: player_character
access: [public]
@@ -84,7 +84,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: the-last-shift_m_d_010
- id: pc-detective_m_d_010
text: "Someone carved initials into this table. Old. A history I don't have."
role: player_character
access: [public]
@@ -95,7 +95,7 @@ lines:
# --- NPC Commentary ---
- id: the-last-shift_m_d_011
- id: pc-detective_m_d_011
text: "The bartender — Sessik, L. Knows everyone. Watches everything. Gives away nothing."
role: player_character
access: [public]
@@ -104,7 +104,7 @@ lines:
trigger: observe_npc
tags: [npc, lera, analytical]
- id: the-last-shift_m_d_012
- id: pc-detective_m_d_012
text: "Good to see Sera. At least someone in this district speaks my language."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
trigger: observe_npc
tags: [npc, sera, warm]
- id: the-last-shift_m_d_013
- id: pc-detective_m_d_013
text: "Lintar, T. — the loud one at the bar. Spends freely. Where's the money from?"
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_npc
tags: [npc, torek, investigation]
- id: the-last-shift_m_d_014
- id: pc-detective_m_d_014
text: "A woman at the corner table — Tamm, N., according to Sera. Davan's partner."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_npc
tags: [npc, naia, analytical]
- id: the-last-shift_m_d_015
- id: pc-detective_m_d_015
text: "The regulars have assigned seating. Unofficial, but enforced. I don't have a spot yet."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: observe_npc
tags: [social, atmospheric]
- id: the-last-shift_m_d_016
- id: pc-detective_m_d_016
text: "Sessik greeted three people by name since I sat down. I got a nod. Progress."
role: player_character
access: [public]
@@ -149,7 +149,7 @@ lines:
trigger: witness_interaction
tags: [npc, lera, social]
- id: the-last-shift_m_d_017
- id: pc-detective_m_d_017
text: "Two dock workers in the corner. Heads close, voices low. Not unusual for after-shift."
role: player_character
access: [public]
@@ -158,7 +158,7 @@ lines:
trigger: witness_interaction
tags: [social, investigation]
- id: the-last-shift_m_d_018
- id: pc-detective_m_d_018
text: "Sera introduced me to Tamm. Brief, polite, guarded. Expected."
role: player_character
access: [public]
@@ -167,7 +167,7 @@ lines:
trigger: post_conversation
tags: [npc, sera, naia, social]
- id: the-last-shift_m_d_019
- id: pc-detective_m_d_019
text: "Sessik poured my drink without asking. She remembered. Small thing, but noted."
role: player_character
access: [public]
@@ -176,7 +176,7 @@ lines:
trigger: observe_npc
tags: [npc, lera, social, warm]
- id: the-last-shift_m_d_020
- id: pc-detective_m_d_020
text: "The old man by the window hasn't spoken to anyone all evening. Watching, though."
role: player_character
access: [public]
@@ -187,7 +187,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: the-last-shift_m_d_021
- id: pc-detective_m_d_021
text: "Sera left when Torek arrived. Second time. Different excuse. Same result."
role: player_character
access: [public]
@@ -201,7 +201,7 @@ lines:
priority: 7
tags: [npc, sera, torek, tell, friend-arc]
- id: the-last-shift_m_d_022
- id: pc-detective_m_d_022
text: "That's the third time. Three excuses, one result. That's data."
role: player_character
access: [public]
@@ -215,7 +215,7 @@ lines:
priority: 9
tags: [npc, sera, torek, tell, friend-arc, contradiction]
- id: the-last-shift_m_d_023
- id: pc-detective_m_d_023
text: "Lintar's spending doesn't match a dock worker's salary. Source unknown."
role: player_character
access: [public]
@@ -228,7 +228,7 @@ lines:
min_confidence: knows_of
tags: [npc, torek, investigation]
- id: the-last-shift_m_d_024
- id: pc-detective_m_d_024
text: "Sessik's eyes tracked me when I sat near the back room. Protective, not hostile."
role: player_character
access: [public]
@@ -241,7 +241,7 @@ lines:
min_confidence: suspects
tags: [npc, lera, investigation]
- id: the-last-shift_m_d_025
- id: pc-detective_m_d_025
text: "Sera's being extra helpful tonight. Names, backgrounds, context. Compensating?"
role: player_character
access: [public]
@@ -255,7 +255,7 @@ lines:
priority: 7
tags: [npc, sera, friend-arc, behavioral]
- id: the-last-shift_m_d_026
- id: pc-detective_m_d_026
text: "Same booth. Same warm smile. Same offer to buy me a drink. Everything except the truth."
role: player_character
access: [public]
@@ -269,7 +269,7 @@ lines:
priority: 8
tags: [npc, sera, contaminated-trust, friend-arc]
- id: the-last-shift_m_d_027
- id: pc-detective_m_d_027
text: "Tamm seems distressed tonight. Not her baseline. Something at home, or something she heard?"
role: player_character
access: [public]
@@ -282,7 +282,7 @@ lines:
state: known
tags: [npc, naia, behavioral]
- id: the-last-shift_m_d_028
- id: pc-detective_m_d_028
text: "Sessik just redirected a conversation away from freight schedules. Protective reflex?"
role: player_character
access: [public]
@@ -295,7 +295,7 @@ lines:
min_confidence: suspects
tags: [npc, lera, investigation]
- id: the-last-shift_m_d_029
- id: pc-detective_m_d_029
text: "Davan and his partner at the bar. She's talking. He's not listening. Noted."
role: player_character
access: [public]
@@ -308,7 +308,7 @@ lines:
state: known
tags: [npc, kael, naia, behavioral]
- id: the-last-shift_m_d_030
- id: pc-detective_m_d_030
text: "The regulars are warming up. Three visits and I'm almost furniture. Almost."
role: player_character
access: [public]
@@ -324,7 +324,7 @@ lines:
# --- Kael Davan — Additional Bar Observations ---
# Ticket: #297 review fix | Filling detective monologue gap
- id: the-last-shift_m_d_043
- id: pc-detective_m_d_031
text: "Davan is different here. Looser. The dock worker persona drops and something warmer replaces it."
role: player_character
access: [public]
@@ -337,7 +337,7 @@ lines:
state: known
tags: [npc, kael, behavioral, dual-context]
- id: the-last-shift_m_d_044
- id: pc-detective_m_d_032
text: "Davan checks the door every time it opens. Even here. Even off-shift. That is not relaxation."
role: player_character
access: [public]
@@ -357,7 +357,7 @@ lines:
# 5-phase FRIEND arc: trust > background data > pattern > question >
# contaminated trust. Contraction shifts mark objectivity loss.
- id: the-last-shift_m_d_031
- id: pc-detective_m_d_033
text: "Sera changed the subject when I mentioned manifests. Smooth. Practised, maybe."
role: player_character
access: [public]
@@ -371,7 +371,7 @@ lines:
priority: 6
tags: [npc, sera, tell, behavioral, friend-arc]
- id: the-last-shift_m_d_032
- id: pc-detective_m_d_034
text: "She volunteers names, routines, context. Gives everything I ask for -- except what I did not."
role: player_character
access: [public]
@@ -385,7 +385,7 @@ lines:
priority: 6
tags: [npc, sera, tell, overcompensation, friend-arc]
- id: the-last-shift_m_d_033
- id: pc-detective_m_d_035
text: "Sera at the Commission kiosk. Third pass this shift. That is not standard recalibration."
role: player_character
access: [public]
@@ -399,7 +399,7 @@ lines:
priority: 7
tags: [npc, sera, tell, peripheral, friend-arc]
- id: the-last-shift_m_d_034
- id: pc-detective_m_d_036
text: "Everything Sera told me checks out. Layout, schedules, regulars. Accurate. Thorough. Selective?"
role: player_character
access: [public]
@@ -413,7 +413,7 @@ lines:
priority: 5
tags: [npc, sera, analytical, phase-2-3, friend-arc]
- id: the-last-shift_m_d_035
- id: pc-detective_m_d_037
text: "She mentioned Naia's worry about Kael early -- before I had context. Priming, or concern?"
role: player_character
access: [public]
@@ -427,7 +427,7 @@ lines:
priority: 7
tags: [npc, sera, naia, retrospective, friend-arc]
- id: the-last-shift_m_d_036
- id: pc-detective_m_d_038
text: "Sera's institutional access goes further than field calibration. She could pull records I cannot."
role: player_character
access: [public]
@@ -440,7 +440,7 @@ lines:
state: friendly
tags: [npc, sera, analytical, capability, friend-arc]
- id: the-last-shift_m_d_037
- id: pc-detective_m_d_039
text: "Assessment: Venn, S. Cooperative. Embedded. Competent. Green -- with reservations now."
role: player_character
access: [public]
@@ -454,7 +454,7 @@ lines:
priority: 7
tags: [npc, sera, phase-transition, analytical, friend-arc]
- id: the-last-shift_m_d_038
- id: pc-detective_m_d_040
text: "Correction: amber. The avoidance pattern is behavioural, not preferential. Data, not taste."
role: player_character
access: [public]
@@ -468,7 +468,7 @@ lines:
priority: 9
tags: [npc, sera, phase-transition, tell, contradiction, friend-arc]
- id: the-last-shift_m_d_039
- id: pc-detective_m_d_041
text: "She's the first person who made this place feel less foreign. I need that to not matter right now."
role: player_character
access: [public]
@@ -482,7 +482,7 @@ lines:
priority: 8
tags: [npc, sera, emotional, phase-4-5, friend-arc]
- id: the-last-shift_m_d_040
- id: pc-detective_m_d_042
text: "Those early tips. The district layout, the introductions. Were those help -- or steering?"
role: player_character
access: [public]
@@ -496,7 +496,7 @@ lines:
priority: 7
tags: [npc, sera, contaminated-trust, retrospective, friend-arc]
- id: the-last-shift_m_d_041
- id: pc-detective_m_d_043
text: "She's not a suspect. She's a friend with access to everything I need. That is the problem."
role: player_character
access: [public]
@@ -510,7 +510,7 @@ lines:
priority: 8
tags: [npc, sera, contaminated-trust, phase-4-5, friend-arc]
- id: the-last-shift_m_d_042
- id: pc-detective_m_d_044
text: "Trust: compromised. The question is not what she told me. It is what she did not."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: the-terminal
lines:
# --- Arrival + Environmental Flavor ---
- id: the-terminal_m_d_001
- id: pc-detective_m_d_001
text: "Logistics hub. Standard prefab, heavy foot traffic. Let's see what the shift change tells me."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, analytical]
- id: the-terminal_m_d_002
- id: pc-detective_m_d_002
text: "Sixteen bays, four loading arms, one manifest board. Everything routes through here."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [arrival, orientation]
- id: the-terminal_m_d_003
- id: pc-detective_m_d_003
text: "Cargo lubricant and recycled air. The smell of honest work. Or a convincing imitation."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: the-terminal_m_d_004
- id: pc-detective_m_d_004
text: "Loading arm three has a grind in its cycle. Maintenance deferred — budget, or negligence?"
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: hear_sound
tags: [environmental, analytical]
- id: the-terminal_m_d_005
- id: pc-detective_m_d_005
text: "Shift change in twenty minutes. The handover period is where oversight gets thin."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: time_idle
tags: [investigation, operational]
- id: the-terminal_m_d_006
- id: pc-detective_m_d_006
text: "The manifest board updates in real-time. Container movement, weight, destination. All logged."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: enter_location
tags: [environmental, orientation]
- id: the-terminal_m_d_007
- id: pc-detective_m_d_007
text: "Heavy foot traffic at shift change. Good — observation is easier in a crowd."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [arrival, investigation]
- id: the-terminal_m_d_008
- id: pc-detective_m_d_008
text: "Containers stacked to regulation height in most bays. Bay four is the exception."
role: player_character
access: [public]
@@ -75,7 +75,7 @@ lines:
trigger: observe_anomaly
tags: [environmental, investigation]
- id: the-terminal_m_d_009
- id: pc-detective_m_d_009
text: "Back at the hub. Different shift, different faces. Same manifest board."
role: player_character
access: [public]
@@ -84,7 +84,7 @@ lines:
trigger: return_visit
tags: [arrival]
- id: the-terminal_m_d_010
- id: pc-detective_m_d_010
text: "The dock lights are running half-cycle. Saves power. Also reduces visibility."
role: player_character
access: [public]
@@ -93,7 +93,7 @@ lines:
trigger: enter_location
tags: [environmental, analytical]
- id: the-terminal_m_d_011
- id: pc-detective_m_d_011
text: "Something in the air beyond the lubricant. Chemical, faint. Not standard freight residue."
role: player_character
access: [public]
@@ -102,7 +102,7 @@ lines:
trigger: enter_location
tags: [environmental, investigation]
- id: the-terminal_m_d_012
- id: pc-detective_m_d_012
text: "Overhead conveyor running at capacity. The manifest says otherwise. Interesting."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
# --- NPC Commentary ---
- id: the-terminal_m_d_013
- id: pc-detective_m_d_013
text: "Shift supervisor Voss. Tense posture, checking the dock more than the schedule."
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_npc
tags: [npc, voss, behavioral]
- id: the-terminal_m_d_014
- id: pc-detective_m_d_014
text: "Korr, M. — freight scheduler. Efficient, focused. Either very good or very careful."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_npc
tags: [npc, maret, analytical]
- id: the-terminal_m_d_015
- id: pc-detective_m_d_015
text: "Rosta, D. — dock inspector. Quick checks, minimal documentation. That's a pattern."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: observe_npc
tags: [npc, drin, investigation]
- id: the-terminal_m_d_016
- id: pc-detective_m_d_016
text: "Dock worker. Davan, K. Unremarkable on paper. But he knows the bay layout cold."
role: player_character
access: [public]
@@ -149,7 +149,7 @@ lines:
trigger: observe_npc
tags: [npc, kael]
- id: the-terminal_m_d_017
- id: pc-detective_m_d_017
text: "Conversations stop when I pass. Standard response to Commission presence. Expected."
role: player_character
access: [public]
@@ -158,7 +158,7 @@ lines:
trigger: witness_interaction
tags: [social, investigation]
- id: the-terminal_m_d_018
- id: pc-detective_m_d_018
text: "Two workers talking by bay six. One sees me, taps the other. Both go quiet."
role: player_character
access: [public]
@@ -167,7 +167,7 @@ lines:
trigger: witness_interaction
tags: [social, investigation]
- id: the-terminal_m_d_019
- id: pc-detective_m_d_019
text: "Voss gave Davan a look across the bay. Brief. Loaded. Filing it."
role: player_character
access: [public]
@@ -176,7 +176,7 @@ lines:
trigger: witness_interaction
tags: [npc, voss, kael, behavioral]
- id: the-terminal_m_d_020
- id: pc-detective_m_d_020
text: "The courier — Renn — moves fast. Efficient routing. Knows these corridors well."
role: player_character
access: [public]
@@ -185,7 +185,7 @@ lines:
trigger: observe_npc
tags: [npc, renn, analytical]
- id: the-terminal_m_d_021
- id: pc-detective_m_d_021
text: "New shift starting. Different bodies, same avoidance radius around me."
role: player_character
access: [public]
@@ -194,7 +194,7 @@ lines:
trigger: observe_npc
tags: [social, atmospheric]
- id: the-terminal_m_d_022
- id: pc-detective_m_d_022
text: "Rosta's inspection of bay four took forty seconds. The others: three minutes each."
role: player_character
access: [public]
@@ -203,7 +203,7 @@ lines:
trigger: observe_npc
tags: [npc, drin, investigation, behavioral]
- id: the-terminal_m_d_023
- id: pc-detective_m_d_023
text: "Voss deflected when I asked about overnight containers. Smooth, but I caught it."
role: player_character
access: [public]
@@ -212,7 +212,7 @@ lines:
trigger: post_conversation
tags: [npc, voss, investigation]
- id: the-terminal_m_d_024
- id: pc-detective_m_d_024
text: "Korr won't make eye contact. Not hostility — something else. Anxiety, maybe."
role: player_character
access: [public]
@@ -223,7 +223,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: the-terminal_m_d_025
- id: pc-detective_m_d_025
text: "Davan's lattice activity spiked. Expecting a message? Or checking for surveillance?"
role: player_character
access: [public]
@@ -236,7 +236,7 @@ lines:
state: known
tags: [npc, kael, behavioral, tell]
- id: the-terminal_m_d_026
- id: pc-detective_m_d_026
text: "Container 4471. Logged for temp storage overnight. Turnaround should be sub-eight hours."
role: player_character
access: [public]
@@ -250,7 +250,7 @@ lines:
priority: 7
tags: [investigation, evidence]
- id: the-terminal_m_d_027
- id: pc-detective_m_d_027
text: "Weight discrepancy on the bay four manifest. Point-three kilos over declared. Noted."
role: player_character
access: [public]
@@ -263,7 +263,7 @@ lines:
min_confidence: suspects
tags: [investigation, evidence]
- id: the-terminal_m_d_028
- id: pc-detective_m_d_028
text: "Rosta's gambling debts are in the case file. Leverage, if I need it. Do I need it?"
role: player_character
access: [public]
@@ -276,7 +276,7 @@ lines:
min_confidence: knows_details
tags: [npc, drin, investigation, moral]
- id: the-terminal_m_d_029
- id: pc-detective_m_d_029
text: "Three names keep appearing near the anomalous manifests: Voss, Davan, Renn. Coincidence?"
role: player_character
access: [public]
@@ -290,7 +290,7 @@ lines:
priority: 7
tags: [investigation, analytical]
- id: the-terminal_m_d_030
- id: pc-detective_m_d_030
text: "The shift transition creates a twenty-minute gap in active oversight. Every cycle."
role: player_character
access: [public]
@@ -304,7 +304,7 @@ lines:
priority: 7
tags: [investigation, operational]
- id: the-terminal_m_d_031
- id: pc-detective_m_d_031
text: "Korr changed the schedule after our conversation. Covering tracks, or just doing her job?"
role: player_character
access: [public]
@@ -318,7 +318,7 @@ lines:
value: schedule_modification
tags: [npc, maret, investigation]
- id: the-terminal_m_d_032
- id: pc-detective_m_d_032
text: "Rosta inspects everything else by the book. Bay four is the exception. That's the tell."
role: player_character
access: [public]
@@ -332,7 +332,7 @@ lines:
priority: 8
tags: [npc, drin, investigation, behavioral]
- id: the-terminal_m_d_033
- id: pc-detective_m_d_033
text: "Davan's at an unusual terminal. Not his assigned bay. Why the move?"
role: player_character
access: [public]
@@ -345,7 +345,7 @@ lines:
state: known
tags: [npc, kael, investigation]
- id: the-terminal_m_d_034
- id: pc-detective_m_d_034
text: "Voss is watching me watch his people. He knows what I'm here for."
role: player_character
access: [public]
@@ -359,7 +359,7 @@ lines:
priority: 6
tags: [npc, voss, investigation]
- id: the-terminal_m_d_035
- id: pc-detective_m_d_035
text: "The routing logs and the physical movement don't match. Someone's hand-editing."
role: player_character
access: [public]
@@ -376,7 +376,7 @@ lines:
# --- Kael Davan FRIEND Arc — Additional Detective Observations ---
# Ticket: #297 review fix | Filling detective monologue gap (7 → 12 Kael lines)
- id: the-terminal_m_d_036
- id: pc-detective_m_d_036
text: "Davan's shift pattern is clean. Too clean. Same bays, same hours, same rotation. Nothing out of place."
role: player_character
access: [public]
@@ -389,7 +389,7 @@ lines:
state: known
tags: [npc, kael, analytical, behavioral]
- id: the-terminal_m_d_037
- id: pc-detective_m_d_037
text: "Davan eats with the same group every shift. Social anchor, or keeping tabs?"
role: player_character
access: [public]
@@ -402,7 +402,7 @@ lines:
state: known
tags: [npc, kael, social, behavioral]
- id: the-terminal_m_d_038
- id: pc-detective_m_d_038
text: "Davan handled that container routing without checking the manifest board. He knows the schedule by heart."
role: player_character
access: [public]
@@ -416,7 +416,7 @@ lines:
priority: 6
tags: [npc, kael, behavioral, tell, competence]
- id: the-terminal_m_d_039
- id: pc-detective_m_d_039
text: "Davan's posture changed when I approached bay four. Subtle. Filing it."
role: player_character
access: [public]
@@ -430,7 +430,7 @@ lines:
priority: 7
tags: [npc, kael, tell, behavioral, friend-arc]
- id: the-terminal_m_d_040
- id: pc-detective_m_d_040
text: "Three manifests with weight discrepancies. All during Davan's shifts. Correlation is not causation. But noted."
role: player_character
access: [public]
@@ -0,0 +1,243 @@
character: detective
location: tutorial
lines:
# Diegetic Tutorial Monologue — Ticket #330 — Detective
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
#
# These lines teach mechanics through character voice — not UI instructions.
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
# These fire on first-time events throughout gameplay, wherever they occur.
#
# Coverage:
# Movement / exploration — first_move, first_new_section
# Fog of perception — first_fog_encounter, first_fog_edge
# Sound model — first_sound_heard, first_off_screen_sound
# NPC interaction — first_npc_proximity, first_npc_face_read
# Insert / HUD — first_insert_open, first_insert_contact_flag
#
# Voice: analytical, procedure-oriented, careful. No contractions in analytical mode.
# Colon usage for categorization. 'Let's see what...' and 'Worth flagging' verbal tics.
# ID discriminator: _tut_ per sprint briefing.
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
# fire once per game), not by a cooldown value.
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
#
# Schema corrections (Gestalt review, Sprint 17):
# priority: string "tutorial" → integer. All other monologue files use integer priority;
# string value would fail engine parse in monologue.rs.
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
# enum. Replaced with arrival, routine per actual trigger context.
#
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
# D-035 v0.1 set — require server-side implementation in monologue.rs.
# -----------------------------------------------------------------------
# MOVEMENT / EXPLORATION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_001
text: "Signage: section designators on the corridor wall. Commission-standard markings. Worth learning before the district learns me."
role: player_character
access: [public]
trust: surface
situation: [arrival, routine]
trigger: first_move
mood: [focused]
priority: 8
tags: [tutorial, movement, navigation, analytical]
notes: >
Detective version of the 'check the signage' tutorial. Colon usage for the signage
label (section 2.2 verbal pattern). 'Commission-standard markings' — institutional
framing; he reads the environment through his professional lens first. 'Worth learning
before the district learns me' — dry awareness of his asymmetric visibility in this
community. Same teaching as the smuggler version, different cognitive frame.
- id: pc-detective_m_d_tut_002
text: "Three sections in range from the main terminal. Commission briefing had the layout. Let's confirm it against the floor."
role: player_character
access: [public]
trust: surface
situation: [arrival, investigation]
trigger: first_new_section
mood: [focused]
priority: 6
tags: [tutorial, movement, navigation, analytical]
notes: >
Fires on entering a new section for the first time. 'Commission briefing had the
layout' — he arrived with a map, now he's verifying it against reality. 'Let's
confirm it against the floor' — 'Let's' verbal tic, investigative method stated as
principle: documentation is a starting point, not a substitute for observation.
Teaches: the district has distinct sections to explore.
# -----------------------------------------------------------------------
# FOG OF PERCEPTION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_003
text: "Visual range terminates at the corridor junction. Consistent with district atmo settings. Anything past that point: unconfirmed."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_encounter
mood: [focused]
priority: 8
tags: [tutorial, fog, perception, analytical]
notes: >
Detective fog tutorial — analytical register throughout. 'Visual range terminates at'
instead of 'Can't see past.' 'Consistent with atmo settings' — he already knows this
is an environmental property, not an anomaly. 'Anything past that point: unconfirmed'
— colon usage, precise epistemic status. Teaches: perception boundary exists, and
the detective categorizes it as an information state, not a barrier. Compare to
smuggler's 'Might be worth checking' — same fog, different response.
- id: pc-detective_m_d_tut_004
text: "Can't clear the far bay from here. Going around is the procedure — not a limitation, a working condition."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_edge
mood: [focused]
priority: 6
tags: [tutorial, fog, perception, analytical]
notes: >
Second fog encounter line — fires at a fog zone boundary. 'Going around is the
procedure' — detective frames the limitation as methodology. The second clause
('not a limitation, a working condition') is self-instruction: he's telling himself
not to treat fog as frustrating but as standard operating environment. Teaches:
perception range is persistent throughout gameplay, and the correct response is
movement, not waiting.
# -----------------------------------------------------------------------
# SOUND MODEL
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_005
text: "Voices. Down the corridor. Source count indeterminate from here — distance degrades clarity."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_sound_heard
mood: [focused]
priority: 8
tags: [tutorial, sound, perception, analytical]
notes: >
Detective sound tutorial — analytical framing of the briefing's example. 'Source count
indeterminate' is the precise version of 'can't tell how many.' 'Distance degrades
clarity' — technical, causal. He hears, notes what he can establish (voices, direction)
and what he can't (count, content). No contractions. Teaches: sound channel is active
and distance-limited. Compare to smuggler's 'Can't make out the words' — same acoustic
reality, systematized differently.
- id: pc-detective_m_d_tut_006
text: "Footsteps. Metal-grated surface. Two sets minimum — moving away. Service access corridor, by the acoustic signature."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_off_screen_sound
mood: [focused]
priority: 6
tags: [tutorial, sound, perception, analytical]
notes: >
Second sound tutorial — fires on an off-screen audio source. The detective parses
the sound through multiple analytical dimensions: surface type, count, direction,
room type. 'Two sets minimum' — precise but not overclaiming. 'By the acoustic
signature' is technical terminology applied to diegetic audio interpretation.
Teaches: sound carries more than presence — floor type, distance, count, direction
are all extractable. The detective extracts all of them automatically.
# -----------------------------------------------------------------------
# NPC INTERACTION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_007
text: "Civilians in range. Standard approach: observe before engaging. Let them surface what's relevant before directing the conversation."
role: player_character
access: [public]
trust: surface
situation: [social, observation]
trigger: first_npc_proximity
mood: [focused]
priority: 8
tags: [tutorial, npc, interaction, analytical]
notes: >
Detective NPC tutorial — procedural, method-as-principle. 'Civilians in range' is
his categorization (Commission-trained: everyone is a witness category until proven
otherwise). 'Standard approach' — he has a protocol. 'Let them surface what's relevant'
is the investigative principle: don't prime witnesses, let them self-select information.
No contractions throughout. Teaches: NPC interaction is player-initiated and
approach-sensitive. Different method than the smuggler ('give them reason to') —
same outcome, different social model.
- id: pc-detective_m_d_tut_008
text: "Commission credentials flag on approach. They know who I am before I speak. Worth noting who adjusts their behavior."
role: player_character
access: [public]
trust: surface
situation: [social, investigation]
trigger: first_npc_face_read
mood: [focused]
priority: 6
tags: [tutorial, npc, observation, analytical, authority]
notes: >
Fires when the player observes an NPC at close range. The detective's asymmetry:
his authority access tier makes him visible in ways the smuggler is not. NPCs react
to his presence before interaction begins. 'Worth noting who adjusts their behavior'
— 'Worth' verbal tic, sets up behavioral observation as investigation method. Teaches:
the detective's Commission credentials affect NPC behavior, and that behavioral
response is data.
# -----------------------------------------------------------------------
# INSERT / HUD
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_009
text: "Lattice overlay active: time, contact positions, flagged entities. Commission-linked. Standard procedure to keep it running."
role: player_character
access: [public]
trust: surface
situation: [routine]
trigger: first_insert_open
mood: [focused]
priority: 8
tags: [tutorial, insert, hud, analytical]
notes: >
Detective insert tutorial. Colon usage: catalogs what the overlay contains. 'Commission-
linked' — institutional framing, establishes why the detective's HUD is denser than
the smuggler's. 'Standard procedure' — not a discovery, a protocol. Teaches: the
insert is an active investigation tool, not optional. Compare to smuggler's 'Should
show the time and nearby contacts' — same overlay, presented as procedural requirement
vs. operational check.
- id: pc-detective_m_d_tut_010
text: "Nine identity flags in range. Cross-referencing case file now. Let's see what matches."
role: player_character
access: [public]
trust: surface
situation: [investigation, routine]
trigger: first_insert_contact_flag
mood: [focused]
priority: 6
tags: [tutorial, insert, hud, lattice, investigation]
notes: >
Fires when the insert first populates with flagged entities. Count given precisely
('Nine identity flags'). 'Case file' — the detective's overlay cross-references a
pre-loaded investigation file, not just a general registry. 'Let's see what matches'
— 'Let's see what' verbal tic closing the tutorial sequence. Teaches: the detective's
insert is investigation-keyed, matching observed faces against case file entries.
This is why his overlay is analytically denser than the smuggler's.
@@ -0,0 +1,228 @@
character: smuggler
location: contradiction
lines:
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc (Smuggler perspective)
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
#
# Trigger: contradiction_detected fires when the smuggler observes Kael in Corridor B-7
# with an unrecognized contact — contradicting Kael's stated location and ring protocol.
# Event payload: source_display_name = "Kael Davan", subject_display_name = "Kael Davan"
# (Kael is both source and subject: he placed himself elsewhere, smuggler sees him here.)
#
# FRIEND-pattern NPCs always use hand-authored lines — generic template fallback
# does not fire for this arc.
#
# Authoring principle: cognitive dissonance, not accusation. The smuggler reads social
# signals instinctively, but doesn't conclude — she contains the alarm. Street-smart
# gut reaction, not analytical procedure.
#
# Phase 2 lines: first observation — Kael in B-7 with unknown contact.
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Kael Davan)
# Phase 3 lines: behavioral pattern already accumulating — increased lattice checking,
# shortened interactions, early exits from break room.
# Prerequisite: Phase 2 + Kael's behavioral anomalies observed (count >= 2).
#
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
# fires once per KG state change), not by a cooldown value.
#
# Note: contradiction_detected is a new trigger type — requires server-side implementation
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
# -----------------------------------------------------------------------
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_001
text: "Kael said second shift was clear. He's in B-7. With someone I don't know."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational]
notes: >
Primary line — three beats, three facts. 'Kael said' names the source (friend register,
first name). 'He's in B-7' is the observation. 'With someone I don't know' is the alarm:
not just wrong location, but outside the ring. Fragment structure throughout — the smuggler
clocks a scene in pieces. No accusation, no conclusion: just inventory of what's wrong.
- id: pc-smuggler_m_s_con_002
text: "That's Kael. Restricted corridor. Unrecognized contact. Kael doesn't come to B-7."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational]
notes: >
Alternate primary — catalog format. 'That's Kael' opens with identification, not alarm:
recognition first, then the wrong details fall into place. Each fragment adds one layer
of what's wrong. Close: 'Kael doesn't come to B-7' — negative statement of prior
expectation, which is now violated. No contraction in the closing declarative (emphasis).
- id: pc-smuggler_m_s_con_003
text: "He's in B-7. He told me he'd be heading home after shift. One of those isn't the case."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2]
notes: >
Alternate primary — echoes the Paula canonical structure (observation / prior claim /
contradiction stated) but in the smuggler's voice. 'He told me' is personal register
(friend, not colleague). 'One of those isn't the case' — the smuggler's understated
version of flagging a contradiction: not 'one of them is a lie' but 'isn't the case.'
Epistemic containment in plain language.
# -----------------------------------------------------------------------
# PHASE 2 — Secondary beat (gut-level recalibration, operational containment)
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_004
text: "Maybe there's a reason. There's always a reason. But Kael knows better than to run a meeting in a maintenance corridor without telling me."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 7
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, cognitive-dissonance, secondary-beat]
notes: >
Secondary beat — fires 3-5 seconds after primary. Self-challenge first: the smuggler
gives Kael benefit of the doubt, then immediately walks it back with operational
logic. 'There's always a reason' is the street-smart equivalent of epistemic
containment — she's seen strange things before. The closing line is where the alarm
lives: this isn't strange, it's a protocol violation.
Lower priority (7) ensures primary lines fire first; server team should implement
delay_after_trigger_ticks for proper sequencing.
- id: pc-smuggler_m_s_con_005
text: "Don't assume. Could be he's running something for Nils I wasn't told about. Could be something else. The second thing is the problem."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 6
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational, cognitive-dissonance, secondary-beat]
notes: >
Alternate secondary beat. 'Don't assume' is the smuggler's self-instruction (operational
discipline). Two possibilities held open: ring-authorized (names Nils — the coordinator)
vs. outside the ring. 'The second thing is the problem' — the alarm, stated as dry
understatement. Operational logic applied to an emotional moment: the smuggler
keeps the processing clean even when the stakes are personal.
# -----------------------------------------------------------------------
# PHASE 3 — Behavioral pattern accumulated (increased lattice checking, early exits)
# Requires: Kael behavioral anomalies observed prior to the B-7 contradiction.
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_006
text: "He's been distracted for three days. Lattice checks every few minutes, leaving lunch early. Now a B-7 meeting with someone outside the ring. Kael's working an angle he hasn't told me about."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, pattern-recognition, operational]
notes: >
Phase 3 primary — connects all accumulated behavioral data to the contradiction. Fragment
catalog builds the picture: three days of signals, then this. Closing line names the
conclusion without naming its implication: 'working an angle' is street-smart language
for running something unauthorized, but the smuggler doesn't say 'exit attempt' —
she doesn't know that yet.
Note: tells_observed is a proposed prerequisite field — server team (#550) to implement
or simplify to a different check.
- id: pc-smuggler_m_s_con_007
text: "Kael doesn't run his own contacts. Devra handles external. So either Devra knows about this, or Kael's outside protocol. Neither's good."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, operational]
notes: >
Alternate Phase 3 — operational knowledge applied. The smuggler knows ring structure:
Devra manages external contacts, Kael handles cargo logistics. An unrecognized contact
means either Devra is running something Kael's involved in (plausible, alarming) or
Kael went outside protocol (worse). Both possibilities named, neither closed. 'Neither's
good' — the smuggler's understated close for a significant threat read.
- id: pc-smuggler_m_s_con_008
text: "He's been somewhere else in his head all week. Now I know why. Whoever that was — not ring. Kael's looking for a way out."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [anxious]
priority: 8
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, intuition]
notes: >
Alternate Phase 3 — gut read, highest emotional weight. 'Somewhere else in his head'
describes the behavioral changes without cataloging them. 'Now I know why' — the click
of understanding arriving. 'Not ring' — short, certain, two words. 'Looking for a way
out' is street-smart language for exit attempt: the smuggler doesn't know the details
but the read is right. This line is the closest the smuggler comes to naming what's
happening — and she still doesn't say it explicitly.
@@ -1,7 +1,7 @@
character: smuggler
location: general
lines:
- id: general_m_s_001
- id: pc-smuggler_m_s_001
text: "Another day on Sova. Same air, same hum, same faces."
role: player_character
access: [public]
@@ -10,7 +10,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: general_m_s_002
- id: pc-smuggler_m_s_002
text: "The span gate hum's louder today. Maintenance hasn't touched it in weeks."
role: player_character
access: [public]
@@ -19,7 +19,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: general_m_s_003
- id: pc-smuggler_m_s_003
text: "Twenty minutes between shifts. That's the window. Always has been."
role: player_character
access: [public]
@@ -28,7 +28,7 @@ lines:
trigger: time_idle
tags: [operational, orientation]
- id: general_m_s_004
- id: pc-smuggler_m_s_004
text: "People talk about leaving Sova. Nobody actually does."
role: player_character
access: [public]
@@ -37,7 +37,7 @@ lines:
trigger: time_idle
tags: [reflection]
- id: general_m_s_005
- id: pc-smuggler_m_s_005
text: "Two years of this. Still not used to the gate vibration in my teeth."
role: player_character
access: [public]
@@ -46,7 +46,7 @@ lines:
trigger: hear_sound
tags: [environmental, personal]
- id: general_m_s_006
- id: pc-smuggler_m_s_006
text: "The money's good. The risk's manageable. Keep telling yourself that."
role: player_character
access: [public]
@@ -55,7 +55,7 @@ lines:
trigger: time_idle
tags: [reflection, operational]
- id: general_m_s_007
- id: pc-smuggler_m_s_007
text: "Back again. Same corridors, same shortcuts, same reasons."
role: player_character
access: [public]
@@ -64,7 +64,7 @@ lines:
trigger: return_visit
tags: [arrival]
- id: general_m_s_008
- id: pc-smuggler_m_s_008
text: "Commission patrols are thinner this cycle. Good or bad — hard to say."
role: player_character
access: [public]
@@ -73,7 +73,7 @@ lines:
trigger: observe_anomaly
tags: [operational, caution]
- id: general_m_s_009
- id: pc-smuggler_m_s_009
text: "If anyone asks, I'm clocking overtime. That's always the story."
role: player_character
access: [public]
@@ -82,7 +82,7 @@ lines:
trigger: time_idle
tags: [operational, cover]
- id: general_m_s_010
- id: pc-smuggler_m_s_010
text: "Recycled air tastes different near the outer ring. Sharper. More machine."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: maintenance-corridors
lines:
# --- Arrival + Environmental Flavor ---
- id: maintenance-corridors_m_s_001
- id: pc-smuggler_m_s_001
text: "Service corridor. Dim lights, damp walls, and the constant drip of condensation."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: maintenance-corridors_m_s_002
- id: pc-smuggler_m_s_002
text: "Cooler down here. The ventilation doesn't reach this far."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_s_003
- id: pc-smuggler_m_s_003
text: "The access panel's still loose. Good — our route's intact."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, operational]
- id: maintenance-corridors_m_s_004
- id: pc-smuggler_m_s_004
text: "Pipes groaning overhead. Station's old. These corridors are older."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: hear_sound
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_s_005
- id: pc-smuggler_m_s_005
text: "Water recycling rumbles through the walls down here. Constant."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: maintenance-corridors_m_s_006
- id: pc-smuggler_m_s_006
text: "Back in the maintenance tunnels. Rust and coolant."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: return_visit
tags: [arrival, atmospheric]
- id: maintenance-corridors_m_s_007
- id: pc-smuggler_m_s_007
text: "Emergency lighting only. Just enough to see where you're going."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [environmental]
- id: maintenance-corridors_m_s_008
- id: pc-smuggler_m_s_008
text: "Footsteps echo different down here. You learn to listen."
role: player_character
access: [public]
@@ -77,7 +77,7 @@ lines:
# --- NPC + Operational ---
- id: maintenance-corridors_m_s_009
- id: pc-smuggler_m_s_009
text: "Renn's chalk marks on the wall. Route's been used recently."
role: player_character
access: [public]
@@ -86,7 +86,7 @@ lines:
trigger: observe_anomaly
tags: [operational, renn]
- id: maintenance-corridors_m_s_010
- id: pc-smuggler_m_s_010
text: "Someone left a crate at junction B-4. Not one of ours."
role: player_character
access: [public]
@@ -95,7 +95,7 @@ lines:
trigger: observe_anomaly
tags: [operational, caution]
- id: maintenance-corridors_m_s_011
- id: pc-smuggler_m_s_011
text: "The temp storage hatch is warm. Cargo cycled through recently."
role: player_character
access: [public]
@@ -104,7 +104,7 @@ lines:
trigger: enter_location
tags: [operational]
- id: maintenance-corridors_m_s_012
- id: pc-smuggler_m_s_012
text: "Scratches on the lock plate. Someone's been forcing this access point."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
trigger: observe_anomaly
tags: [environmental, caution]
- id: maintenance-corridors_m_s_013
- id: pc-smuggler_m_s_013
text: "Timer says twelve minutes until the oversight gap closes. Plenty."
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: time_idle
tags: [operational]
- id: maintenance-corridors_m_s_014
- id: pc-smuggler_m_s_014
text: "That's Renn's jacket on the pipe junction. He was here."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_anomaly
tags: [npc, renn, operational]
- id: maintenance-corridors_m_s_015
- id: pc-smuggler_m_s_015
text: "Voices echoing from the junction. Can't make out words yet."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: hear_sound
tags: [caution]
- id: maintenance-corridors_m_s_016
- id: pc-smuggler_m_s_016
text: "The route through B-section is clear. Voss kept his word."
role: player_character
access: [public]
@@ -151,7 +151,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: maintenance-corridors_m_s_017
- id: pc-smuggler_m_s_017
text: "Corridor B-7. This is where I saw Kael. With someone I don't know."
role: player_character
access: [public]
@@ -165,7 +165,7 @@ lines:
priority: 8
tags: [npc, kael, contradiction, friend-arc]
- id: maintenance-corridors_m_s_018
- id: pc-smuggler_m_s_018
text: "Fresh boot prints in the dust. Two sets. Different sizes."
role: player_character
access: [public]
@@ -178,7 +178,7 @@ lines:
min_confidence: suspects
tags: [evidence, kael, friend-arc]
- id: maintenance-corridors_m_s_019
- id: pc-smuggler_m_s_019
text: "New scratch marks on the service hatch. Someone's using our route."
role: player_character
access: [public]
@@ -192,7 +192,7 @@ lines:
priority: 7
tags: [operational, caution]
- id: maintenance-corridors_m_s_020
- id: pc-smuggler_m_s_020
text: "The container in temp was supposed to move last shift. It didn't. Why?"
role: player_character
access: [public]
@@ -205,7 +205,7 @@ lines:
min_confidence: knows_of
tags: [operational, contraband]
- id: maintenance-corridors_m_s_021
- id: pc-smuggler_m_s_021
text: "Camera's been repositioned at junction C-2. Who authorized that?"
role: player_character
access: [public]
@@ -219,7 +219,7 @@ lines:
priority: 7
tags: [investigation, caution]
- id: maintenance-corridors_m_s_022
- id: pc-smuggler_m_s_022
text: "If Kael's meeting people down here without telling us, the whole route's exposed."
role: player_character
access: [public]
@@ -233,7 +233,7 @@ lines:
priority: 8
tags: [npc, kael, operational, friend-arc]
- id: maintenance-corridors_m_s_023
- id: pc-smuggler_m_s_023
text: "Sealed containers don't end up in temp storage by accident. Someone rerouted."
role: player_character
access: [public]
@@ -246,7 +246,7 @@ lines:
min_confidence: knows_details
tags: [operational, contraband]
- id: maintenance-corridors_m_s_024
- id: pc-smuggler_m_s_024
text: "Kael's not answering his lattice. He was supposed to meet me here."
role: player_character
access: [public]
@@ -260,7 +260,7 @@ lines:
priority: 7
tags: [npc, kael, concern, friend-arc]
- id: maintenance-corridors_m_s_025
- id: pc-smuggler_m_s_025
text: "Someone wiped the dust off this hatch. Recently. Carefully."
role: player_character
access: [public]
@@ -8,7 +8,7 @@ lines:
# Arc: station hum → spatial count → fog boundary → thin crowd → Kael sighting ×2 → news ticker → sound ping → time countdown → settle ×2.
# All lines fully schema-compliant per D-035 (role, access, trust, situation present).
- id: opening_m_s_001
- id: pc-smuggler_m_s_001
text: "Station hum. B-flat, steady — freight's moving."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
tags: [opening-hook, tutorial-arc, atmospheric]
notes: "Opening sensation. Station audio registers first — the hum tells her freight is running. Teaches: audio environment carries operational information (D-074)."
- id: opening_m_s_002
- id: pc-smuggler_m_s_002
text: "Sixteen bays, three arms running. Standard morning."
role: player_character
access: [public]
@@ -34,7 +34,7 @@ lines:
tags: [opening-hook, tutorial-arc, orientation]
notes: "Spatial orientation. She clocks the floor in two beats — count of bays, count of running arms. Everything in fragments. Teaches: the terminal layout."
- id: opening_m_s_003
- id: pc-smuggler_m_s_003
text: "Haze past bay eight today. Can't see the far dock from here."
role: player_character
access: [public]
@@ -47,7 +47,7 @@ lines:
tags: [opening-hook, tutorial-arc, fog, perception]
notes: "Fog boundary. She notices the edge of her vision as a fact of the floor, not a mechanic. Teaches: perception range has limits (D-015, D-046)."
- id: opening_m_s_004
- id: pc-smuggler_m_s_004
text: "Morning crew's spread thin. Good. Room to move."
role: player_character
access: [public]
@@ -60,7 +60,7 @@ lines:
tags: [opening-hook, tutorial-arc]
notes: "Light crowd = operational comfort. 'Good.' standalone verbal tic (section 1.7). Teaches: NPC density affects movement options."
- id: opening_m_s_005
- id: pc-smuggler_m_s_005
text: "Kael's at dock three. Right where he should be."
role: player_character
access: [public]
@@ -73,7 +73,7 @@ lines:
tags: [opening-hook, tutorial-arc, kael, friend-arc]
notes: "First Kael sighting. Warm, immediate — she locates him before she finishes reading the floor. Teaches: named NPCs are identifiable via insert overlay (D-033 green entity)."
- id: opening_m_s_006
- id: pc-smuggler_m_s_006
text: "Already has his clipboard. Day's organized before it starts. Two years, same routine."
role: player_character
access: [public]
@@ -86,7 +86,7 @@ lines:
tags: [opening-hook, tutorial-arc, kael, friend-arc]
notes: "Kael warmth through behavior-reading, not labeling. Three beats: observation → interpretation → familiarity. 'Two years, same routine' says she knows his patterns intimately. Establishes: Kael is trusted, competent, reliable — the baseline before the friend arc erodes it."
- id: opening_m_s_007
- id: pc-smuggler_m_s_007
text: "Bulletin board's got a new Commission notice. Lattice registration updates. Not today's problem."
role: player_character
access: [public]
@@ -99,7 +99,7 @@ lines:
tags: [opening-hook, tutorial-arc, news-ticker]
notes: "News ticker glance. She reads and dismisses it. The Commission exists as background noise — relevant only when it stops being ignorable. Teaches: environmental text exists, player can engage or skip. D-037 irony: lattice registration is exactly what the ring circumvents."
- id: opening_m_s_008
- id: pc-smuggler_m_s_008
text: "Span gate echo from dock fourteen. Something inbound, running heavy by the sound of it."
role: player_character
access: [public]
@@ -112,7 +112,7 @@ lines:
tags: [opening-hook, tutorial-arc, sound, perception]
notes: "First sound ping at the edge of range. She interprets pitch instantly — the echo tells her load weight. Teaches: sound carries operational information (D-018, D-074 sound model). Sound channel is meaningful."
- id: opening_m_s_009
- id: pc-smuggler_m_s_009
text: "Shift board says light cargo today. Fifty minutes before Voss does his rounds."
role: player_character
access: [public]
@@ -125,7 +125,7 @@ lines:
tags: [opening-hook, tutorial-arc, operational]
notes: "'Fifty minutes' = time-as-countdown verbal tic (section 1.7). She's already calculating windows. Teaches: time and NPC patrol rhythms are trackable."
- id: opening_m_s_010
- id: pc-smuggler_m_s_010
text: "Same floor, same faces, same hum. Could be worse."
role: player_character
access: [public]
@@ -138,7 +138,7 @@ lines:
tags: [opening-hook, tutorial-arc, atmospheric]
notes: "Settling line. Baseline contentment. Parallel three-beat structure then understatement. 'Could be worse' is her comfort language — not enthusiastic, just settled. Voice anchor for confidence register (section 1.8)."
- id: opening_m_s_011
- id: pc-smuggler_m_s_011
text: "Dock's clean this morning. Good start."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
# --- Tell Observations (5 core tells from pc-as-npc-spec.md section 8) ---
- id: general_m_s_011
- id: pc-smuggler_m_s_001
text: "Commission officer just stood there after talking to Voss. Making notes. Not good."
role: player_character
access: [public]
@@ -25,7 +25,7 @@ lines:
state: known
tags: [npc, pc-detective, tell, note-taking, caution]
- id: general_m_s_012
- id: pc-smuggler_m_s_002
text: "That investigator is working the hub section by section. Not browsing. Searching."
role: player_character
access: [public]
@@ -38,7 +38,7 @@ lines:
state: known
tags: [npc, pc-detective, tell, systematic-movement, caution]
- id: general_m_s_013
- id: pc-smuggler_m_s_003
text: "Commission's parked at the manifest terminal again. Third time today."
role: player_character
access: [public]
@@ -51,7 +51,7 @@ lines:
state: known
tags: [npc, pc-detective, tell, manifest-fixation, caution]
- id: general_m_s_014
- id: pc-smuggler_m_s_004
text: "They're watching. From the corner, where they can see the whole dock floor. Professional."
role: player_character
access: [public]
@@ -64,7 +64,7 @@ lines:
state: known
tags: [npc, pc-detective, tell, observation-positioning, caution]
- id: general_m_s_015
- id: pc-smuggler_m_s_005
text: "The investigator and Sera again. Old friends? Or is Sera reporting to them?"
role: player_character
access: [public]
@@ -82,7 +82,7 @@ lines:
# --- Operational Awareness (smuggler's threat assessment) ---
- id: general_m_s_016
- id: pc-smuggler_m_s_006
text: "New Commission face at the hub. Keep your head down. Don't give them anything."
role: player_character
access: [public]
@@ -96,7 +96,7 @@ lines:
priority: 8
tags: [npc, pc-detective, operational, caution, first-sighting]
- id: general_m_s_017
- id: pc-smuggler_m_s_007
text: "That investigator's been here all morning. Nobody stays that long for a routine check."
role: player_character
access: [public]
@@ -110,7 +110,7 @@ lines:
priority: 6
tags: [npc, pc-detective, suspicion, operational]
- id: general_m_s_018
- id: pc-smuggler_m_s_008
text: "Commission's at the bar. Sitting with Sera. Watching the room like they own it."
role: player_character
access: [public]
@@ -23,7 +23,7 @@ lines:
# --- Arrival ---
- id: smuggling-hold_m_s_001
- id: pc-smuggler_m_s_001
text: "Sub-level. The kind of space nobody finds unless you know where to look."
role: player_character
access: [public]
@@ -32,7 +32,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, atmospheric, operational]
- id: smuggling-hold_m_s_002
- id: pc-smuggler_m_s_002
text: "Coolant smell. No ventilation down here — just recycled air and waiting."
role: player_character
access: [public]
@@ -41,7 +41,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, atmospheric, sensory]
- id: smuggling-hold_m_s_003
- id: pc-smuggler_m_s_003
text: "Access hatch sealed from the inside. Good. Route's still ours."
role: player_character
access: [public]
@@ -50,7 +50,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, operational]
- id: smuggling-hold_m_s_004
- id: pc-smuggler_m_s_004
text: "Three containers in temp. Right where they should be."
role: player_character
access: [public]
@@ -63,7 +63,7 @@ lines:
min_confidence: knows_of
tags: [arrival, operational, contraband]
- id: smuggling-hold_m_s_005
- id: pc-smuggler_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]
@@ -72,7 +72,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, operational]
- id: smuggling-hold_m_s_006
- id: pc-smuggler_m_s_006
text: "Nobody's been here. Good. That's how it should feel."
role: player_character
access: [public]
@@ -83,7 +83,7 @@ lines:
# --- Perception / Sound ---
- id: smuggling-hold_m_s_007
- id: pc-smuggler_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]
@@ -92,7 +92,7 @@ lines:
situation: [routine]
tags: [sensory, environmental, operational]
- id: smuggling-hold_m_s_008
- id: pc-smuggler_m_s_008
text: "Footsteps above. Maintenance crew — they stay up top. Always."
role: player_character
access: [public]
@@ -101,7 +101,7 @@ lines:
situation: [routine]
tags: [sensory, environmental, caution]
- id: smuggling-hold_m_s_009
- id: pc-smuggler_m_s_009
text: "That sound. Pressure shift. Someone opened the main hatch."
role: player_character
access: [public]
@@ -111,7 +111,7 @@ lines:
priority: 7
tags: [sensory, caution]
- id: smuggling-hold_m_s_010
- id: pc-smuggler_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]
@@ -122,7 +122,7 @@ lines:
# --- Time Idle / Ruminative ---
- id: smuggling-hold_m_s_011
- id: pc-smuggler_m_s_011
text: "Fifteen minutes until the oversight window closes. Plenty of time. Probably."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
situation: [shift_transition]
tags: [operational, atmospheric]
- id: smuggling-hold_m_s_012
- id: pc-smuggler_m_s_012
text: "Medical-grade lattice components. People need these. That's still the reason."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
situation: [routine]
tags: [atmospheric, contraband]
- id: smuggling-hold_m_s_013
- id: pc-smuggler_m_s_013
text: "Twelve minutes of reduced oversight. Stop counting and do something useful."
role: player_character
access: [public]
@@ -149,7 +149,7 @@ lines:
situation: [shift_transition]
tags: [operational]
- id: smuggling-hold_m_s_014
- id: pc-smuggler_m_s_014
text: "How many times have I been in this room telling myself it's almost done?"
role: player_character
access: [public]
@@ -158,7 +158,7 @@ lines:
situation: [routine]
tags: [atmospheric, personal]
- id: smuggling-hold_m_s_015
- id: pc-smuggler_m_s_015
text: "Quiet. The right kind. Not the wrong kind."
role: player_character
access: [public]
@@ -169,7 +169,7 @@ lines:
# --- Anomaly / Investigation ---
- id: smuggling-hold_m_s_016
- id: pc-smuggler_m_s_016
text: "Container 4471's been opened. Not by me. Not by anyone I authorized."
role: player_character
access: [public]
@@ -179,7 +179,7 @@ lines:
priority: 8
tags: [operational, caution, contraband]
- id: smuggling-hold_m_s_017
- id: pc-smuggler_m_s_017
text: "Dust disturbed at the secondary hatch. Recent. Someone's been using the back route."
role: player_character
access: [public]
@@ -188,7 +188,7 @@ lines:
situation: [routine]
tags: [caution, operational]
- id: smuggling-hold_m_s_018
- id: pc-smuggler_m_s_018
text: "Scratches on the access panel. New ones, over the old ones. Different tool."
role: player_character
access: [public]
@@ -197,7 +197,7 @@ lines:
situation: [routine]
tags: [caution, environmental]
- id: smuggling-hold_m_s_019
- id: pc-smuggler_m_s_019
text: "The temp unit's running warm. Someone moved cargo through here fast."
role: player_character
access: [public]
@@ -208,7 +208,7 @@ lines:
# --- Knowledge-Gated: Kael Arc ---
- id: smuggling-hold_m_s_020
- id: pc-smuggler_m_s_020
text: "Kael used to wait for me here. The one place where we could actually talk."
role: player_character
access: [public]
@@ -222,7 +222,7 @@ lines:
priority: 6
tags: [npc, kael, atmospheric, friend-arc]
- id: smuggling-hold_m_s_021
- id: pc-smuggler_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]
@@ -236,7 +236,7 @@ lines:
priority: 7
tags: [npc, kael, concern, friend-arc]
- id: smuggling-hold_m_s_022
- id: pc-smuggler_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]
@@ -252,7 +252,7 @@ lines:
priority: 9
tags: [npc, kael, investigation, contraband, friend-arc]
- id: smuggling-hold_m_s_023
- id: pc-smuggler_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]
@@ -268,7 +268,7 @@ lines:
priority: 9
tags: [npc, kael, operational, contraband, friend-arc, contaminated-trust]
- id: smuggling-hold_m_s_024
- id: pc-smuggler_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]
@@ -284,7 +284,7 @@ lines:
# --- Post-Conversation ---
- id: smuggling-hold_m_s_025
- id: pc-smuggler_m_s_025
text: "Renn didn't ask questions. That's either loyalty or he already knows."
role: player_character
access: [public]
@@ -293,7 +293,7 @@ lines:
situation: [routine]
tags: [npc, renn, operational]
- id: smuggling-hold_m_s_026
- id: pc-smuggler_m_s_026
text: "One more run. That's what I keep telling myself. Has been for eight months."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: the-last-shift
lines:
# --- Arrival + Environmental Flavor ---
- id: the-last-shift_m_s_001
- id: pc-smuggler_m_s_001
text: "Lera's. Grain spirit and noise. Could use both right now."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: the-last-shift_m_s_002
- id: pc-smuggler_m_s_002
text: "Bar's half full. The after-shift crowd's starting to filter in."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [arrival]
- id: the-last-shift_m_s_003
- id: pc-smuggler_m_s_003
text: "Lera's got the Meridian feed running. Same headlines, different day."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, environmental]
- id: the-last-shift_m_s_004
- id: pc-smuggler_m_s_004
text: "Smells like fried protein and cheap grain spirit. Perfect."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: the-last-shift_m_s_005
- id: pc-smuggler_m_s_005
text: "My booth's free. Small victories."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: enter_location
tags: [arrival, personal]
- id: the-last-shift_m_s_006
- id: pc-smuggler_m_s_006
text: "The overhead light's still buzzing. Lera says it adds character."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: the-last-shift_m_s_007
- id: pc-smuggler_m_s_007
text: "Music's louder tonight. Lera's in a good mood, or drowning something out."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: hear_sound
tags: [environmental, atmospheric]
- id: the-last-shift_m_s_008
- id: pc-smuggler_m_s_008
text: "Back at Lera's. Same booth, same drink, same crowd."
role: player_character
access: [public]
@@ -75,7 +75,7 @@ lines:
trigger: return_visit
tags: [arrival]
- id: the-last-shift_m_s_009
- id: pc-smuggler_m_s_009
text: "The ventilation rattles when the kitchen's on. You learn to talk over it."
role: player_character
access: [public]
@@ -84,7 +84,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: the-last-shift_m_s_010
- id: pc-smuggler_m_s_010
text: "Quiet night at Lera's. Fewer people means fewer ears."
role: player_character
access: [public]
@@ -95,7 +95,7 @@ lines:
# --- NPC Commentary ---
- id: the-last-shift_m_s_011
- id: pc-smuggler_m_s_011
text: "Lera's behind the bar. Good — she keeps things steady."
role: player_character
access: [public]
@@ -104,7 +104,7 @@ lines:
trigger: observe_npc
tags: [npc, lera]
- id: the-last-shift_m_s_012
- id: pc-smuggler_m_s_012
text: "Kael's at the corner table. Looks relaxed. That's something."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
trigger: observe_npc
tags: [npc, kael]
- id: the-last-shift_m_s_013
- id: pc-smuggler_m_s_013
text: "Torek's buying rounds again. That man doesn't know the word 'discreet.'"
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_npc
tags: [npc, torek, caution]
- id: the-last-shift_m_s_014
- id: pc-smuggler_m_s_014
text: "Sera's here. Commission field tech. She keeps to herself — mostly."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_npc
tags: [npc, sera, commission]
- id: the-last-shift_m_s_015
- id: pc-smuggler_m_s_015
text: "Naia's laughing at something Lera said. Nice to see her relaxed."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: observe_npc
tags: [npc, naia]
- id: the-last-shift_m_s_016
- id: pc-smuggler_m_s_016
text: "Old Pael's in his usual spot. Hasn't moved in three hours. Impressive."
role: player_character
access: [public]
@@ -149,7 +149,7 @@ lines:
trigger: observe_npc
tags: [npc, atmospheric]
- id: the-last-shift_m_s_017
- id: pc-smuggler_m_s_017
text: "Lera just cut someone off. Smooth about it, but firm."
role: player_character
access: [public]
@@ -158,7 +158,7 @@ lines:
trigger: witness_interaction
tags: [npc, lera]
- id: the-last-shift_m_s_018
- id: pc-smuggler_m_s_018
text: "Kael and Naia at the bar. Leaning into each other. They look good together."
role: player_character
access: [public]
@@ -167,7 +167,7 @@ lines:
trigger: witness_interaction
tags: [npc, kael, naia]
- id: the-last-shift_m_s_019
- id: pc-smuggler_m_s_019
text: "Voss is drinking alone. Not his usual pace."
role: player_character
access: [public]
@@ -176,7 +176,7 @@ lines:
trigger: observe_npc
tags: [npc, voss]
- id: the-last-shift_m_s_020
- id: pc-smuggler_m_s_020
text: "That new face in the corner — been nursing the same drink for an hour."
role: player_character
access: [public]
@@ -187,7 +187,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: the-last-shift_m_s_021
- id: pc-smuggler_m_s_021
text: "Sera left when Torek walked in. Coincidence? Maybe."
role: player_character
access: [public]
@@ -200,7 +200,7 @@ lines:
min_confidence: suspects
tags: [npc, sera, torek, pattern]
- id: the-last-shift_m_s_022
- id: pc-smuggler_m_s_022
text: "Torek just paid cash credit, not station account. Sloppy."
role: player_character
access: [public]
@@ -213,7 +213,7 @@ lines:
min_confidence: knows_of
tags: [npc, torek, operational]
- id: the-last-shift_m_s_023
- id: pc-smuggler_m_s_023
text: "Lera gave me that look. The one that says 'not here, not now.'"
role: player_character
access: [public]
@@ -226,7 +226,7 @@ lines:
min_confidence: knows_details
tags: [npc, lera, operational]
- id: the-last-shift_m_s_024
- id: pc-smuggler_m_s_024
text: "Naia looks tired. Has Kael been coming home late again?"
role: player_character
access: [public]
@@ -239,7 +239,7 @@ lines:
state: known
tags: [npc, naia, kael, concern]
- id: the-last-shift_m_s_025
- id: pc-smuggler_m_s_025
text: "The detective is here. At the bar. My bar."
role: player_character
access: [public]
@@ -253,7 +253,7 @@ lines:
priority: 8
tags: [investigation, caution]
- id: the-last-shift_m_s_026
- id: pc-smuggler_m_s_026
text: "Sera and the detective sharing a booth. Commission people stick together."
role: player_character
access: [public]
@@ -266,7 +266,7 @@ lines:
min_confidence: knows_of
tags: [npc, sera, investigation]
- id: the-last-shift_m_s_027
- id: pc-smuggler_m_s_027
text: "Kael didn't save my seat tonight. First time in months."
role: player_character
access: [public]
@@ -280,7 +280,7 @@ lines:
priority: 8
tags: [npc, kael, contaminated-trust, friend-arc]
- id: the-last-shift_m_s_028
- id: pc-smuggler_m_s_028
text: "The back room light's on. Nils is here, then."
role: player_character
access: [public]
@@ -293,7 +293,7 @@ lines:
min_confidence: knows_details
tags: [operational, nils]
- id: the-last-shift_m_s_029
- id: pc-smuggler_m_s_029
text: "Kael bought Naia a drink but barely looked at her. That's new."
role: player_character
access: [public]
@@ -306,7 +306,7 @@ lines:
min_confidence: suspects
tags: [npc, kael, naia, tell, friend-arc]
- id: the-last-shift_m_s_030
- id: pc-smuggler_m_s_030
text: "Everyone's a little too quiet tonight. Something happened. I can feel it."
role: player_character
access: [public]
@@ -326,7 +326,7 @@ lines:
# perspective. On replay, these lines gain new meaning when the player
# understands Sera's concealment arc from the detective playthrough.
- id: the-last-shift_m_s_031
- id: pc-smuggler_m_s_031
text: "Sera's at the Commission kiosk. Third time this shift. Nobody recalibrates that much."
role: player_character
access: [public]
@@ -340,7 +340,7 @@ lines:
priority: 6
tags: [npc, sera, commission, pattern, dual-lens]
- id: the-last-shift_m_s_032
- id: pc-smuggler_m_s_032
text: "Naia trusts Sera. More than most people here. That says something."
role: player_character
access: [public]
@@ -349,7 +349,7 @@ lines:
trigger: witness_interaction
tags: [npc, sera, naia, social, dual-lens]
- id: the-last-shift_m_s_033
- id: pc-smuggler_m_s_033
text: "Sera was studying the freight display. For a calibration tech, that's a lot of manifest reading."
role: player_character
access: [public]
@@ -363,7 +363,7 @@ lines:
priority: 6
tags: [npc, sera, tell, cargo, dual-lens]
- id: the-last-shift_m_s_034
- id: pc-smuggler_m_s_034
text: "That's three times Sera's bailed when Torek showed up. Maybe she owes him money."
role: player_character
access: [public]
@@ -377,7 +377,7 @@ lines:
priority: 7
tags: [npc, sera, torek, pattern, dual-lens, replay-payload]
- id: the-last-shift_m_s_035
- id: pc-smuggler_m_s_035
text: "Sera offered the detective compliance records. Helpful -- or professional courtesy?"
role: player_character
access: [public]
@@ -390,7 +390,7 @@ lines:
min_confidence: knows_of
tags: [npc, sera, investigation, caution, dual-lens]
- id: the-last-shift_m_s_036
- id: pc-smuggler_m_s_036
text: "Something about Sera tonight. Same smile, different eyes."
role: player_character
access: [public]
@@ -403,7 +403,7 @@ lines:
state: known
tags: [npc, sera, behavioral, dual-lens]
- id: the-last-shift_m_s_037
- id: pc-smuggler_m_s_037
text: "Commission tech who drinks at a dock bar. Either lonely or embedded. Either way -- careful."
role: player_character
access: [public]
@@ -412,7 +412,7 @@ lines:
trigger: observe_npc
tags: [npc, sera, commission, assessment, dual-lens]
- id: the-last-shift_m_s_038
- id: pc-smuggler_m_s_038
text: "Sera knows the shift rotations better than Voss. That's not calibration. That's attention."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: the-terminal
lines:
# --- Arrival + Environmental Flavor ---
- id: the-terminal_m_s_001
- id: pc-smuggler_m_s_001
text: "Morning shift. Recycled air and cargo lubricant. Home sweet home."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: the-terminal_m_s_002
- id: pc-smuggler_m_s_002
text: "The loading arms are cycling. Freight's on schedule — for once."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [arrival, operational]
- id: the-terminal_m_s_003
- id: pc-smuggler_m_s_003
text: "Shift board's already full. Voss has everyone running double today."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, operational]
- id: the-terminal_m_s_004
- id: pc-smuggler_m_s_004
text: "Cargo lubricant and ozone. You'd think I'd stop noticing."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: the-terminal_m_s_005
- id: pc-smuggler_m_s_005
text: "Loading arm three is grinding again. Someone should file that."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: the-terminal_m_s_006
- id: pc-smuggler_m_s_006
text: "Containers stacked six high in bay four. That's new."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: observe_anomaly
tags: [environmental, operational]
- id: the-terminal_m_s_007
- id: pc-smuggler_m_s_007
text: "The freight manifest board's flickering. Always flickering."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [environmental]
- id: the-terminal_m_s_008
- id: pc-smuggler_m_s_008
text: "Dock lights are on half-cycle. Budget cuts again."
role: player_character
access: [public]
@@ -75,7 +75,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: the-terminal_m_s_009
- id: pc-smuggler_m_s_009
text: "Back at the hub. Shift transition in fifty minutes."
role: player_character
access: [public]
@@ -84,7 +84,7 @@ lines:
trigger: return_visit
tags: [arrival, operational]
- id: the-terminal_m_s_010
- id: pc-smuggler_m_s_010
text: "Quiet morning. Containers moving, nobody talking. I like it this way."
role: player_character
access: [public]
@@ -93,7 +93,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: the-terminal_m_s_011
- id: pc-smuggler_m_s_011
text: "Bay three's been sealed off. Inspection, or something else?"
role: player_character
access: [public]
@@ -102,7 +102,7 @@ lines:
trigger: observe_anomaly
tags: [environmental, caution]
- id: the-terminal_m_s_012
- id: pc-smuggler_m_s_012
text: "The conveyor hums at a different pitch when it's loaded heavy. This one's heavy."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
# --- NPC Commentary ---
- id: the-terminal_m_s_013
- id: pc-smuggler_m_s_013
text: "Kael's already at the dock. Good. The day's better when he's on shift."
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_npc
tags: [npc, kael, warm]
- id: the-terminal_m_s_014
- id: pc-smuggler_m_s_014
text: "Voss is pacing. Never a good sign."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_npc
tags: [npc, voss, caution]
- id: the-terminal_m_s_015
- id: pc-smuggler_m_s_015
text: "Maret's got her scheduling face on. Tight lips, fast hands on the terminal."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: observe_npc
tags: [npc, maret]
- id: the-terminal_m_s_016
- id: pc-smuggler_m_s_016
text: "New face at dock seven. Temp worker, or someone I should know about?"
role: player_character
access: [public]
@@ -149,7 +149,7 @@ lines:
trigger: observe_npc
tags: [npc, caution]
- id: the-terminal_m_s_017
- id: pc-smuggler_m_s_017
text: "Drin's at inspection again. Going through the motions, same as always."
role: player_character
access: [public]
@@ -158,7 +158,7 @@ lines:
trigger: observe_npc
tags: [npc, drin]
- id: the-terminal_m_s_018
- id: pc-smuggler_m_s_018
text: "Renn's running late. Third time this week."
role: player_character
access: [public]
@@ -167,7 +167,7 @@ lines:
trigger: observe_npc
tags: [npc, renn, operational]
- id: the-terminal_m_s_019
- id: pc-smuggler_m_s_019
text: "Voss and Maret talking by the schedule board. Both look tense."
role: player_character
access: [public]
@@ -176,7 +176,7 @@ lines:
trigger: witness_interaction
tags: [npc, voss, maret]
- id: the-terminal_m_s_020
- id: pc-smuggler_m_s_020
text: "Kael nodded at me across the bay. Business as usual."
role: player_character
access: [public]
@@ -185,7 +185,7 @@ lines:
trigger: observe_npc
tags: [npc, kael]
- id: the-terminal_m_s_021
- id: pc-smuggler_m_s_021
text: "Someone I don't recognize talking to Drin. Drin's smiling. Drin never smiles."
role: player_character
access: [public]
@@ -194,7 +194,7 @@ lines:
trigger: witness_interaction
tags: [npc, drin, anomaly]
- id: the-terminal_m_s_022
- id: pc-smuggler_m_s_022
text: "Torek's on the dock. Shouldn't be — his shift ended two hours ago."
role: player_character
access: [public]
@@ -203,7 +203,7 @@ lines:
trigger: observe_anomaly
tags: [npc, torek, caution]
- id: the-terminal_m_s_023
- id: pc-smuggler_m_s_023
text: "Nils isn't here yet. That's unusual."
role: player_character
access: [public]
@@ -212,7 +212,7 @@ lines:
trigger: observe_anomaly
tags: [npc, nils, operational]
- id: the-terminal_m_s_024
- id: pc-smuggler_m_s_024
text: "Kael's eating alone today. He always eats with me."
role: player_character
access: [public]
@@ -223,7 +223,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: the-terminal_m_s_025
- id: pc-smuggler_m_s_025
text: "Kael keeps checking his lattice. Waiting for a message? Not like him to be jumpy."
role: player_character
access: [public]
@@ -237,7 +237,7 @@ lines:
priority: 7
tags: [npc, kael, tell, friend-arc]
- id: the-terminal_m_s_026
- id: pc-smuggler_m_s_026
text: "Container 4471's been in temp since yesterday. That's not the normal routing."
role: player_character
access: [public]
@@ -250,7 +250,7 @@ lines:
min_confidence: knows_details
tags: [operational, contraband]
- id: the-terminal_m_s_027
- id: pc-smuggler_m_s_027
text: "Voss changed the rotation again. Third time this month. Covering something."
role: player_character
access: [public]
@@ -263,7 +263,7 @@ lines:
min_confidence: knows_of
tags: [npc, voss, operational]
- id: the-terminal_m_s_028
- id: pc-smuggler_m_s_028
text: "That detective's back. Asking questions near bay four. Stay calm."
role: player_character
access: [public]
@@ -277,7 +277,7 @@ lines:
priority: 8
tags: [investigation, caution]
- id: the-terminal_m_s_029
- id: pc-smuggler_m_s_029
text: "Maret hasn't looked at me all shift. She noticed something. I know she did."
role: player_character
access: [public]
@@ -292,7 +292,7 @@ lines:
priority: 6
tags: [npc, maret, paranoia]
- id: the-terminal_m_s_030
- id: pc-smuggler_m_s_030
text: "Shift transition in ten. If the route's clean, we move tonight."
role: player_character
access: [public]
@@ -305,7 +305,7 @@ lines:
min_confidence: knows_details
tags: [operational, contraband]
- id: the-terminal_m_s_031
- id: pc-smuggler_m_s_031
text: "Kael was in corridor B-7 last night. Off-shift. Off-route. That's not nothing."
role: player_character
access: [public]
@@ -319,7 +319,7 @@ lines:
priority: 8
tags: [npc, kael, contradiction, friend-arc]
- id: the-terminal_m_s_032
- id: pc-smuggler_m_s_032
text: "Who was Kael talking to? Not anyone from our rotation. I'd know the face."
role: player_character
access: [public]
@@ -333,7 +333,7 @@ lines:
priority: 9
tags: [npc, kael, contradiction, friend-arc]
- id: the-terminal_m_s_033
- id: pc-smuggler_m_s_033
text: "He looked left before answering. He always looks left when he's lying."
role: player_character
access: [public]
@@ -347,7 +347,7 @@ lines:
priority: 9
tags: [npc, kael, tell, friend-arc]
- id: the-terminal_m_s_034
- id: pc-smuggler_m_s_034
text: "Torek's spending too much at Lera's. Someone's going to notice."
role: player_character
access: [public]
@@ -360,7 +360,7 @@ lines:
min_confidence: knows_of
tags: [npc, torek, operational, caution]
- id: the-terminal_m_s_035
- id: pc-smuggler_m_s_035
text: "Same old Kael. Same jokes, same routine. ...What are you not telling me?"
role: player_character
access: [public]
@@ -0,0 +1,229 @@
character: smuggler
location: tutorial
lines:
# Diegetic Tutorial Monologue — Ticket #330 — Smuggler
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
#
# These lines teach mechanics through character voice — not UI instructions.
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
# These fire on first-time events throughout gameplay, wherever they occur.
#
# Coverage:
# Movement / exploration — first_move, first_new_section
# Fog of perception — first_fog_encounter, first_fog_edge
# Sound model — first_sound_heard, first_off_screen_sound
# NPC interaction — first_npc_proximity, first_npc_face_read
# Insert / HUD — first_insert_open, first_insert_contact_flag
#
# Voice: observational, street-smart, practical. Fragments. Standalone "Good." as tic.
# No analytical distance — the smuggler reads, reacts, files, moves on.
# ID discriminator: _tut_ per sprint briefing.
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
# fire once per game), not by a cooldown value.
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
#
# Schema corrections (Gestalt review, Sprint 17):
# priority: string "tutorial" → integer. All other monologue files use integer priority;
# string value would fail engine parse in monologue.rs.
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
# enum. Replaced with arrival, routine, observation per actual trigger context.
# mood: [focused] retained where set; valid per D-035 Sprint 14 amendment.
#
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
# D-035 v0.1 set — require server-side implementation in monologue.rs.
# -----------------------------------------------------------------------
# MOVEMENT / EXPLORATION
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_001
text: "These corridors all look the same. Mental note: check the signage."
role: player_character
access: [public]
trust: surface
situation: [arrival, routine]
trigger: first_move
mood: [focused]
priority: 8
tags: [tutorial, movement, navigation]
notes: >
Directly from the sprint briefing example. First move teaches navigation: the district
looks homogeneous, signage is the tool. 'Mental note' is the smuggler's internal
flag for things to remember — operational housekeeping, not analysis. Establishes
early: this environment requires active spatial tracking.
- id: pc-smuggler_m_s_tut_002
text: "Two lefts past the junction, right at the supply hatch. That's dock four. Route's in the muscle now."
role: player_character
access: [public]
trust: surface
situation: [arrival, routine]
trigger: first_new_section
mood: [content]
priority: 6
tags: [tutorial, movement, navigation, operational]
notes: >
Fires on entering a new section for the first time. 'Route's in the muscle' — the
smuggler doesn't memorize spatially, she moves until it's automatic. Practical,
self-sufficient. Teaches: the district has multiple sections, each requires its own
orientation. 'Dock four' is a landmark. Forward movement is rewarded with familiarity.
# -----------------------------------------------------------------------
# FOG OF PERCEPTION
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_003
text: "Can't see past that corner. Might be worth checking."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_encounter
mood: [focused]
priority: 8
tags: [tutorial, fog, perception]
notes: >
Directly from the sprint briefing example. First fog boundary. Restated as habit:
the smuggler doesn't treat the fog as a limitation, she treats every unknown corner
as a question worth answering. 'Might be worth' — not paranoia, just operational
instinct. Teaches: the perception boundary is navigable, not fixed.
- id: pc-smuggler_m_s_tut_004
text: "Haze cuts off past the junction. Anything in it — can't say."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_edge
mood: [focused]
priority: 6
tags: [tutorial, fog, perception]
notes: >
Second fog encounter line — fires when the player reaches the boundary of a fog zone
(the edge of visible range). 'Can't say' — plain statement of unknowing, not anxiety.
The smuggler accepts incomplete information as a working condition, not a failure state.
Teaches: perception range is an ongoing factor, not a one-time obstacle.
# -----------------------------------------------------------------------
# SOUND MODEL
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_005
text: "Voices down the hall. Can't make out the words from here."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_sound_heard
mood: [focused]
priority: 8
tags: [tutorial, sound, perception]
notes: >
Directly from the sprint briefing example. First sound heard that's not immediately
visible. Teaching: sound carries information, but range and occlusion limit it.
'Can't make out the words' establishes both the value of the sound channel (voices =
people = relevant) and its limitation (unclear at range). Moving toward source is
the implied next step.
- id: pc-smuggler_m_s_tut_006
text: "Footsteps. Two sets. Moving away — good."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_off_screen_sound
mood: [content]
priority: 6
tags: [tutorial, sound, perception, operational]
notes: >
Second sound line — fires when audio from an off-screen source is detected. The
smuggler parses footsteps as count and direction instinctively. 'Moving away — good.'
— the standalone 'Good.' tic applied to tactical assessment. Teaches: sound carries
directional and movement data, not just presence. Two sets = two people, which is
specific enough to be operationally useful.
# -----------------------------------------------------------------------
# NPC INTERACTION
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_007
text: "Could ask around. People talk if you give them reason to."
role: player_character
access: [public]
trust: surface
situation: [social, observation]
trigger: first_npc_proximity
mood: [content]
priority: 8
tags: [tutorial, npc, interaction, social]
notes: >
Directly from the sprint briefing example. First time in range of an NPC. The smuggler
doesn't approach directly — she flags the option. 'Give them reason to' is street-smart
social knowledge: people don't volunteer freely, they need a hook. Teaches: NPC
interaction is player-initiated, and approach matters. No mechanics text — just the
character's own method stated as personal principle.
- id: pc-smuggler_m_s_tut_008
text: "Know the face. Don't need the name."
role: player_character
access: [public]
trust: surface
situation: [social, observation]
trigger: first_npc_face_read
mood: [content]
priority: 6
tags: [tutorial, npc, observation, social]
notes: >
Fires when the player observes an NPC at close range (face visible). The smuggler
reads people by face, not by name — the name comes later if relevant. Teaches: NPCs
are identifiable by observation, and the insert overlay will fill in names when
available. The smuggler's social intelligence is pre-lattice, instinctive.
# -----------------------------------------------------------------------
# INSERT / HUD
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_009
text: "Check the overlay. Should show the time and nearby contacts."
role: player_character
access: [public]
trust: surface
situation: [routine]
trigger: first_insert_open
mood: [content]
priority: 8
tags: [tutorial, insert, hud, operational]
notes: >
Directly from the sprint briefing example. First insert/HUD open. The smuggler's
overlay shows time and nearby contacts — both operationally critical. 'Should show'
— practical, not certain. Teaches: the insert is the primary tool for time-tracking
and NPC awareness. Diegetic: she checks it the same way she checks a shift board.
- id: pc-smuggler_m_s_tut_010
text: "Overlay marks Kael two sections over. Good."
role: player_character
access: [public]
trust: surface
situation: [routine, social]
trigger: first_insert_contact_flag
mood: [content, warm]
priority: 6
tags: [tutorial, insert, hud, kael, friend-arc]
notes: >
Fires when the insert first flags a known contact (here: Kael, the FRIEND NPC).
'Two sections over' — spatial data from the overlay. 'Good.' standalone tic —
warm, settling. Teaches: the insert shows known NPC positions in real-time.
Also seeds the Kael relationship: the smuggler checks his location as a habit,
not a task. His position is always relevant to her.
+96 -4
View File
@@ -7,6 +7,7 @@ Direct DB access only for sprint lifecycle mutations.
Usage:
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
sprint start [--sprint N] Activate a planned sprint
sprint stop [--sprint N] Complete an active sprint
sprint start-work [--sprint N] [--team T] Full context dump for starting work
@@ -503,6 +504,95 @@ def cmd_start_work(args):
print(REMINDER)
def cmd_sweep(args):
"""Health check: grouped tickets, bookkeeping issues, team summary (JSON)."""
flags, _ = parse_flags(args, ["sprint"])
sprint = detect_sprint(flags, prefer_status="active")
tickets = get_tickets_for_sprint(sprint["id"])
# Build dependency map for blocked detection
blocked_by_map = {} # ticket_id -> [blocker_id, ...]
for t in tickets:
if t["status"] == "done":
continue
deps = get_ticket_deps(t["id"])
open_blockers = [b["id"] for b in deps.get("blocked_by", []) if b["status"] != "done"]
if open_blockers:
blocked_by_map[t["id"]] = open_blockers
# Group by status
by_status = {"done": [], "review": [], "in_progress": [], "blocked": [], "backlog": []}
for t in tickets:
entry = {
"id": t["id"],
"title": t["title"],
"team": t.get("team") or "unassigned",
"assigned_to": t.get("assigned_to"),
}
if t["status"] == "done":
by_status["done"].append(entry)
elif t["id"] in blocked_by_map:
entry["blocked_by"] = blocked_by_map[t["id"]]
by_status["blocked"].append(entry)
elif t["status"] == "review":
by_status["review"].append(entry)
elif t["status"] == "in_progress":
by_status["in_progress"].append(entry)
else:
by_status["backlog"].append(entry)
# Per-team summary
by_team = {}
for status, items in by_status.items():
for item in items:
team = item["team"]
if team not in by_team:
by_team[team] = {"backlog": 0, "in_progress": 0, "review": 0, "blocked": 0, "done": 0, "total": 0}
by_team[team][status] = by_team[team].get(status, 0) + 1
by_team[team]["total"] += 1
# Bookkeeping issues
issues = []
for t in tickets:
if t["status"] in ("in_progress", "review") and not t.get("assigned_to"):
issues.append({
"type": "unassigned_in_progress",
"detail": f"#{t['id']} unassigned {t['status']}",
"fix": f"db/connectors/ticket assign {t['id']} <agent>",
})
if t["status"] == "backlog" and sprint["status"] == "active" and t["id"] not in blocked_by_map:
issues.append({
"type": "stale_backlog",
"detail": f"#{t['id']} stale backlog",
"fix": f"db/connectors/ticket status {t['id']} in_progress",
})
if t["status"] == "done" and t.get("assigned_to"):
issues.append({
"type": "assigned_but_done",
"detail": f"#{t['id']} done, still assigned",
"fix": f"db/connectors/ticket unassign {t['id']}",
})
# Progress
total = len(tickets)
done = len(by_status["done"])
pct = int(done / total * 100) if total > 0 else 0
result = {
"sprint": {
"id": sprint["id"],
"name": sprint.get("name", f"Sprint {sprint['id']}"),
"goal": sprint.get("goal", ""),
},
"progress": {"total": total, "done": done, "pct": pct},
"by_status": by_status,
"by_team": by_team,
"issues": issues,
}
print(json.dumps(result, indent=2))
def cmd_prepare(args):
flags, _ = parse_flags(args, ["sprint", "team"])
sprint = detect_sprint_for_prepare(flags)
@@ -606,16 +696,17 @@ HELP = """sprint \u2014 sprint lifecycle and context for agents
Usage:
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
sprint start [--sprint N] Activate a planned sprint
sprint stop [--sprint N] Complete an active sprint
sprint start-work [--sprint N] [--team T] Full context dump for starting work
sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps)
Sprint auto-detection:
status/start-work prefer the active sprint
start prefer the planning sprint
stop prefer the active sprint
prepare target next sprint (max id + 1)
status/start-work/sweep prefer the active sprint
start prefer the planning sprint
stop prefer the active sprint
prepare target next sprint (max id + 1)
Team auto-detection:
If --team is omitted, uses the current git branch name (unless on main).
@@ -632,6 +723,7 @@ def main():
commands = {
"status": cmd_status,
"sweep": cmd_sweep,
"start": cmd_start,
"stop": cmd_stop,
"start-work": cmd_start_work,
+49 -1
View File
@@ -398,6 +398,54 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Raised by:** Lead (passive panel directive, occlusion filter, monologue separation)
- **Dissent:** None
### D-079: Knowledge Grant Architecture
- **Date:** 2026-02-24
- **Decision:** All knowledge input (dialogue testimony, physical evidence, POI discovery, NPC-authored initial knowledge) flows through a single `KnowledgeEventType::KnowledgeGranted` event type. No separate event types for different grant sources. The `KnowledgeGrant` YAML schema is extended to an untagged enum supporting `Fact { fact_id, confidence }` and `Entity { entity_ref, attributes, confidence }` variants. The source field (`KnowledgeSource`) distinguishes provenance: `ToldBy { source_id: StableId, tick }` for NPC testimony, `DirectObservation { tick }` for physical evidence, `Heard { tick, range }` for overheard conversations. Grants fire at line selection time in `process_talk_interaction`, server-side, pushed to `KnowledgeEventQueue` and processed on the same tick. A `ContentEntityRegistry` resource (`BTreeMap<String, StableId>`, populated at NPC spawn time) resolves `entity_ref` strings to `StableId`s at grant processing time. Content-load validation enforces: confidence strings parse to valid `KnowledgeConfidence` variants; fact_ids conform to `"category.topic"` format; entity_refs resolve to registered StableIds. Runtime guardrail: if the granting NPC's KG does not contain the fact being granted, the grant is dropped with `tracing::warn!`.
- **Scope note — `Compound` grant variant:** `Compound { grants: Vec<KnowledgeGrant> }` (multiple grants from a single line) deferred to Sprint 18. Sprint 17 ships `Fact` and `Entity` variants only. Sprint 18 `EntityGrant { attributes: BTreeMap }` full variant is committed scope, not aspirational (Paula condition, 2026-02-24).
- **Rationale:** Unified event type keeps the knowledge system composable. Grant timing at line selection (not client display) ensures D-010 determinism — tick-stamped and server-authoritative. Entity grants are required for contradiction detection: testimony must create `EntityKnowledge` with `ToldBy` source so that a subsequent `DirectObservation` can detect a discrepancy. Physical evidence uses the same mechanism with `DirectObservation` source, which is treated as higher-confidence and cannot be contradicted by the NPC-denial path.
- **Raised by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous on grant timing and unified event type. Entity grants in Sprint 17 per team lead decision (2026-02-24), overriding Paula's Round 2 acceptance of Dudley's FactId workaround.
- **Dissent:** Paula (Round 2) accepted the FactId workaround with 3 binding conditions; team lead overrode in favour of entity grants path championed by Tyre, Gestalt, and Dudley. Paula's conditions honoured where applicable: (1) `ToldBy` source flows through `ContradictionDetected` event payload — satisfied by D-083; (2) Sprint 18 entity grants are committed scope; (3) formal record of this commitment.
- **Implements:** Tickets #545 (schema), #546 (event + handler)
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline), [D-083](#d-083-contradiction-detection-pipeline)
### D-080: NPC-to-NPC Knowledge Propagation
- **Date:** 2026-02-24
- **Decision:** NPC-to-NPC knowledge transfer is implemented via a `transfer_npc_knowledge` Bevy system that runs `after(run_npc_conversations)`. The system uses `kg_query.get_many_mut([entity_a, entity_b])` for dual-mutable KG access. Knowledge transfer occurs once per conversation at conversation start, not per-line. Rate: flat `rng.random_range(1..=3)` facts per conversation, drawn from eligible entries sorted by `last_updated_tick` descending (most recent first). Trust-gated filtering: `RelationshipEdge.trust` value determines eligible entries (None tier < 0: no transfer; Surface 0–3: Active facts at KnowsOf+ only; Real 3–7: facts at any confidence + entity observations; Secret 7–10: all Active entries). Confidence cap: `transferred_confidence = min(source_confidence, KnowledgeConfidence::KnowsOf)`. Facts with `disclosure_blocked: true` are never transferred regardless of trust tier. Source construction: `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: time.tick }`. Player overhearing: when the player is within `VOICE_RANGE_TILES` (8) of a knowledge-transferring NPC pair, the player gains entity-level KG entries at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: Medium }`. Specific overheard fact transfer to player deferred to Sprint 18.
- **Rationale:** Reuses `run_npc_conversations` proximity detection, deterministic StableId-sorted pairing, and conversation lifecycle — avoids building a separate propagation system. Confidence capping at `KnowsOf` prevents gossip chains from amplifying information. `ToldBy` source construction is the prerequisite for contradiction detection. The `disclosure_blocked` flag models secrets whose sharing is existentially dangerous regardless of trust (D-034 NPC design requirement).
- **Resolves:** Q-024
- **Raised by:** Workshop — unanimous
- **Dissent:** None
- **Implements:** Ticket #548
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)
### D-081: Unprompted Disclosure Design
- **Date:** 2026-02-24
- **Decision:** Unprompted disclosure is implemented as a filtered KG query producing a `DisclosureCandidates` component, consumed by Layer 4 of the dialogue pipeline. The system runs as `derive_disclosure_candidates` (Active-tier NPCs within player range only; recomputed every 30 ticks via `computed_tick` freshness check). NPCs check their own KG only — no cross-entity KG queries (D-010 principle 2). Candidate selection filters: Active state only; confidence ≥ KnowsOf (Cautious trait raises threshold); not in `per_fact_history` (not already disclosed this cooldown period); `disclosure_blocked != true`. Trait two-stage filter: Stage 1 (what) filters candidate pool by trait-based predicates (Cautious raises confidence floor, Gossipy lowers it, Loyal suppresses facts about protected entities, Talkative overrides witness inhibition). Stage 2 (how) biases line pool scoring in Layer 4. Trigger gates (all must pass): trust tier ≥ Surface toward player; mood not Hostile; contentment ≥ −10; candidates not empty; witness inhibition (no non-trusted NPCs within 5 tiles, OR NPC-player trust = Secret tier, OR Talkative trait override); location privacy (`disclosure_context` tag compatible with current zone type: `private`/`semi_private`/`any`); per-NPC cooldown (300 ticks / 30 game-minutes) not active; global rate limit not exceeded. Global rate limit: 1 disclosure per 10 ticks maximum across all NPCs; when multiple candidates ready in same window, select by ascending StableId (deterministic, D-010). Rate limiting layers: (1) per-fact per-NPC `per_fact_history` — primary; (2) per-NPC 300-tick cooldown — secondary; (3) global 1/10-tick cap — tertiary.
- **Rationale:** NPCs checking only their own KG (Option A) is both diegetically correct and mechanically richer: NPCs can say things the player already knows, which creates dramatic irony. Option B (NPC checks player's KG) would collapse this asymmetry at exactly the moments their speech would be most dramatically charged. Separate `DisclosureCandidates` component prevents DerivedTellState serialization bloat. The location privacy gate creates learnable spatial behavior patterns. The per-fact `per_fact_history` is the primary narrative quality gate.
- **Raised by:** Workshop — unanimous on architecture; Paula and Gestalt on trigger gates; Tyre and Dudley on implementation structure
- **Dissent:** Gestalt (Round 1) opposed global rate limit as creating invisible NPC competition. Resolved in Round 2 via deterministic StableId selection. Included.
- **Implements:** Tickets #551, #172, #173
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-082](#d-082-npc-information-boundaries--mvp-scope), [D-034](content.md#d-034-the-friend-npc-archetype)
### D-082: NPC Information Boundaries — MVP Scope
- **Date:** 2026-02-24
- **Decision:** Sprint 17 MVP information boundary scope: (1) `tell_state.rs` reads NPC relationship state from KG for OTHER-entity state (replaces direct `relationships.entries` read for Friendly tell derivation); self-axis components (Secret, Contentment, Tolerance, Mood) continue as ground-truth reads. (2) Unprompted disclosure (D-081) uses KG for candidate selection. No other system retrofits in Sprint 17. `derive_tell_state` adds `Option<&KnowledgeGraph>` to query; `None` falls through to current axis-based behavior (existing tests continue passing). All KG-driven behavior scoped to `With<ActiveSim>` — Background-tier NPCs receive no KG-based boundary changes. Deferred: conversation partner KG-awareness check (Sprint 18 candidate, `kg.knows_entity(&partner_sid)` in pairing loop); routine KG awareness (v0.2+); pathfinding KG integration (not planned — failure mode has no safe recovery). Fallback on missing KG entry: fall through to ground truth with `tracing::debug!` structured logging.
- **Rationale:** The minimum viable boundary produces maximum gameplay-visible difference for minimum risk. Pathfinding from KG has an unresolvable failure mode (NPC forgets path nodes = stuck NPCs = undefined movement behavior) and must not be implemented. Self-knowledge always comes from axis components because the NPC is the continuous observer of its own state. The `Option<&KnowledgeGraph>` addition is backward-compatible.
- **Raised by:** Workshop — unanimous
- **Dissent:** None. Paula adds that `tell_state` should eventually incorporate KG-derived secret-exposure intensity — deferred to a future sprint as enhancement.
- **Implements:** Tickets #549 (step 1), #551 (step 2), ticket #142
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-081](#d-081-unprompted-disclosure-design), [D-026](architecture.md#d-026-npc-simulation-tier-system)
### D-083: Contradiction Detection Pipeline
- **Date:** 2026-02-24
- **Decision:** Contradiction detection is event-driven at KG write time — fires inside `observe_entity()` (`server/src/knowledge/graph.rs`) before overwriting an existing entry. Detection algorithm: if the existing entry has `source: ToldBy { tick: told_tick }` AND `last_known_position` differs from incoming position AND `|current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS` (default 600; configurable per contradiction type), then: (1) `entry.state = KnowledgeState::Contradicted`; (2) `entry.contradicted_claim = Some(ContradictionClaim { source: entry.source.clone(), claimed_position: entry.last_known_position, detected_at_tick: current_tick })`; (3) proceed to write new observation. A `ContradictionDetected { observer, entity_sid, source_display_name: Option<String>, subject_display_name: Option<String> }` event is pushed; display names are resolved at detection time via `EntityRegistry + NpcName` so the monologue system is a pure string consumer. `EntityKnowledge` gains one new field: `pub contradicted_claim: Option<ContradictionClaim>` with `#[serde(skip_serializing_if = "Option::is_none")]` and `#[serde(default)]` — backward-compatible, zero migration required. Downstream event chain: `ContradictionDetected` → monologue system (named contradiction line) → relationship shift (`ToldBy` source entity → `PersonOfInterest`) → D-033 amber via existing pipeline → `AnomalyMarker` via `detect_anomalies` (already tested). Both the `ToldBy` entry and the `DirectObservation` entry receive `Contradicted` state (epistemic neutrality). Location contradiction is automatic (Sprint 17). Attribute contradiction uses content-authored YAML pairs (Sprint 18). Fact contradiction uses content-authored `contradicts` field on KnowledgeGrant entries (Sprint 18). `CONTRADICTION_WINDOW_TICKS = 600` (1 game-hour) as default constant, with content-authored override per claim type.
- **Rationale:** Event-driven detection avoids per-tick KG scan overhead (99% of ticks have no new information). The `ContradictionClaim` struct (typed) was chosen over string encoding in `known_attributes`: the struct is accessed with direct field reads (no string parsing in the detection hot path); the monologue system needs a typed `KnowledgeSource` to resolve the source entity's name; string encoding in `known_attributes` mixes semantic metadata with internal bookkeeping; one optional struct field is additive (no breaking changes, no migration). Steps 2–5 of the downstream chain (anomaly marker, D-033 color, relationship shift, monologue selection) are already implemented and passing tests. Contradiction detection is the missing link that enables THE FRIEND arc.
- **Resolves:** Q-026
- **Raised by:** Workshop — unanimous on architecture and downstream chain; Tyre and Gestalt on struct approach; Dudley conceded Option B (string encoding).
- **Dissent:** Dudley (Round 1): Option B string encoding as Sprint 17 workaround. Conceded in Round 2. No standing dissent.
- **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain)
- **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation)
---
*32 decisions. Last updated: 2026-02-20 (D-061: amended with unified conversation log architecture from Sprint 14 #535)*
*37 decisions. Last updated: 2026-02-24 (D-079–D-083: Knowledge Flow & NPC Information Boundaries Workshop)*
+22 -17
View File
@@ -131,26 +131,23 @@ Tracked questions awaiting discussion or resolution.
- **Source:** Architecture Review Audit 2026-02-11
### Q-024: Gossip propagation timing
- **Status:** Open (preferred direction: queued)
- **Question:** When does NPC-to-NPC knowledge transfer occur? Two options: (1) Immediate during conversation — knowledge transfers instantly when two NPCs talk, more reactive but harder to predict. (2) Queued for routine intersection — knowledge transfers at scheduled meeting points (bar visits, shift changes), more predictable and player can exploit timing windows.
- **Preferred direction:** Queued approach aligns better with core gameplay. If gossip propagates at routine intersections, the player can observe NPCs meeting and predict information flow, time actions between intersection points to exploit windows of ignorance, and strategically attend/skip routine events to control what they overhear. Creates the "I need to be at the bar before shift change to see who talks to whom" mechanic. Immediate propagation makes gossip invisible and unpredictable; queued propagation makes it observable and exploitable, which is the design goal (D-010 principle 2, D-028 Layer 4).
- **Context:** Deferred to Sprint 3. Making this decision now without implementation experience would be premature. Gossip propagation is not in Sprint 2 scope (D-041).
- **Assigned to:** Gestalt, Tyre
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt Round 1)
- **Status:** Resolved → [D-080](perception.md#d-080-npc-to-npc-knowledge-propagation)
- **Resolution:** Knowledge transfer occurs via a separate `transfer_npc_knowledge` Bevy system running `after(run_npc_conversations)`. Transfer fires once per conversation at conversation start (immediate during conversation, not queued). Rate: 1–3 facts drawn by recency. Trust-tier gated. See D-080 for full specification.
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous. 2026-02-24.
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2.
### Q-025: Knowledge graph cap and eviction strategy
- **Status:** Open
- **Question:** At what point does an NPC's knowledge graph need entry eviction? What is the eviction policy? Options: MAX_ENTITY_KNOWLEDGE constant (e.g., 100 entities), LRU by `last_observed_tick`, lowest confidence first, hybrid approach. How are evicted entries handled — complete removal, or archival to "forgotten" state that can be refreshed?
- **Context:** Memory budget analysis shows ~14 KB per NPC with 50 entities + 20 facts. At 80 Active NPCs this is ~1.1 MB, well within budget. However, long-running sessions or NPCs with high interaction rates could accumulate entries. Eviction policy affects gameplay: forgetting low-confidence rumors creates natural information decay; forgetting old observations simulates memory limits.
- **Assigned to:** Tyre, Dudley
- **Source:** Knowledge Graph & Information Boundaries Workshop (Dudley Round 1, section 8.3)
- **Status:** Resolved — no cap or eviction needed for v0.1/v0.2. Re-evaluation trigger: Active NPC count > 200 OR KG memory exceeds 50 MB.
- **Question:** At what point does an NPC's knowledge graph need entry eviction? What is the eviction policy?
- **Resolution (2026-02-24, confirmed by Knowledge Flow workshop):** Current analysis: ~14 KB per Active NPC KG (50 entities + 20 facts, D-041 budget). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries = ~5 MB. Total ~6 MB. With gossip propagation shipping in Sprint 17 (D-080, 1–3 facts per conversation): estimated ~12 MB peak at current NPC counts. Neither re-evaluation condition expected before v0.3. The existing decay system (`decay_knowledge` in `knowledge/events.rs`) downgrades confidence and marks entries Stale but does not remove them — correct behavior (preserves "I used to know X" for narrative). If eviction becomes necessary, simplest policy: on each decay pass, if entities.len() > MAX_ENTITIES, remove Stale entries with lowest last_updated_tick. BTreeMap makes this O(N).
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — Tyre, Gestalt, Dudley confirmed; Paula non-objection noted. 2026-02-24.
- **Source:** Knowledge Graph & Information Boundaries Workshop (Dudley Round 1, section 8.3). Architecture audit 2026-02-23. Workshop confirmation 2026-02-24.
### Q-026: Contradiction detection algorithm
- **Status:** Open
- **Question:** How exactly does the system detect that two knowledge entries contradict each other? Options: (1) Content-authored contradiction pairs (explicit markup in content files: "fact A contradicts fact B"), (2) Automatic same-subject different-value detection (algorithmic comparison of EntityKnowledge fields), (3) Hybrid (author-marked contradictions plus automatic location/state conflicts). How granular should contradiction detection be? Entity location only, or also attributes, relationships, facts?
- **Context:** THE FRIEND arc (D-034, D-039 wow moment #3) requires contradiction detection for emotional impact. The canonical example: Sera tells detective "Kael was at the dock during second shift" (ToldBy source), then detective observes Kael in corridor B-7 at that time (DirectObservation source). System must detect location contradiction, set both entries to Contradicted state, emit monologue event, and shift relationship to PersonOfInterest.
- **Assigned to:** Gestalt, Paula, Dudley
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1)
- **Status:** Resolved → [D-083](perception.md#d-083-contradiction-detection-pipeline)
- **Resolution:** Event-driven detection at KG write time in `observe_entity()`, using `ContradictionClaim` struct. Location contradiction is automatic (Sprint 17): position comparison + time window (CONTRADICTION_WINDOW_TICKS = 600). Attribute and fact contradiction are content-authored (Sprint 18). Both ToldBy and DirectObservation entries receive Contradicted state (epistemic neutrality). `ContradictionDetected` event → monologue with resolved display names → relationship shift → AnomalyMarker via existing pipeline.
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous on architecture. 2026-02-24.
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2.
### Q-027: Fast-travel system design
- **Status:** Open
@@ -163,6 +160,14 @@ Tracked questions awaiting discussion or resolution.
- **Assigned to:** Gestalt, Paula, Tyre
- **Source:** Sprint 10 PR review discussion (2026-02-19)
### Q-028: Collision-resistant line IDs for auto-generated NPCs
- **Status:** Open
- **Question:** The D-035 NPC-scoped line ID scheme uses NPC slugs as prefix (`kael-davan_d_001`). Hand-authored NPCs have unique slugs, but auto-generated populations (D-029: hundreds of NPCs) will produce collisions when the generator creates multiple NPCs with the same role slug (e.g., two `dock-worker` NPCs). What collision-resistance mechanism should be used? Options: (1) Short UUID/hash suffix on auto-gen slugs (`dock-worker-a7f3_d_001`), (2) Entity UUID as prefix, (3) Slug registry that guarantees uniqueness at generation time, (4) Composite key (entity ID + sequence) in server, human-readable slug only for authored content.
- **Constraints:** Line IDs must be globally unique across entire save file lifetime (history log readiness). Must stay human-readable for hand-authored content. Server treats IDs as opaque strings — solution lives in content/generation layer. Must be compatible with D-035 NPC-scoped namespace.
- **Ticket:** #544
- **Assigned to:** Gestalt, Tyre
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
---
*27 questions (3 resolved, 2 partially resolved, 22 open). Last updated: 2026-02-19*
*28 questions (6 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-24*
+20 -20
View File
@@ -113,7 +113,7 @@ lines:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `topic` | list\<enum\> | no | `[]` | Topic tags for Layer 4 weighted selection. Lines without topic tags are eligible for any topic context. Values: `colleague`, `routine`, `cargo`, `money`, `trust`, `danger`, `institution`, `personal`, `investigation`. |
| `mood` | list\<enum\> | no | `[]` | Mood tags for Layer 4 weighted selection. Lines without mood tags are eligible for any mood context. Values: `fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`. |
| `mood` | list\<enum\> | no | `[]` | Mood tags for Layer 4 weighted selection. Lines without mood tags are eligible for any mood context. Values: `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`. |
| `tags` | list\<string\> | no | `[]` | Freeform escape hatch. Not consumed by the filtering engine — used for author organization, content queries, and the line previewer. No validation on values. |
#### Knowledge grant (optional)
@@ -157,33 +157,33 @@ All lines in pool
location: the-last-shift
role: dock-worker
lines:
- id: the-last-shift_d_001
- id: kael-davan_d_015
text: "Saved you a seat. Lera's got the spiced rice tonight."
role: dock-worker
access: [insider, peer]
trust: surface
situation: [bar_evening, social, arrival]
mood: [fond]
mood: [warm]
topic: [personal, colleague]
tags: [kael, greeting, phase-1]
- id: the-last-shift_d_010
- id: kael-davan_d_024
text: "Nils wants to talk. Tomorrow, bay side. Said it's about volume."
role: dock-worker
access: [insider]
trust: real
situation: [bar_evening, social]
mood: [concerned]
mood: [anxious]
topic: [danger]
tags: [kael, ring-ops, nils]
- id: the-last-shift_d_012
- id: kael-davan_d_026
text: "Lera knows more than she lets on. She won't say anything — but don't test it."
role: dock-worker
access: [insider]
trust: real
situation: [bar_evening, social, alone]
mood: [concerned]
mood: [anxious]
topic: [colleague, danger]
tags: [kael, ring-ops, lera, caution]
knowledge_grant:
@@ -291,13 +291,13 @@ character: detective
location: the-last-shift
lines:
# Basic atmospheric line — no prerequisites, any visit
- id: the-last-shift_m_d_001
- id: pc-detective_m_d_001
text: "The Last Shift. Only place in this district that doesn't smell like freight lubricant."
trigger: enter_location
tags: [arrival, atmospheric]
# Knowledge-gated observation — requires prior suspicion
- id: the-last-shift_m_d_021
- id: pc-detective_m_d_021
text: "Sera left when Torek arrived. Second time. Different excuse. Same result."
trigger: observe_anomaly
prerequisites:
@@ -308,7 +308,7 @@ lines:
tags: [npc, sera, torek, tell, friend-arc]
# Relationship-gated line — requires person_of_interest status
- id: the-last-shift_m_d_026
- id: pc-detective_m_d_026
text: "Same booth. Same warm smile. Same offer to buy me a drink. Everything except the truth."
trigger: observe_npc
prerequisites:
@@ -326,15 +326,15 @@ lines:
### 5.1 Pattern
```
{location-slug}_{type}_{character?}_{sequence}
{npc-slug}_{type}_{character?}_{sequence}
```
| Segment | Format | Values | Example |
|---------|--------|--------|---------|
| `location-slug` | kebab-case | Matches location directory name, or `general` | `the-last-shift`, `general` |
| `npc-slug` | kebab-case | NPC name in kebab-case. Each NPC has an independent sequence. | `kael-davan`, `dock-worker`, `pc-detective` |
| `type` | single char | `d` = dialogue, `m` = monologue, `e` = environmental (future) | `d`, `m` |
| `character` | single char | `s` = smuggler, `d` = detective. **Monologue only.** | `s`, `d` |
| `sequence` | 3-digit zero-padded | `001`–`999` | `001`, `042` |
| `sequence` | 3-digit zero-padded | `001`–`999` per NPC | `001`, `042` |
**General validation regex** (matches both dialogue and monologue IDs):
@@ -342,26 +342,26 @@ lines:
^[a-z0-9-]+_(d|m)(_[a-z])?_\d{3}$
```
- `[a-z0-9-]+` — location slug (kebab-case, at least one character)
- `[a-z0-9-]+` — npc slug (kebab-case, at least one character)
- `(d|m)` — pool type: `d` for dialogue, `m` for monologue
- `(_[a-z])?` — optional character segment (monologue only): `_s` or `_d`
- `\d{3}` — three-digit zero-padded sequence number
Use the pool-specific regexes in [Section 5.2](#52-regex-patterns) for strict per-type validation. This general regex is useful for quick format checks that accept either type.
> **D-035 deviation note:** D-035 specifies `{template}_{d|m|e}_{###}`. This spec refines that to `{location-slug}_{d|m}_{###}` (dialogue) and `{location-slug}_m_{s|d}_{###}` (monologue) for two reasons: (1) location-slug is more precise than template name and matches the directory hierarchy, and (2) monologue IDs include a character segment (`s`/`d`) to ensure uniqueness across the hard character partition (D-032). All existing authored content already uses this refined format. D-035 should be updated to reflect the implemented convention.
> **D-035 Sprint 15 amendment:** IDs are NPC-scoped, not location-scoped. The old `{location-slug}_{d|m}_{###}` format caused collisions when the same NPC appeared at multiple locations (e.g. `the-terminal_d_039` appeared in multiple NPC files). The new format `{npc-slug}_{d|m}_{###}` gives each NPC an independent 999-line ceiling. For multi-location NPCs, sequences are globally continuous across files (e.g. kael-davan uses _001-_014 at maintenance-corridors, _015-_033 at the-last-shift, _034-_075 at the-terminal). Single-location NPCs start at _001. Monologue uses per-file restart with composite key (file_path + line_id).
### 5.2 Regex patterns
| Pool type | Regex | Example |
|-----------|-------|---------|
| Dialogue | `^[a-z][a-z0-9-]*_d_[0-9]{3}$` | `the-last-shift_d_001` |
| Monologue | `^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$` | `the-last-shift_m_d_021`, `general_m_s_003` |
| Dialogue | `^[a-z][a-z0-9-]*_d_[0-9]{3}$` | `kael-davan_d_001` |
| Monologue | `^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$` | `pc-detective_m_d_021`, `pc-smuggler_m_s_003` |
### 5.3 Uniqueness scope
- IDs must be unique **within a single YAML file**.
- IDs are **not required to be globally unique** — location slug + file path provides global uniqueness. The engine uses `(file_path, line_id)` as the composite key.
- IDs are **not required to be globally unique** — npc-slug + file path provides global uniqueness. The engine uses `(file_path, line_id)` as the composite key.
- Sequence numbers need not be contiguous. Gaps are expected when lines are removed or reordered.
### 5.4 ID stability
@@ -406,9 +406,9 @@ All enum values are defined in `content/global/enums/` and validated by the JSON
### 6.5 Moods (D-028 Layer 4)
`fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`
`anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`
8 values for v0.1.
8 values for v0.1. (Updated Sprint 14 amendment to D-035.)
### 6.6 Monologue triggers
Binary file not shown.
+5 -5
View File
@@ -234,10 +234,10 @@ All schema files live in `content/_schema/`. They use JSON Schema draft 2020-12.
| Content Type | ID Pattern | Example |
|---|---|---|
| Dialogue | `{location_slug}_{d}_{###}` | `the-terminal_d_001` |
| Monologue (smuggler) | `{location_slug}_m_s_{###}` | `the-terminal_m_s_001` |
| Monologue (detective) | `{location_slug}_m_d_{###}` | `the-terminal_m_d_001` |
| Examine | `{location_slug}_{e}_{###}` | `the-terminal_e_001` |
| Dialogue | `{npc-slug}_d_{###}` | `kael-davan_d_001` |
| Monologue (smuggler) | `{npc-slug}_m_s_{###}` | `pc-smuggler_m_s_001` |
| Monologue (detective) | `{npc-slug}_m_d_{###}` | `pc-detective_m_d_001` |
| Examine | `{npc-slug}_{e}_{###}` | `kael-davan_e_001` |
Line IDs are stable. They are never reused, even if a line is deleted. Numbering gaps are expected and acceptable.
@@ -586,7 +586,7 @@ For quick reference during authoring. Authoritative source: `content/global/enum
**Topics (9):** `colleague`, `routine`, `cargo`, `money`, `trust`, `danger`, `institution`, `personal`, `investigation`
**Moods (8):** `fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`
**Moods (8):** `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`
**Access Tiers (5):** `public`, `insider`, `authority`, `peer`, `hostile`
+606
View File
@@ -0,0 +1,606 @@
# Insert/HUD Wireframe and Visual Specification — v0.1
## Dual Character Variants: Smuggler + Detective
**Ticket:** #314
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-24
**Status:** Active — feeds #263 (time display client implementation) and #151 (minimap rendering)
**Visual grammar reference:** `docs/design/visual-grammar-v01.md` (§7 Neural Insert Overlay)
---
## 1. System Architecture
The insert HUD is not a UI panel bolted onto the game. It is the player character's **neural lattice** — Settled Reach technology that overlays computational data onto the character's perception. Every element on z-layer 6 is diegetic: the character is experiencing this information through their implant, not reading a game interface.
This has direct design consequences:
- The insert does not appear/disappear — it activates and deactivates. The character's lattice is always there; they choose when to engage it.
- Labels use the character's vocabulary, not the game's. The smuggler's insert calls it "The Last Shift." The detective's insert calls it "Licensed Establishment, District 7."
- The same underlying `ObserverSnapshot` feeds both variants. One parameterized system: `lattice_profile` on the snapshot determines which visual mode renders. Stig builds one HUD framework, not two. The variants are rendering configurations, not separate systems.
### 1.1 Technical Parameters
| Parameter | Smuggler | Detective |
|-----------|----------|-----------|
| `lattice_profile` | `lattice_baseline` | `lattice_augmented` |
| Line weight | 1px | 1–2px |
| Annotation density | Sparse — essential markers only | Dense — more markers, connection lines |
| Passive opacity | 60% | 80% |
| Active opacity | 85% | 100% |
| Bloom | 2–3px gaussian at 40% blend | 2–3px gaussian at 40% blend |
| Chrome color | `#c8d0e0` | `#c8d0e0` |
### 1.2 Z-Layer Position
Insert HUD lives entirely on **z-layer 6**. It renders above fog (z-layer 5) and below monologue/UI (z-layer 7). The fog shader does not mask it — insert data is computational, not perceptual. Entity markers can appear in fogged areas if the lattice has position data.
---
## 2. Screen Layout
**Base resolution:** 1920×1080px
The HUD has four zones:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ [STANCE] [PERCEPTION] [TIME] │ ← HUD chrome band (y: 0–80px)
│ │
│ │
│ WORLD VIEW │
│ (insert entity markers rendered here, │
│ positioned at entity world locations) │
│ │
│ ↑ ↑ ↑ ↑ border arrows appear here │
│ ← (for off-screen known POIs) → │
│ ↓ ↓ ↓ ↓ │
│ │
│ [INVENTORY] [INVENTORY] │ ← inventory band (y: 816–864px)
├─────────────────────────────────────────────────────────────────────────────┤
│ DIALOGUE BOX (z-layer 7, when active) │ ← bottom 20% (y: 864–1080px)
│ [NPC speech] │
│ [response options] │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Coordinate system:** Origin (0,0) is top-left. X increases rightward, Y increases downward. All pixel values at 1920×1080 base; Godot 4 DPI scaling handles other resolutions.
**Dialogue box:** The bottom 20% (216px) is reserved when dialogue is active. Insert overlay remains visible at 60% of its normal opacity when dialogue is open — the lattice keeps running, the character keeps perceiving. The insert does not suppress during dialogue.
---
## 3. Time Display
**Feeds:** Ticket #263 (client time display implementation)
### 3.1 Position and Dimensions
| Property | Value |
|----------|-------|
| Container x | 1720px → 1904px (right-inset 16px, 184px wide) |
| Container y | 16px → 64px (48px tall) |
| Background | `#0e1218` at 40% opacity — barely-there panel behind text |
| Typeface | Michroma Regular 400 |
| Alignment | Right-aligned text, right-inset 8px within container |
This position is intentionally unremarkable. The player should find it without hunting for it.
### 3.2 Smuggler Variant
Single line: the time. Informal, just the clock.
```
09:42
```
| Property | Value |
|----------|-------|
| Text | `{HH}:{MM}` — 24-hour format, always two digits each |
| Font size | 24px |
| Color | `#c8d0e0` (insert chrome) |
| Opacity | 80% passive, 100% when insert active and entity hover |
| Y position | Center within container, ~y: 32px |
No annotation. The smuggler's lattice gives them the time. That's it.
### 3.3 Detective Variant
Two lines: clock on top, shift indicator below. The detective thinks in shifts.
```
09:42
SHIFT A ·
```
| Element | Value |
|---------|-------|
| Time text | `{HH}:{MM}` — Michroma 20px, `#c8d0e0` at 80% passive, y: 26px in container |
| Phase label | `SHIFT {X}` — Michroma 11px, `#c8d0e0` at 60%, y: 50px in container |
| Phase dot | 4px circle, phase-color (see below), same y as phase label |
**Day phase to shift mapping:**
| Game time | Shift label | Dot color |
|-----------|-------------|-----------|
| 05:00–12:00 | `SHIFT A` | `#c8d0e0` (chrome, neutral) |
| 12:00–18:00 | `SHIFT B` | `#c8d0e0` (chrome, neutral) |
| 18:00–22:00 | `SHIFT C` | `#e8c547` (amber — social hours, higher risk of contacts) |
| 22:00–05:00 | `SHIFT OFF` | `#4a9ebb` (teal — night, lower routine activity) |
The dot is a subtle visual cue, not a state machine icon. It gives the detective's lattice a slightly more annotated feel than the smuggler's plain clock.
### 3.4 Insert-Off Behavior
When `insert_active == false`:
- Time display dims to 50% of passive opacity
- Background panel disappears entirely
- Text remains — the character always knows what time it is, even without active lattice engagement. Time is ambient lattice output, not targeting output.
- Phase label (detective) also dims to 50%
The time display is the one persistent insert element. Everything else goes dark on insert deactivation.
---
## 4. Entity Markers
Entity markers appear at each entity's world position — rendered in world space on z-layer 6. They are not minimap icons in a panel; they annotate the world the player is looking at.
### 4.1 Smuggler Variant — Social Network View
The smuggler sees their world as a social network. People are dots. Labels are familiar names.
**Dot specification:**
| Property | Value |
|----------|-------|
| Shape | Filled circle |
| Diameter | 6px |
| Color | D-033 relationship color for this NPC |
| Bloom | 2–3px gaussian at 40% blend |
| Passive state | 60% opacity |
| Active state (hover) | 100% opacity + bloom brightens |
| All non-hovered on hover | 40% opacity (focus effect) |
The dot sits at the entity's sprite anchor point (center-bottom of the 64×64 canvas). It does not float; it attaches to the entity.
**Label specification:**
Named NPCs (Kael, Lera, Sera, etc.) always show a casual label. Generic NPCs show no label unless hovered.
| Property | Value |
|----------|-------|
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 70% passive, 100% on hover |
| Position | 8px above the dot, horizontally centered |
| Format | First name only for Known NPCs: `Kael` |
| Format | Location nickname for POIs: `The Last Shift`, `Kael's usual spot` |
| Background | None — label is direct on world view. Keep it legible against zone palettes. |
**Relationship colors applied:**
| NPC state | Dot color | Label format |
|-----------|-----------|--------------|
| Unknown | `#4a9ebb` teal | No label (generic) |
| Known/Friendly | `#6bc9a6` green | First name |
| Person of Interest | `#e8c547` amber | First name + `?` suffix: `Torek?` |
| Hostile | `#d45d5d` red | First name |
The smuggler does not see "Person of Interest." To them, amber means "something's off about this person" — not a case file designation.
### 4.2 Detective Variant — Investigation Overlay
The detective sees their world as a case map. Known individuals are flagged. Locations have institutional labels. The overlay is denser and more annotated.
**Named NPC marker specification:**
| NPC type | Shape | Size | Color |
|----------|-------|------|-------|
| Person of Interest | Diamond (rotated square) | 10px × 10px | `#e8c547` amber |
| Known/Friendly | Filled circle | 6px | `#6bc9a6` green |
| Unknown | Filled circle | 6px | `#4a9ebb` teal |
| Hostile / Suspect | Diamond | 10px × 10px | `#d45d5d` red |
The diamond shape is the detective's POI designation — it communicates "flagged" without text. The smuggler's variant uses only circles because their lattice doesn't have case-file designation logic.
**Label specification:**
| Property | Value |
|----------|-------|
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 80% passive, 100% on hover |
| Position | 10px above the marker, right-aligned to marker center |
| NPC label format | `POI: K. Davan` (formal: last name initial + surname) |
| NPC label format | `K. Davan` for Known/non-POI |
| Location label | See §5 |
**Evidence annotation:**
When the detective has logged evidence associated with an entity, a small square appears adjacent to their marker:
| Property | Value |
|----------|-------|
| Shape | Filled square |
| Size | 4px × 4px |
| Position | 4px right of the primary marker |
| Color | `#e8c547` amber |
| Opacity | 60% passive, 100% on hover |
This is the insert's way of flagging "you have something on this person." It doesn't say what.
**Commission grid overlay (Detective only):**
The detective's augmented lattice renders a very faint reference grid across the viewport. This is the "commission-style" aesthetic — the world read as a site map.
| Property | Value |
|----------|-------|
| Grid lines | Horizontal + vertical, every 5 visual tiles (~320px) |
| Line color | `#333340` at 20% opacity |
| Line weight | 1px |
| Behavior | Always visible when insert active, disappears on insert-off |
The grid does not animate, pulse, or react. It is a passive reference overlay. Five lines across the width, five down the height, making a 5×5 grid of reference squares. It is barely visible — felt as structure, not seen as decoration.
---
## 5. Location Labels
Location labels appear at POI anchor points in world space — the entrance tile or center tile of a known location.
### 5.1 Smuggler Variant
Casual labels, written in the smuggler's vocabulary. These are their personal pins.
| Location | Label text |
|----------|-----------|
| The Last Shift (bar) | `The Last Shift` |
| The Terminal (logistics hub) | `The Terminal` |
| Maintenance corridor (smuggling space) | `B-7 drop` (or specific pin label) |
| Kael's regular position | `Kael's usual spot` |
| Property | Value |
|----------|-------|
| Typeface | Michroma 12px |
| Color | `#c8d0e0` at 50% — location labels are backgrounded, not foregrounded |
| Position | Centered on location anchor, 16px above entity-level (below any entity markers at that location) |
| Visibility | Always visible for known locations, fades in on discovery |
### 5.2 Detective Variant
Institutional labels, written in Commission case file format.
| Location | Label text |
|----------|-----------|
| The Last Shift (bar) | `Licensed Establishment · District 7` |
| The Terminal (logistics hub) | `Freight Operations Hub · Level 2` |
| Maintenance corridor | `Restricted Access — Corridor B-7` |
| Kael's regular position | `K. Davan – Last Sighted` |
| Property | Value |
|----------|-------|
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 70% — slightly more prominent than smuggler's labels |
| Position | Same as smuggler, centered on anchor |
| Secondary text | Dot separator `·` between primary name and category/district |
| Visibility | Always visible for known/case-file locations |
---
## 6. Border Arrows (Off-Screen Navigation)
When a known POI or tracked entity is outside the current viewport, a border arrow appears at the screen edge pointing toward them.
**The camera is locked to the player character (D-014).** Border arrows are the only navigation affordance for off-screen known locations. There is no minimap panel.
### 6.1 Arrow Specification
| Property | Value |
|----------|-------|
| Shape | Equilateral triangle, apex pointing toward screen edge |
| Size | 12px × 10px |
| Inset from edge | 8px |
| Color | Matches the POI's relationship color (D-033), or `#c8d0e0` chrome for neutral known locations |
| Opacity | 60% passive, 100% on hover |
| Label on hover | Location name (using appropriate variant's label format), Michroma 10px, appears inside-screen adjacent to arrow |
**Clustering:** When multiple POIs are off-screen in similar directions, arrows stack with 4px gap. Maximum 6 arrows per edge before clustering into a count indicator: `+3` (Michroma 10px, chrome color).
**Decay:** If the character has only `Suspects` confidence about a location (not `KnowsOf`), the border arrow renders at 30% opacity with a dashed border (1px alternating 2px on / 2px off). "Something might be there."
### 6.2 Smuggler vs Detective
- **Smuggler:** Border arrows appear for Known/Friendly NPCs and personal pins. The arrow color is the NPC's relationship color. The label is casual. Unknown NPCs do not generate border arrows.
- **Detective:** Border arrows appear for all case-file entries, POIs, and known locations regardless of relationship state. Evidence-flagged entities show the amber evidence square (4px) adjacent to the arrow.
---
## 7. Inventory Indicators (Smuggler Only)
Per D-065: the smuggler can carry up to 3–4 physical items. Icons appear in the lower-right corner, above the dialogue band.
| Property | Value |
|----------|-------|
| Position | x: 1904px (right inset 16px), y: 816px → 856px |
| Icon size | 40×40px |
| Gap between icons | 8px horizontal, right-to-left stacking |
| Opacity | 80% passive, 100% on hover |
| Empty slots | Not displayed — icons only appear when items are carried |
| Z-layer | 6 (insert layer), but icons are physical objects, not lattice data |
**Item icons (placeholder descriptions for #314 — actual sprites are a separate ticket):**
- Manifest copy: document silhouette
- Corridor access token: hexagonal tag silhouette
- Personal comm log: communication device silhouette
Icons do not disappear when insert is off — they are physical items, not lattice annotations. The character still has pockets whether or not their insert is active.
**Detective inventory:** The detective has 2 inventory slots but they are knowledge-only in v0.1 (D-065). No inventory icons display for the detective in v0.1.
---
## 8. Stance Indicator
Both variants show the current movement stance. This is ambient information — the player set it, but may have forgotten.
| Property | Value |
|----------|-------|
| Position | x: 16px → 180px, y: 826px → 856px (lower-left, above dialogue band) |
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 50% — intentionally quiet |
| Content | Current stance label: `WALK` / `CAREFUL` / `SPRINT` / `CROUCH` |
| Behavior | Fades to 30% when dialogue is active |
The stance indicator never hides; it just dims. It is the quietest element in the HUD.
---
## 9. Interaction States
### 9.1 Insert Active — Default State
Full overlay as described in §3–§8. Entity markers, location labels, border arrows, time display, stance indicator, inventory icons all render at their passive opacity values.
### 9.2 Insert Inactive (D-056/D-057)
When `insert_active == false`:
| Element | State |
|---------|-------|
| Entity markers | Hidden |
| Location labels | Hidden |
| Border arrows | Hidden |
| Commission grid (detective) | Hidden |
| Evidence annotations | Hidden |
| Bloom | Off — no gaussian pass |
| Time display | Dims to 50% of passive opacity, background panel disappears |
| Stance indicator | Remains — 30% opacity |
| Inventory icons | Remain — physical, not insert data |
| Cursor | Shape still changes on entity proximity (subconscious physical response per D-056) |
The world goes quiet. The character still moves, still perceives physically, still has their items. But the lattice stops annotating.
### 9.3 Entity Hover (Insert Active)
When the player's cursor enters an entity's interaction radius:
1. Hovered entity's marker brightens to 100% opacity + bloom intensifies to 60%
2. All other entity markers dim to 40% opacity
3. Interaction verb appears: Michroma 13px, D-033 color of the target, positioned 16px above the entity marker
4. Entity label brightens to 100%
The interaction verb is the primary affordance for engagement. The verb appears on the insert layer — it disappears when insert is off, and is unaffected by fog (since it's z-layer 6).
Verb examples: `Talk`, `Observe`, `Follow`, `Confront`, `Examine`
### 9.4 Dialogue Active
When a dialogue interaction is in progress:
| Element | Change |
|---------|--------|
| Dialogue box | Appears at bottom 20% (y: 864–1080), z-layer 7 |
| Monologue (if active) | Floats above dialogue box at y: ~800–860, z-layer 7 |
| Insert overlay | Reduces to 60% of active opacity — still visible, lattice still running |
| Entity markers | Remain visible but dimmed 40% |
| Hovered entity marker | Stays bright — the conversation target stays highlighted |
| Inventory icons | Remain visible |
| Time display | Remains |
| Stance indicator | Dims to 30% |
The insert doesn't close during dialogue. The character is still aware of their surroundings.
### 9.5 High Perception Load (D-059)
When multiple perception modes are active simultaneously, the insert shows diegetic interference:
| Property | Value |
|----------|-------|
| Effect | Horizontal scan-line distortion |
| Intensity | 5% — subtle, peripheral |
| Behavior | Periodic (not constant), short bursts, random intervals 2–8 seconds |
| Applies to | All insert elements simultaneously |
| Does NOT apply to | UI z-layer 7 (monologue, dialogue) |
This is a visual warning, not a mechanical penalty. The player reads it as "the lattice is working hard." It should not be alarming; it should be ambient.
---
## 10. Wireframe Mockups
### 10.1 Smuggler Variant — The Last Shift, Evening
The smuggler is in the bar. Kael is nearby (green — Known/Friendly). An unknown dock worker is in the corner (teal). The bar is a known location.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ CAREFUL 09:42 │
│ │
│ ↑ The Last Shift (bar: border direction) │
│ │
│ │
│ ● ● │
│ Kael [player] ● │
│ (green) (cream) (teal) │
│ │
│ The Last Shift │
│ (chrome label, centered) │
│ │
│ │
│ [manifest][token]│
├─────────────────────────────────────────────────────────────────────────┤
│ [dialogue box — inactive] │
└─────────────────────────────────────────────────────────────────────────┘
Legend:
● = entity dot marker (6px) Kael = label (Michroma 11px, chrome)
The Last Shift = location label (Michroma 12px, 50% chrome)
[manifest][token] = inventory icons (40×40px, lower-right)
09:42 = time display (Michroma 24px, upper-right)
CAREFUL = stance (Michroma 11px, lower-left, 50%)
```
### 10.2 Detective Variant — Same Location, Same Time
The detective is outside the bar, watching the entrance. Kael is inside, behind a wall — the lattice shows his last known position as a dim marker. A POI is approaching from the corridor (off-screen right).
```
┌─────────────────────────────────────────────────────────────────────────┐
│ WALK [ANALYT] 09:42 │
│ SHIFT C · │
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
│ · ·│
│ · ◆ [player] → ·│
│ · K. Davan (blue-wh) [POI: arrow, amber, off-screen]·│
│ · (amber diamond) ·│
│ · ·│
│ · Licensed Establishment · District 7 ·│
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
│ · ·│
│ · ·│
│ │
│ WALK │
├─────────────────────────────────────────────────────────────────────────┤
│ [dialogue box — inactive] │
└─────────────────────────────────────────────────────────────────────────┘
Legend:
◆ = POI diamond marker (10×10px) K. Davan = formal label (Michroma 11px)
+·····+ = commission grid overlay (Michroma thin, 20% opacity)
→ = border arrow (off-screen POI, amber — K. Davan connection)
Licensed Establishment · District 7 = institutional location label
09:42 / SHIFT C · = time display + shift with amber phase dot
[ANALYT] = perception mode indicator (placeholder position, upper-center-right)
```
### 10.3 Entity Hover State — Detective, POI Hover
```
┌─────────────────────────────────────────────────────────────────────────┐
│ WALK [ANALYT] 09:42 │
│ SHIFT C · │
│ │
│ Talk │
│ Observe │
│ ◆ Confront ←─ interaction verbs (amber, 13px) │
│ K. Davan │
│ [■]◆ (brightened, 100%, bloom 60%) │
│ │
│ all other markers at 40% opacity ─────────────────────────→ │
│ ● │
│ (40%) │
└─────────────────────────────────────────────────────────────────────────┘
[■] = evidence annotation (4×4px amber square, adjacent to diamond)
```
---
## 11. Color Reference Summary
All colors from `visual-grammar-v01.md` §1.5 and §3.2. Reproduced here for implementation reference.
| Use | Hex | Notes |
|-----|-----|-------|
| Insert chrome (default) | `#c8d0e0` | Time display, labels, stance, grid |
| Entity — Unknown | `#4a9ebb` | Cool teal. Default NPC state. |
| Entity — Known/Friendly | `#6bc9a6` | Soft green. Trusted network. |
| Entity — POI | `#e8c547` | Warm amber. Flagged. |
| Entity — Hostile | `#d45d5d` | Muted red. Subjective danger. |
| Player — Detective | `#e0e8ff` | Cool blue-white. |
| Player — Smuggler | `#e8e0d0` | Warm cream. |
| Unexplored wireframe | `#333340` | Not used in HUD chrome directly — world layer use |
| Time panel background | `#0e1218` at 40% | Barely-there. Matches zone ambient palette. |
**Saturation rule:** Entity D-033 colors must remain the most saturated elements on screen at all times. Nothing in the insert chrome may compete with entity color saturation (D-044, §3.6 of visual grammar). The chrome is cool grey-white — it frames entity colors, never rivals them.
---
## 12. Typography Reference
All text is Michroma Regular 400 (Google Fonts). No exceptions (visual-grammar-v01.md §5.4).
| Role | Size | Color | Opacity (passive / active) | Z-layer |
|------|------|-------|---------------------------|---------|
| Time display | 24px (smuggler) / 20px (detective) | `#c8d0e0` | 80% / 100% | 6 |
| Phase label (detective) | 11px | `#c8d0e0` | 60% / 80% | 6 |
| Entity label (hover) | 11px | `#c8d0e0` | 70% / 100% | 6 |
| Location label (smuggler) | 12px | `#c8d0e0` | 50% / 70% | 6 |
| Location label (detective) | 11px | `#c8d0e0` | 70% / 90% | 6 |
| Interaction verb | 13px | D-033 target color | 95% (always active) | 6 |
| Stance indicator | 11px | `#c8d0e0` | 50% / 60% | 6 |
| Border arrow label (hover) | 10px | `#c8d0e0` | 0% / 100% (hover-only) | 6 |
---
## 13. What This Document Does Not Cover
These are deliberately out of scope for #314. Separate tickets own them.
- **Perception mode overlay visual design** — thermal, camera feed, etc. (#315 area, depends on D-017/D-048 full spec)
- **Monologue display system** — typography animation, stacking (#315)
- **Sound indicator visual design** — fog-edge ping pulse (#317)
- **THE FRIEND visual treatment** — special relationship visual affordances (#318)
- **Minimap panel** (if a panel approach is ever adopted — currently border arrows handle navigation, no panel exists in v0.1)
- **World map / inter-location navigation** — a different system entirely
- **Cursor states** — specified in D-056, not repeated here
---
## Appendix A — Integration Notes for Stig (#263)
**For the time display implementation (#263):**
1. The time display container is at **x: 1720–1904, y: 16–64** (right-inset 16px from 1920px width). These are your anchor coordinates.
2. The `ObserverSnapshot` needs to expose current game time (hours, minutes) and current day phase. Consult D-031 for the tick-to-time mapping (10 ticks = 1 game-minute).
3. Time display text must be **right-aligned** within the container. Don't center it — it should feel like a readout in the upper-right corner.
4. The background panel (`#0e1218` at 40%) should be a simple NinePatchRect or ColorRect — minimal, no rounded corners.
5. When `insert_active == false`: set time display `modulate.a = 0.5 * passive_opacity` and hide the background panel. The text stays; the container chrome disappears.
6. For the detective's phase label: drive the shift string from a simple range lookup on the game hour (see §3.3 table above). The dot color is a color constant mapped from phase — not dynamic.
7. The phase label is visible only on `lattice_profile == lattice_augmented`. Single boolean check in the time display node.
**For the entity marker system (future tickets):**
- One marker scene, parametrized by shape (circle vs diamond), size, color, bloom intensity
- The `lattice_profile` drives shape selection: baseline = circles always, augmented = diamonds for POI state, circles for other states
- Evidence annotation (`■`) is a separate child node, shown/hidden based on KnowledgeGraph query for evidence entries on this entity
- Commission grid is a separate CanvasLayer node, only visible when `lattice_profile == lattice_augmented` and `insert_active == true`
---
## Appendix B — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-013 | Diegetic insert/POI navigation. The HUD IS the neural insert. |
| D-014 | Minimap spec: "dots when close, border arrows for known distant POIs" |
| D-019 | Camera tilt — sprites authored for shallow tilt. Markers sit above entity anchor. |
| D-031 | Time system — tick-to-time mapping, day phases. Feeds §3. |
| D-033 | Entity color = relationship to player. All marker colors source from this. |
| D-048 | Neural insert visual design. Bloom spec, character variants. |
| D-049 | Z-layer 6 = insert overlay. Z-layer 7 = UI/monologue. |
| D-051 | "Settling is placement." Object density as meaning. Insert labels reflect this. |
| D-056 | Cursor states persist when insert-off. |
| D-057 | Entity interaction labels suppressed when insert-off. |
| D-059 | Perception load → scan-line interference at 5%. |
| D-065 | Inventory: 40×40px icons, smuggler-only in v0.1. |
| D-066 | Dual-scale grid. Entity markers anchor to visual tile positions. |
+1 -1
View File
@@ -307,7 +307,7 @@ Social positioning in a specific context.
### Full YAML format (engine canonical)
```yaml
- id: transit_m_s_025
- id: pc-smuggler_m_s_025
character: smuggler
text: "Kael keeps checking his lattice. Waiting for a message? Not like him to be jumpy."
trigger: observe_anomaly
+2 -2
View File
@@ -226,7 +226,7 @@ Add `tags: [mirror_moment]` to both sides of every mirror pair so they can be id
```yaml
# Smuggler side — monologue-smuggler.yaml
- id: transit_m_s_044
- id: pc-smuggler_m_s_044
character: smuggler
trigger: observe_npc
prerequisite:
@@ -239,7 +239,7 @@ Add `tags: [mirror_moment]` to both sides of every mirror pair so they can be id
tags: [mirror_moment, mirror_worried_partner]
# Detective side — monologue-detective.yaml
- id: transit_m_d_044
- id: pc-detective_m_d_044
character: detective
trigger: observe_npc
prerequisite:
+7 -7
View File
@@ -359,7 +359,7 @@ skills:
```yaml
# File: districts/{location}/dialogue/{role}-mirror.yaml
- id: "{location}_d_001"
- id: "{npc-slug}_d_001"
text: "Greeting — warm, open, no guardedness"
role: "{role}"
access: ["public"]
@@ -367,7 +367,7 @@ skills:
situation: ["greeting"]
mood: ["warm"]
- id: "{location}_d_002"
- id: "{npc-slug}_d_002"
text: "Sharing worry — direct, no evasion"
role: "{role}"
access: ["public", "insider"]
@@ -375,7 +375,7 @@ skills:
situation: ["worried_inquiry"]
mood: ["anxious"]
- id: "{location}_d_010"
- id: "{npc-slug}_d_010"
text: "Deep emotional vulnerability — peer tier"
role: "{role}"
access: ["peer"]
@@ -385,8 +385,8 @@ skills:
# Monologue content (separate per character, per D-032)
# File: districts/{location}/monologue-smuggler.yaml
- id: "{location}_m_s_001"
# File: districts/{location}/monologue/smuggler/{location}.yaml
- id: "pc-smuggler_m_s_001"
text: "Smuggler feels guilt observing MIRROR's distress"
character: "smuggler"
trigger: "observe_npc"
@@ -394,8 +394,8 @@ skills:
target.known_attributes.name: "{Name}"
target.known_attributes.emotional_state: "worried"
# File: districts/{location}/monologue-detective.yaml
- id: "{location}_m_d_001"
# File: districts/{location}/monologue/detective/{location}.yaml
- id: "pc-detective_m_d_001"
text: "Detective recognizes MIRROR as honest witness"
character: "detective"
trigger: "post_conversation"
+27 -27
View File
@@ -32,7 +32,7 @@ All smuggler openings share:
These lines fire when the smuggler enters The Terminal at session start. 3-4 lines available; engine selects one based on seed variant.
```yaml
- id: transit_m_s_open_001
- id: pc-smuggler_m_s_001
character: smuggler
trigger: enter_location
location: the-terminal
@@ -40,7 +40,7 @@ These lines fire when the smuggler enters The Terminal at session start. 3-4 lin
mood: [content, routine]
tags: [opening_hook, canonical_anchor]
- id: transit_m_s_open_002
- id: pc-smuggler_m_s_002
character: smuggler
trigger: enter_location
location: the-terminal
@@ -48,7 +48,7 @@ These lines fire when the smuggler enters The Terminal at session start. 3-4 lin
mood: [routine, aware]
tags: [opening_hook]
- id: transit_m_s_open_003
- id: pc-smuggler_m_s_003
character: smuggler
trigger: enter_location
location: the-terminal
@@ -56,7 +56,7 @@ These lines fire when the smuggler enters The Terminal at session start. 3-4 lin
mood: [routine, observant]
tags: [opening_hook]
- id: transit_m_s_open_004
- id: pc-smuggler_m_s_004
character: smuggler
trigger: enter_location
location: the-terminal
@@ -70,7 +70,7 @@ These lines fire when the smuggler enters The Terminal at session start. 3-4 lin
These fire when the smuggler sees Kael on the dock floor. Critical FRIEND establishment lines.
```yaml
- id: transit_m_s_open_010
- id: pc-smuggler_m_s_010
character: smuggler
trigger: observe_npc
target: npc:kael-davan
@@ -82,7 +82,7 @@ These fire when the smuggler sees Kael on the dock floor. Critical FRIEND establ
mood: [content, fond]
tags: [opening_hook, friend_phase_1, canonical_anchor]
- id: transit_m_s_open_011
- id: pc-smuggler_m_s_011
character: smuggler
trigger: observe_npc
target: npc:kael-davan
@@ -104,7 +104,7 @@ These fire when the smuggler sees Kael on the dock floor. Critical FRIEND establ
**Operational context line (observe_anomaly — schedule board):**
```yaml
- id: transit_m_s_open_020
- id: pc-smuggler_m_s_020
character: smuggler
trigger: observe_anomaly
location: the-terminal
@@ -131,7 +131,7 @@ Kael greets the smuggler warmly. Asks about the weekend. Makes a dry comment abo
**Absence lines (observe_npc, failed — Kael's station empty):**
```yaml
- id: transit_m_s_open_030
- id: pc-smuggler_m_s_030
character: smuggler
trigger: observe_anomaly
location: the-terminal
@@ -140,7 +140,7 @@ Kael greets the smuggler warmly. Asks about the weekend. Makes a dry comment abo
tags: [opening_hook, variant_b]
notes: "Phase 1 tell: Kael's routine is so established that absence is noted before alarm."
- id: transit_m_s_open_031
- id: pc-smuggler_m_s_031
character: smuggler
trigger: time_idle
location: the-terminal
@@ -169,7 +169,7 @@ Kael arrives slightly disheveled, slightly out of breath. Brief explanation —
**Environmental read lines:**
```yaml
- id: transit_m_s_open_040
- id: pc-smuggler_m_s_040
character: smuggler
trigger: observe_npc
target: npc:voss
@@ -177,7 +177,7 @@ Kael arrives slightly disheveled, slightly out of breath. Brief explanation —
mood: [aware, cautious]
tags: [opening_hook, variant_c]
- id: transit_m_s_open_041
- id: pc-smuggler_m_s_041
character: smuggler
trigger: observe_npc
target: npc:voss
@@ -204,7 +204,7 @@ Kael arrives slightly disheveled, slightly out of breath. Brief explanation —
**Container flag lines:**
```yaml
- id: transit_m_s_open_050
- id: pc-smuggler_m_s_050
character: smuggler
trigger: observe_anomaly
location: the-terminal
@@ -212,7 +212,7 @@ Kael arrives slightly disheveled, slightly out of breath. Brief explanation —
mood: [operational, concerned]
tags: [opening_hook, variant_d]
- id: transit_m_s_open_051
- id: pc-smuggler_m_s_051
character: smuggler
trigger: observe_anomaly
location: the-terminal
@@ -245,7 +245,7 @@ All detective openings share:
Lines that fire when the detective enters their starting location. Selection based on seed variant.
```yaml
- id: transit_m_d_open_001
- id: pc-detective_m_d_001
character: detective
trigger: enter_location
location: the-terminal
@@ -253,7 +253,7 @@ Lines that fire when the detective enters their starting location. Selection bas
mood: [professional, observant]
tags: [opening_hook, canonical_anchor]
- id: transit_m_d_open_002
- id: pc-detective_m_d_002
character: detective
trigger: enter_location
location: the-terminal
@@ -262,7 +262,7 @@ Lines that fire when the detective enters their starting location. Selection bas
tags: [opening_hook, canonical_anchor]
notes: "Companion line to open_001 — these fire in sequence on Variant A/B/C."
- id: transit_m_d_open_003
- id: pc-detective_m_d_003
character: detective
trigger: enter_location
location: the-last-shift
@@ -270,7 +270,7 @@ Lines that fire when the detective enters their starting location. Selection bas
mood: [professional, curious]
tags: [opening_hook, variant_a_bar]
- id: transit_m_d_open_004
- id: pc-detective_m_d_004
character: detective
trigger: enter_location
location: the-terminal
@@ -284,7 +284,7 @@ Lines that fire when the detective enters their starting location. Selection bas
Critical FRIEND establishment for the detective.
```yaml
- id: transit_m_d_open_010
- id: pc-detective_m_d_010
character: detective
trigger: observe_npc
target: npc:sera-venn
@@ -296,7 +296,7 @@ Critical FRIEND establishment for the detective.
mood: [relieved, warm]
tags: [opening_hook, friend_phase_1, canonical_anchor]
- id: transit_m_d_open_011
- id: pc-detective_m_d_011
character: detective
trigger: observe_npc
target: npc:sera-venn
@@ -322,7 +322,7 @@ Critical FRIEND establishment for the detective.
**Bar arrival line:**
```yaml
- id: transit_m_d_open_020
- id: pc-detective_m_d_020
character: detective
trigger: enter_location
location: the-last-shift
@@ -334,7 +334,7 @@ Critical FRIEND establishment for the detective.
**Lera read (public NPC, establishing social texture):**
```yaml
- id: transit_m_d_open_021
- id: pc-detective_m_d_021
character: detective
trigger: observe_npc
target: npc:lera-sessik
@@ -363,7 +363,7 @@ Sera gives a brief district overview — who the regulars are, what the Terminal
**Terminal read line:**
```yaml
- id: transit_m_d_open_030
- id: pc-detective_m_d_030
character: detective
trigger: enter_location
location: the-terminal
@@ -375,7 +375,7 @@ Sera gives a brief district overview — who the regulars are, what the Terminal
**Voss approach (first adversarial NPC):**
```yaml
- id: transit_m_d_open_031
- id: pc-detective_m_d_031
character: detective
trigger: observe_npc
target: npc:voss
@@ -406,7 +406,7 @@ Standard question options: "Thank you, I'll need those." / "Tell me about your s
**Investigative focus line:**
```yaml
- id: transit_m_d_open_040
- id: pc-detective_m_d_040
character: detective
trigger: enter_location
location: the-terminal
@@ -414,7 +414,7 @@ Standard question options: "Thank you, I'll need those." / "Tell me about your s
mood: [analytical, focused]
tags: [opening_hook, variant_c]
- id: transit_m_d_open_041
- id: pc-detective_m_d_041
character: detective
trigger: observe_anomaly
location: the-terminal
@@ -436,7 +436,7 @@ Standard question options: "Thank you, I'll need those." / "Tell me about your s
**Escort read lines:**
```yaml
- id: transit_m_d_open_050
- id: pc-detective_m_d_050
character: detective
trigger: observe_npc
target: npc:torek-lintar
@@ -444,7 +444,7 @@ Standard question options: "Thank you, I'll need those." / "Tell me about your s
mood: [professional, curious]
tags: [opening_hook, variant_d]
- id: transit_m_d_open_051
- id: pc-detective_m_d_051
character: detective
trigger: observe_npc
target: npc:torek-lintar
+60
View File
@@ -0,0 +1,60 @@
# Sprint 17: Tell — Client Tasks
**Goal:** NPCs volunteer information unprompted and express personality through delivery; the player can read time and orientation at a glance from the insert HUD; POI infrastructure lands on the server.
**Branch:** `client`
**Agents:** Stig (UI), Tyre (arch), Hoshe (QA)
## Carry-over from Sprint 16
None. Sprint 16 client tickets (#540, #543) will close before Sprint 17.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #263 | Time display on insert HUD | — |
| #537 | UX: E-Talk overlay improvement | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/perception.md` — D-019 (camera angle), D-033 (entity color)
- `decisions/architecture.md` — D-020 (Godot client + Rust server via IPC)
- `decisions/scope.md` — D-051 (diegetic insert/minimap)
## Notes
### #263 — Time display on insert HUD
**What exists:** The client has `client/scripts/ui/debug_overlay.gd` for debug display. The insert HUD concept is defined in D-051 but no insert UI code exists yet. `client/scripts/rendering/world_renderer.gd` handles the main rendering pipeline. The server sends `SimulationTime` data in `ObserverSnapshot` (see `server/src/bridge/types.rs`).
**What to deliver:** A diegetic time display on the player's insert/HUD. The player needs to know the current game time for routine-based investigation (NPC schedules, shift changes). Should show time in the station's local format, not abstract tick counts.
**Design dependency:** #314 (visual, this sprint) produces the insert/HUD wireframe. Coordinate with Araminta's output for placement and visual style. Can start with a placeholder layout and refine after the wireframe lands.
**Integration:** Reads `SimulationTime` from the snapshot. No new server work required — time data is already in the protocol.
### #537 — UX: E-Talk overlay improvement
**What exists:** The E-Talk interaction overlay appears when adjacent to an NPC. Playtester feedback says it doesn't communicate enough value — the overlay appears but doesn't convey what talking will accomplish or what access tier is available.
**What to deliver:** Improve the overlay to show: NPC name (if known from KG), relationship state color (from `RelationshipState` in snapshot), and a hint of available dialogue tier (surface/real/secret based on trust level). The goal is to make the player feel informed before committing to a conversation.
**Integration:** All data needed is already in `ObserverSnapshot` — the `VisibleEntity` includes `relationship` state and `known_attributes`. No server changes needed.
## Dependency Chain
```
#263 (Time display) → standalone
#537 (E-Talk UX) → standalone
Both can run in parallel.
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): sprint 17 description" --description "body" --base main --head client
```
+94
View File
@@ -0,0 +1,94 @@
# Sprint 17: Tell — Copy Tasks
**Goal:** NPCs volunteer information unprompted and express personality through delivery; the player can read time and orientation at a glance from the insert HUD; POI infrastructure lands on the server.
**Branch:** `copy`
**Agents:** Mellanie (author), Paula (narrative), Gestalt (systems)
## Carry-over from Sprint 16
None. Sprint 16 copy ticket (#542, line ID migration) closed.
## Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #330 | Diegetic tutorial monologue lines — per character | — (blockers #299, #300 done) |
| #552 | Author contradiction monologue lines — Sera/Kael FRIEND arc | — (can author against D-083 spec) |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/content.md` — D-035 (tag taxonomy, monologue prerequisites), D-028 (dialogue architecture)
- `decisions/perception.md` — D-019 (camera and perception), D-011 (fog of perception), D-083 (contradiction detection pipeline)
## Notes
### #330 — Diegetic tutorial monologue lines — per character
**What exists:** Monologue pools at `content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/` and `.../smuggler/` contain character-specific internal monologue. Opening monologue files exist (`opening.yaml` for both characters). The monologue system in `server/src/simulation/monologue.rs` fires lines based on triggers and prerequisites.
**What to deliver:** 8-10 monologue lines per character (smuggler + detective) that teach core mechanics diegetically:
- Movement and exploration ("These corridors all look the same. Mental note: check the signage.")
- Fog of perception ("Can't see past that corner. Might be worth checking.")
- Sound model ("Voices down the hall. Can't make out the words from here.")
- NPC interaction ("Could ask around. People talk if you give them reason to.")
- Insert/HUD usage ("Check the overlay. Should show the time and nearby contacts.")
Lines fire on first-time events (first move, first fog encounter, first sound heard, first NPC proximity, first insert open). Use `trigger: first_time_event` with appropriate event tags.
**Voice consistency:** Smuggler lines should be observational, street-smart, practical. Detective lines should be analytical, procedure-oriented, careful. Both stay in-character — tutorials are disguised as natural inner thoughts, not fourth-wall-breaking instructions.
**Prerequisite format:** Per D-035:
```yaml
- id: pc-smuggler_m_s_tut_001
text: "Voices. Down the corridor. Can't tell how many."
trigger: first_sound_heard
priority: tutorial
cooldown: -1
```
Use `_tut_` in IDs to distinguish tutorial lines from narrative monologue.
**Note on PC monologue ID collision (Q-028):** The review of PR #59 flagged that PC monologue IDs collide across location files. Use the `_tut_` discriminator for tutorial lines. Q-028 will resolve the broader scheme.
### #552 — Author contradiction monologue lines — Sera/Kael FRIEND arc
**What exists:** The monologue system in `server/src/simulation/monologue.rs` handles anomaly/recognition lines. Sprint double-take monologue already fires on `KnowledgeState::Contradicted`. The server team is implementing the contradiction detection pipeline (#547, #550) that will SET this state.
**What to deliver:** Source-named contradiction monologue lines for both characters. These fire when the player discovers a location contradiction — an NPC told them one thing, but they observe something different.
**Canonical example (THE FRIEND arc, D-034):** Sera tells the detective "Kael was at the dock during second shift." The detective later observes Kael in corridor B-7 at that time. Contradiction detected. Monologue fires:
> "Sera said Kael was at the dock. I just saw him in B-7. One of those isn't true."
**Tone principle:** Cognitive dissonance, not accusation. The PC is processing conflicting information, not making judgments. The monologue should feel like genuine internal processing — uncertainty, recalibration, filing away the inconsistency.
**Authoring spec:** See Paula's Round 2 workshop output at `docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md` for the full Mellanie spec, including:
- Phase 2 (Sera/Kael location contradiction) lines
- Phase 3 (deeper contradictions) lines
- Trigger specifications
- The cognitive-dissonance-not-accusation principle
**Both character variants needed:**
- **Detective:** Analytical, procedure-oriented. Notes the discrepancy formally, mentally files it.
- **Smuggler:** Street-smart, instinctive. Gut reaction to being lied to, reads it as a social signal.
**Integration:** The server team (#550) will wire these lines into the monologue system. The `ContradictionDetected` event payload includes `source_display_name` and `subject_display_name` — lines can reference the source NPC by name.
**Can start immediately** — author against the D-083 spec without waiting for server implementation.
## Dependency Chain
```
#330 (Tutorial monologue) → standalone, no deps
#552 (Contradiction monologue) → standalone (author against spec), feeds #550 (server)
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(content): sprint 17 description" --description "body" --base main --head copy
```
+98
View File
@@ -0,0 +1,98 @@
# Sprint 17: Tell — Joint Tasks
**Goal:** NPCs volunteer information unprompted and express personality through delivery; the player can read time and orientation at a glance from the insert HUD; POI infrastructure lands on the server.
## Pre-Sprint: Knowledge Flow & NPC Boundaries Workshop (COMPLETE)
**Outcomes:** `docs/workshops/knowledge-flow-npc-boundaries/workshop-outcomes.md`
**Participants:** Tyre (arch), Gestalt (mechanics), Dudley (impl), Paula (narrative), Qatux (docs)
**Status:** Complete. All decisions registered, tickets created.
### Decisions Produced
| Decision | Topic | Key outcome |
|----------|-------|-------------|
| **D-079** | Knowledge Grant Architecture | Compound `KnowledgeGrant` enum (Fact + Entity), `ContentEntityRegistry`, grants fire at line selection |
| **D-080** | NPC-to-NPC Propagation | Transfer via `transfer_npc_knowledge` system, trust-gated, confidence capped at `KnowsOf` |
| **D-081** | Unprompted Disclosure | `DisclosureCandidates` component, trigger gates, three-layer rate limiting, two-stage trait filter |
| **D-082** | NPC Information Boundaries MVP | tell_state + disclosure only, pathfinding deferred, Active-tier only |
| **D-083** | Contradiction Detection Pipeline | Event-driven at KG write time, `ContradictionClaim` struct, 600-tick window |
### Questions Closed
| Question | Resolution |
|----------|------------|
| **Q-024** | Gossip timing — conversation system hook confirmed |
| **Q-025** | KG memory — not a constraint at current scale, re-evaluate at 200+ Active NPCs |
| **Q-026** | Contradiction detection — event-driven at KG write time with `ContradictionClaim` |
### Team Lead Decision
Entity grants ship in Sprint 17 (overrides FactId workaround). Compound `KnowledgeGrant` with `Entity` variant + `ContentEntityRegistry` at NPC spawn.
### Workshop Tickets Created
7 implementation tickets (#545-#551) under the Knowledge Graph epic, plus #552 (copy team contradiction content). See server briefing for full dependency chain.
## Pre-Sprint Decisions
| Decision | Who | Status |
|----------|-----|--------|
| Workshop Topics 1-5 | Tyre, Gestalt, Dudley, Paula | **Done** — D-079 through D-083 |
| Q-028: Collision-resistant line IDs | Gestalt, Tyre | Open |
## Cross-Team Integration Points
| Server ticket | Client ticket | Integration |
|---------------|---------------|-------------|
| #148 POI data model | #263 Time display | POI data feeds future minimap; time data already in snapshot |
| #232 Protocol versioning | All client | Client must handle version field in messages |
| #551 Unprompted disclosure | #537 E-Talk UX | Disclosure lines appear in dialogue UI; E-Talk overlay should hint at NPC willingness |
| Server ticket | Visual ticket | Integration |
|---------------|---------------|-------------|
| #148 POI data model | #314 Insert/HUD wireframe | POI categories must match wireframe's display elements |
| Server ticket | Copy ticket | Integration |
|---------------|-------------|-------------|
| #550 Contradiction monologue | #552 Author contradiction lines | Copy authors lines per D-083 spec; server wires them into monologue system |
## Sprint Completion Proof
Sprint 17 is **DONE** when:
1. **Unprompted disclosure fires** — An NPC with high trust and relevant knowledge volunteers information to the player without being asked. The volunteered line appears in the dialogue UI. The player's KnowledgeGraph gains a new fact via `knowledge_grant`.
2. **Trait modifiers reshape delivery** — Two NPCs with different personality traits deliver the same base information differently (different mood, different wording or selection weight).
3. **Time is visible** — The insert HUD shows current game time in a diegetic format. Time advances visibly as ticks pass.
4. **POIs exist on the map** — Server spawns POI entities with the `PointOfInterest` component. POIs can be discovered via walking into a location (physical discovery).
5. **Protocol version field exists** — All messages include a version field. Client logs a warning on version mismatch.
6. **E-Talk overlay is informative** — The interaction overlay shows NPC name (if known), relationship color, and a dialogue tier hint.
7. **Insert wireframe is specified** — Both smuggler and detective variants are wireframed with layout, colors, and interaction states documented.
8. **Tutorial monologue fires** — First-time events (move, fog, sound, NPC proximity) trigger diegetic tutorial lines for both characters.
9. **Contradiction detection fires** — Location contradiction between `ToldBy` and `DirectObservation` sources marks both entries `Contradicted`. Source-named monologue line triggers. THE FRIEND arc mechanical sequence validates end-to-end.
10. **NPC-to-NPC knowledge transfers** — NPCs in conversation exchange trust-gated facts. `ToldBy` source is constructed. Confidence capped at `KnowsOf`.
## Test Plan (D-030 alignment)
Sprint 17 is in the **integration testing** phase (Sprints 3-4 per D-030):
- **#545 + #546:** Unit tests for `KnowledgeGrant` enum serde roundtrip, `ContentEntityRegistry` resolution, `KnowledgeGranted` event processing.
- **#547 + #550:** Integration test — `ToldBy` entry + `DirectObservation` at different position within 600-tick window → `Contradicted` state → monologue fires → relationship shift → amber color.
- **#548:** Integration test — NPC conversation transfers facts, confidence capped, `ToldBy` source constructed.
- **#549:** Unit test — tell_state reads KG for relationship data, falls through to axes for self-knowledge.
- **#551 + #172 + #173:** Integration test — NPC with specific axis values and KG state triggers unprompted disclosure; trait modifier changes the selected line.
- **#148 + #149:** Unit tests for POI component CRUD; integration test for discovery event → KG update.
- **#232:** Unit test for version serialization roundtrip; integration test for version mismatch handling.
- **#263:** Manual verification — time display updates as simulation advances.
- **#537:** Manual verification — overlay shows correct NPC data.
- **#330:** Line preview test — tutorial lines fire on first-time triggers, respect cooldown -1 (fire once).
- **#552:** Content review — contradiction lines follow cognitive-dissonance-not-accusation tone, name source entity.
## Teams
| Team | Branch | Agents | Tickets |
|------|--------|--------|---------|
| server | `server` | Dudley, Tyre, Hoshe | #545, #546, #547, #548, #549, #550, #551, #141, #142, #172, #173, #148, #149, #232 |
| client | `client` | Stig, Tyre, Hoshe | #263, #537 |
| copy | `copy` | Mellanie, Paula, Gestalt | #330, #552 |
| visual | `visual` | Araminta | #314 |
+135
View File
@@ -0,0 +1,135 @@
# Sprint 17: Tell — Server Tasks
**Goal:** NPCs volunteer information unprompted and express personality through delivery; the knowledge graph becomes a live system with grants, propagation, contradiction detection, and NPC information boundaries; POI infrastructure lands; protocol versioning ships.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (arch), Hoshe (QA)
## Carry-over from Sprint 16
None. Sprint 16 server was 100% complete (4/4 done).
## Workshop Output
The **Knowledge Flow & NPC Boundaries** workshop completed before this sprint. Outcomes at `docs/workshops/knowledge-flow-npc-boundaries/workshop-outcomes.md`.
**Decisions registered:** D-079 (Grant Architecture), D-080 (NPC-to-NPC Propagation), D-081 (Unprompted Disclosure), D-082 (NPC Information Boundaries MVP), D-083 (Contradiction Detection Pipeline)
**Questions closed:** Q-024 (gossip timing — conversation system hook), Q-025 (KG memory — not a concern at current scale), Q-026 (contradiction detection — event-driven at KG write time)
**Team lead decision:** Entity grants ship in Sprint 17 (compound `KnowledgeGrant` enum with `Entity` variant, `ContentEntityRegistry` at NPC spawn).
## Tickets
### Knowledge Graph Infrastructure (Workshop Tickets)
| # | Title | Priority | Blocked by | Est. lines |
|---|-------|----------|------------|------------|
| **#545** | KnowledgeGrant schema + ContentEntityRegistry | critical | — | ~120 |
| **#546** | KnowledgeGranted event + handler | high | #545 | ~150 |
| **#547** | ContradictionClaim struct + detection in observe_entity | high | #545 | ~160 + tests |
| **#548** | NPC-to-NPC knowledge transfer system | high | #545, #546 | ~160 |
| **#549** | tell_state.rs KG awareness — MVP boundary (#142) | high | #545 | ~35 |
| **#550** | Contradiction monologue + event chain | high | #545, #546, #547, #548, #552 | ~90 |
| **#551** | DisclosureCandidates + unprompted disclosure | high | #545, #546, #548 | ~220 |
### Stories (Acceptance Gates)
| # | Title | Priority | Blocked by |
|---|-------|----------|------------|
| **#141** | Knowledge-based information gating | high | #546 |
| **#142** | NPC information boundaries | high | #546, #548, #549 |
| **#172** | Layer 4: Unprompted disclosure | high | #551 |
| **#173** | Trait modifier system | high | — |
### Standalone
| # | Title | Priority | Blocked by |
|---|-------|----------|------------|
| **#148** | POI data model | high | — |
| **#149** | POI discovery system | high | #148 |
| **#232** | Create protocol versioning scheme | high | — |
Use `db/connectors/ticket show <id>` for full details.
## Dependency Chain
```
#545 (KnowledgeGrant schema) ─ CRITICAL ROOT
├─→ #546 (event + handler)
│ ├─→ #548 (NPC-to-NPC transfer)
│ │ ├─→ #551 (disclosure) → #172 (story gate)
│ │ ├─→ #550 (contradiction monologue) ← also needs #547, #552
│ │ └─→ #142 (story gate)
│ └─→ #141 (story gate)
├─→ #547 (ContradictionClaim) → #550
├─→ #549 (tell_state KG) → #142 (story gate)
│
Parallel tracks (no KG deps):
#148 → #149 (POI)
#173 (trait modifiers)
#232 (protocol versioning)
```
**Critical path:** #545 → #546 → #548 → #551 → #172
**Parallel from day 1:** #547 (with #545), #549 (after #545), #148, #173, #232
## Key Decisions
- `decisions/perception.md` — D-079 (grant architecture), D-080 (NPC-to-NPC propagation), D-081 (unprompted disclosure), D-082 (NPC boundaries MVP), D-083 (contradiction detection)
- `decisions/content.md` — D-028 (dialogue architecture, four relational layers), D-024 (NPC 10 axes), D-035 (tag taxonomy)
- `decisions/architecture.md` — D-041 (knowledge graph data model), D-010 (information boundaries), D-020 (engine architecture)
- `decisions/perception.md` — D-033 (entity color from relationship)
## Implementation Notes
### #545 — KnowledgeGrant schema + ContentEntityRegistry
**D-079.** Compound `KnowledgeGrant` enum with `Fact` and `Entity` variants via `#[serde(untagged)]`. `ContentEntityRegistry` resource (`BTreeMap<String, StableId>`) populated at NPC spawn time. ~0.5 day infrastructure. This is the critical root — everything else depends on it.
### #546 — KnowledgeGranted event + handler
**D-079.** New `KnowledgeEventType::KnowledgeGranted` variant. Fires at line selection time in `server/src/simulation/dialogue.rs`. Wires the `knowledge_grant` field on `IndexedDialogueLine` (`server/src/content/line_pool.rs` line 297) — currently `Option<KnowledgeGrant>`, always `None`.
### #547 — ContradictionClaim struct + detection
**D-083.** Add `contradicted_claim: Option<ContradictionClaim>` to `EntityKnowledge`. Detection fires in `observe_entity()` when: existing entry has `source: ToldBy`, position differs, and `|current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS` (600 ticks). Parallel with #546.
### #548 — NPC-to-NPC knowledge transfer
**D-080.** Separate `transfer_npc_knowledge` system (ECS borrow constraint — can't hold two mutable KGs in same system). Runs after `run_npc_conversations`. Trust-gated: Surface 0-1, Real 1-2, Secret 2-3 facts. Confidence capped at `KnowsOf`. Constructs `ToldBy` source.
### #549 — tell_state.rs KG awareness
**D-082.** MVP information boundary. Add `Option<&KnowledgeGraph>` to tell_state query. NPC relationship reads come from KG (other-entity knowledge), self-knowledge stays on axes (always ground truth). ~35 lines, standalone after #545.
### #550 — Contradiction monologue + event chain
**D-083.** `ContradictionDetected` event → monologue system triggers source-named line → relationship shifts to `PersonOfInterest` → `AnomalyMarker` set → D-033 amber color. Pre-resolve `source_display_name` into event payload. Depends on #552 (copy team authors the lines).
### #551 — DisclosureCandidates + unprompted disclosure
**D-081.** `DisclosureCandidates` component (separate from `DerivedTellState`). Trigger gates: trust >= Surface, mood != Hostile, contentment >= -10, witness inhibition, location privacy. Three rate-limit layers: per-fact history, per-NPC 300-tick cooldown, global 1/10-tick StableId-ordered cap. Trait-based two-stage filter (what + how).
### #172 — Layer 4: Unprompted disclosure (story)
Acceptance gate for #551. Verify: NPC with high trust and relevant knowledge volunteers information unprompted. Player's KG gains a new fact via `knowledge_grant`.
### #173 — Trait modifier system
**D-081 two-stage design.** Stage 1: traits filter WHAT is disclosed (Cautious → KnowsDetails minimum, Gossipy → Suspects minimum). Stage 2: traits modify HOW (delivery mood, line selection weight). Composes with Layer 3 (trust-gated) and Layer 4 (#551).
### #148 / #149 — POI data model + discovery
POIs enter KG as `FactId("poi.*")` namespace per D-079. `PointOfInterest` component with name, location, category, discovery source, visibility rules. Discovery via `KnowledgeEventType::KnowledgeGranted` with `Fact` variant.
### #232 — Protocol versioning
Standalone infrastructure. Version field in protocol messages, compatibility checking, migration strategy. No gameplay change.
## PR Workflow
When ready to submit, create a PR with `tea` CLI:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(server): sprint 17 description" --description "body" --base main --head server
```
+61
View File
@@ -0,0 +1,61 @@
# Sprint 17: Tell — Visual Tasks
**Goal:** NPCs volunteer information unprompted and express personality through delivery; the player can read time and orientation at a glance from the insert HUD; POI infrastructure lands on the server.
**Branch:** `visual`
**Agents:** Araminta (art direction)
## Carry-over from Sprint 16
None. Sprint 16 visual ticket (#540, sprite camera angle) will close before Sprint 17.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #314 | Insert/HUD wireframe and spec — dual character variants | — (blocker #303 done) |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/perception.md` — D-019 (camera and perception), D-033 (entity color palette)
- `decisions/scope.md` — D-051 (diegetic insert/minimap)
- `decisions/content.md` — D-024 (NPC 10 axes, relationship display)
## Notes
### #314 — Insert/HUD wireframe and spec — dual character variants
**What exists:** D-051 defines the diegetic insert/minimap concept. D-033 defines the entity color palette (Unknown teal, Known green, PersonOfInterest amber, Hostile red). No wireframe or visual spec exists yet for the insert overlay.
**What to deliver:** Wireframe and visual specification for the lattice overlay HUD with dual-character variants:
**Smuggler variant:**
- Social contacts displayed as dots using D-033 relationship colors
- Casual labels ("The Last Shift", "Kael's usual spot")
- Time display (feeds #263 client implementation)
- Informal, street-level aesthetic — the smuggler's network view
**Detective variant:**
- POIs as amber diamonds with formal labels
- Commission-style grid overlay
- Time display with shift indicators
- Analytical, procedural aesthetic — the detective's investigation tool
**Output format:** Wireframe images (PNG) + written spec document describing layout, color usage, typography, and interaction states. Use `/frame0-wireframe` if Frame0 is available, otherwise produce annotated mockups.
**Integration:** This spec directly feeds #263 (client, time display) and future #151 (minimap rendering). The client team needs placement coordinates and visual hierarchy from this spec.
## Dependency Chain
```
#314 (Insert/HUD wireframe) → standalone, informs #263 (client)
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(visual): sprint 17 description" --description "body" --base main --head visual
```
@@ -0,0 +1,521 @@
# Workshop Round 1 — Dudley: Implementation Feasibility Analysis
**Workshop:** Knowledge Flow & NPC Information Boundaries
**Author:** Dudley (Server Developer)
**Date:** 2026-02-23
**Focus:** Rust implementation feasibility, integration with existing systems, edge cases
---
## Framing
After reading all referenced files, I can confirm Tyre's audit is accurate and complete. The simulation guarantees state consistency for everything it already touches — the problem is the empty spaces where state transitions have no code to run. My job here is to determine the precise implementation shape for each gap, flag edge cases, and push back on anything that will create determinism problems.
One constraint runs through all five topics: **every state mutation must flow through an event or a system, never through direct component mutation in a calling system.** This is D-010 principle 4. Any design that requires "update the KG in the dialogue handler" instead of "push an event for the KG system to process" is inadmissible. I'll note where this creates architectural pressure.
---
## Topic 1: Knowledge Flow — The Grant Mechanism
### State of the code
`server/src/content/types.rs` lines 494-498:
```rust
pub struct KnowledgeGrant {
pub fact_id: String,
pub confidence: String,
}
```
`server/src/content/line_pool.rs` line 297:
```rust
pub knowledge_grant: Option<types::KnowledgeGrant>,
```
The field is deserialized and indexed, but nothing reads it at runtime. The grant never fires.
### Where the grant fires: line selection, not client display
The grant must fire server-side at line selection — not when the client displays the text. Reasons:
1. D-010 principle 4: deterministic simulation requires state changes to occur at a specific tick, tied to an input event (the Talk verb). Client display timing is non-deterministic.
2. The `process_talk_interaction` system already has access to the selected `IndexedDialogueLine`. This is the natural injection point.
3. Firing on client display would require a client→server acknowledgment round-trip, adding IPC complexity.
**Implementation path:** After the weighted line selection in `process_talk_interaction`, if `selected_line.knowledge_grant.is_some()`, push a `KnowledgeEventType::KnowledgeGranted` event into `KnowledgeEventQueue`. The existing `process_knowledge_events` system (events.rs line 85) handles it on the same tick.
### New event type
`server/src/knowledge/events.rs` — add to `KnowledgeEventType` enum:
```rust
KnowledgeGranted {
recipient: Entity, // who receives the knowledge
fact_id: FactId,
confidence: KnowledgeConfidence,
source_id: StableId, // NPC who told them (ToldBy)
}
```
The `recipient` is the observer entity (player or NPC). The `source_id` is the NPC's StableId — available via `registry.to_stable(npc_entity)` at the point of line selection.
### Confidence string parsing
`KnowledgeGrant.confidence` is a `String` in the YAML schema. We need a parsing step to convert `"KnowsOf"` → `KnowledgeConfidence::KnowsOf`. I recommend this at content index time (when `IndexedDialogueLine` is built), not at runtime grant time. A `TryFrom<&str> for KnowledgeConfidence` implementation on the type is ~6 lines.
If parsing fails on an unknown confidence string, the content indexer should emit a validation error and reject the YAML file. Do not silently default — silent defaults hide content author errors.
### Entity grants vs fact grants
The current `KnowledgeGrant` schema only covers facts (`fact_id`). The brief asks whether entity-knowledge grants are needed.
**My position: defer entity grants to Sprint 18.** The mechanics required for Sprint 17 (#141, #149 POI discovery) are fact-based. Entity grants introduce a harder problem: the content author must specify the target entity by `fact_id`-style reference (e.g., `"entity.kael"`) which requires a separate entity name registry that doesn't exist yet.
For THE FRIEND arc (D-034), Sera says "Kael was at the dock during second shift." The grant here is: `FactId("entity.kael.location.second_shift_dock")` with confidence `KnowsOf`. This is a fact grant that encodes the entity + location + time context as a structured fact ID. The contradiction detector can then match it against the player's direct observation. This approach works without entity grants.
The `EntityKnowledge.known_attributes` field (`BTreeMap<String, String>` in types.rs line 171) can receive population via fact grants that encode attribute updates — but this is awkward. A proper entity grant schema is a Sprint 18 design task.
### POI discovery
Option (a) — `FactId("poi.dock_7_restricted")` — is the correct choice. Reasons:
1. POIs are locations/things, not entities. `FactKnowledge` (types.rs line 177) models exactly this.
2. `filter_by_access` (`KnowledgeGated` variant, graph.rs line 299) already gates on `FactId` presence. POI visibility gating works immediately with no new access control code.
3. The `"poi.*"` namespace is self-documenting in YAML.
4. No new type is needed. `KnowledgeGraph.facts` already stores arbitrary `FactId`.
The POI discovery system (#149) pushes a `KnowledgeGranted` event with `fact_id = FactId("poi.dock_7_restricted")` when the player enters the POI's trigger zone. Same event type, same queue, same processing system. No special-casing.
### Physical evidence discovery
Physical evidence (terminals, documents, manifests) should use the same `KnowledgeGranted` event, not a separate `EvidenceDiscovered` type. Adding a new event type when the existing mechanism handles it cleanly violates the simulation's guarantee of a minimal, composable event set.
The *rendering* distinction between "NPC told you" and "you found a document" is a client-side concern. Server-side, both are `KnowledgeGranted` with different `KnowledgeSource` variants: `ToldBy` vs `DirectObservation`. The source is already tracked per-entry (types.rs line 88-102).
### Content author guardrails
At content index time (when `LinePoolIndex` is built), validate:
1. `confidence` string parses to a valid `KnowledgeConfidence` variant
2. `fact_id` conforms to the `"category.topic"` format
Runtime enforcement (NPC can only grant facts it knows) is **Tier 3 difficulty** and should not be Sprint 17 scope. It requires cross-entity KG queries at line selection time, which adds significant query complexity to an already-complex system.
For Sprint 17: authoring-time validation only. Document the constraint: "NPCs should only grant facts consistent with their character background and role." This is enforced by content review, not runtime checks.
### Summary: Topic 1 implementation cost
- New `KnowledgeEventType::KnowledgeGranted` variant: **~30 lines**
- `process_knowledge_events` handler for new variant: **~20 lines**
- `TryFrom<&str> for KnowledgeConfidence` parsing: **~10 lines**
- Hook in `process_talk_interaction` after line selection: **~15 lines**
- Content index validation: **~20 lines**
- Total: **~95 lines of new code**, zero schema changes, zero breaking changes
**Tyre's 2-day estimate is accurate for the fact-only path.**
---
## Topic 2: NPC-to-NPC Knowledge Propagation (Q-024)
### The conversation system is the right hook
`run_npc_conversations` at `server/src/simulation/conversation.rs` line 278+ already provides:
- Proximity pairing with deterministic StableId sort (line 321-324)
- Cooldown management (line 314-319)
- Conversation lifecycle (start tick, end tick, line timing)
This satisfies "queued at routine intersections." No separate system needed for the pairing logic.
### The critical ECS constraint
`run_npc_conversations` currently queries NPCs with:
```rust
npc_query: Query<(Entity, &TilePosition, Option<&NpcName>, ...), (With<Npc>, With<ActiveSim>)>
```
There is no `&mut KnowledgeGraph` in this query. To perform knowledge transfer, we need mutable access to the KGs of both conversation participants. **Bevy ECS does not allow two mutable borrows of the same component type from a single query in the same system.** This is the primary structural constraint.
**Solution: separate `transfer_npc_knowledge` system.** This system runs immediately after `run_npc_conversations` completes, reads the `NpcConversation` components (which identify both participants), and performs the transfer. The system ordering guarantee: `transfer_npc_knowledge.after(run_npc_conversations)` ensures transfer happens on the same tick as conversation initiation.
The transfer system structure:
```rust
pub fn transfer_npc_knowledge(
time: Res<SimulationTime>,
conversations: Query<(Entity, &NpcConversation, Option<&StableEntityId>), With<Npc>>,
mut kg_query: Query<&mut KnowledgeGraph>,
relationship_query: Query<&Relationships, With<Npc>>,
mut event_queue: ResMut<KnowledgeEventQueue>,
) { ... }
```
Using `kg_query.get_many_mut([entity_a, entity_b])` — bevy_ecs supports this for distinct entities.
### Trust-gated filtering and confidence downgrade
Trust tier mapping (D-028 to NPC-NPC transfer):
| Trust level | What A shares with B |
|-------------|---------------------|
| `surface` (trust_level 0-2) | `KnowledgeState::Active` facts with `confidence >= KnowsOf` only |
| `real` (trust_level 3-6) | Also `Suspects` facts + entity knowledge at `KnowsOf` or above |
| `secret` (trust_level 7-10) | Also entity knowledge at `Suspects` (rumors) |
Confidence downgrade:
```rust
let transferred_confidence = source_entry.confidence.min(KnowledgeConfidence::KnowsOf);
```
This works because `KnowledgeConfidence` derives `Ord` with load-bearing ordering assertions at types.rs lines 50-55. `Direct` downgrades to `KnowsOf`. `KnowsDetails` downgrades to `KnowsOf`. `Suspects` stays `Suspects`. `KnowsOf` stays `KnowsOf`. The cap is clean and prevents knowledge amplification through gossip chains.
### Rate limiting
1-3 facts per conversation, drawn via `rng.rng.random_range(1..=3)` at conversation start. The cap goes in `NpcConversation`:
```rust
pub struct NpcConversation {
...
pub knowledge_transfers_remaining: u8, // set at start, decremented per fact
}
```
Transfer happens once per conversation (at conversation start tick), not on each line emission. The conversation is already tracked; we transfer when `NpcConversation` is first attached.
### ToldBy source construction
`KnowledgeSource::ToldBy { source_id: StableId, tick: u64 }` (types.rs line 97) is defined but never constructed. Construction point:
```rust
let source_sid = registry.to_stable(entity_a)
.expect("NPC in conversation must be registered");
let source = KnowledgeSource::ToldBy {
source_id: source_sid,
tick: time.tick,
};
```
The StableId is available. The tick is in `SimulationTime`. This is a 2-line construction. The `expect()` is justified — any NPC in an active conversation must have been registered at spawn.
### Overheard knowledge by player
The overheard grant should fire for the player entity when `distance <= VOICE_RANGE_TILES` AND the conversation had a knowledge transfer. The player's confidence should be `Suspects` unconditionally — the player doesn't know what was said, only that information was exchanged.
**Implementation constraint:** the player's overheard grant requires knowing which facts NPC A transferred to NPC B during the conversation. This means the transfer system must also push a `KnowledgeGranted` event for the player (or: record the transfer in a `RecentConversationTransfer` resource that the observer system reads).
For Sprint 17: **overheard knowledge at `Suspects` for the NPC entity (not the specific facts).** The player learns "Kael was being discussed" not "Kael was discussed in relation to dock schedules." The specific fact transfer to player can wait for Sprint 18 when content-authored conversation lines replace placeholder lines.
### Summary: Topic 2 implementation cost
- New `transfer_npc_knowledge` system: **~80 lines**
- `NpcConversation` struct addition (`knowledge_transfers_remaining`): **~5 lines**
- Trust-tier filter helper: **~30 lines**
- Overheard entity grant (Suspects confidence): **~20 lines**
- Total: **~135 lines**, no breaking changes
**Tier 1 for the core transfer. Tier 2 for overheard fact precision.** Q-024 resolves with the core transfer.
---
## Topic 3: Unprompted Disclosure Design (#172)
### Architecture constraint: where does disclosure live?
The dialogue pipeline layers in D-028 are: access → situation → trust → topic+mood scoring. Layer 4 is "unprompted disclosure." Currently Layer 4 only fires when the player initiates (Talk verb creates `TalkRequest`).
For unprompted disclosure, the NPC initiates. This means:
1. A new system `derive_disclosure_candidates` runs each tick on Active-tier NPCs with a KG
2. It writes `DisclosureCandidates` component (new component, per-NPC)
3. A new system `process_unprompted_disclosure` checks whether conditions are met, then pushes a dialogue line to `DialogueResponseBuffer` (or a new `UnpromptedDisclosureBuffer`)
### DerivedTellState vs separate component
I recommend a **separate `DisclosureCandidates` component** rather than adding `disclosure_candidates: Vec<FactId>` to `DerivedTellState`. Reasons:
1. `DerivedTellState` is read by the observer snapshot system — adding a `Vec<FactId>` to it would serialize unnecessary data over the bridge
2. `DisclosureCandidates` may be empty (and often will be) — keeping it separate avoids allocating a `Vec` on every Active NPC every tick
```rust
#[derive(Component, Debug, Default)]
pub struct DisclosureCandidates {
pub candidates: Vec<FactId>,
pub last_derived_tick: u64,
}
```
### Candidate selection algorithm
```
For each Active NPC with KG:
candidates = npc_kg.known_facts_iter()
.filter(|(_, fact)| fact.state == KnowledgeState::Active)
.filter(|(_, fact)| fact.confidence >= KnowsOf)
.filter(|(fact_id, _)| !matches trust gate against player relationship)
.take(3)
.collect()
```
The NPC does NOT check the player's KG (Option A). This is:
1. Correct per D-010 principle 3 — no entity-special-casing
2. Realistic — NPCs don't know what you know
3. Architecturally clean — avoids cross-entity KG queries in a per-NPC system
Repeated disclosure of known facts is acceptable. The rate limiting (see below) prevents spam.
### Trait filtering (#173)
From my implementation perspective: traits should filter WHAT is disclosed, not just HOW. The reason is architectural: if traits only affect delivery, they're a line-pool modifier and we already have that mechanism via `tags`. But if a cautious NPC systematically withholds certain fact categories, that requires filtering the `DisclosureCandidates` set — a KG-level operation.
Implementation: traits become a `candidate_filter` function applied to the candidates list. A `Cautious` trait drops facts with `source == ToldBy` (NPC won't pass on rumors). A `Gossipy` trait includes `Suspects` confidence facts. This is a trait-to-filter-predicate mapping, ~20 lines per trait.
### Trigger conditions
My proposed conditions (as system checks in `process_unprompted_disclosure`):
1. `trust_toward_player >= SURFACE_THRESHOLD` (read from NPC's `Relationships` component)
2. `mood_state != Hostile` (hostile NPCs don't volunteer info)
3. `DisclosureCandidates.candidates.is_not_empty()`
4. Per-NPC disclosure cooldown not active (new `DisclosureCooldown` component)
The `SURFACE_THRESHOLD` should be configured per-NPC role (not global). Content-authored as a YAML field.
### Rate limiting
The existing `LINE_COOLDOWN_TICKS = 600` (dialogue.rs line 39) applies to individual lines. For unprompted disclosure, we also need a per-NPC cooldown to prevent one NPC bombarding the player:
```rust
#[derive(Component, Debug)]
pub struct DisclosureCooldown {
pub until_tick: u64,
pub last_disclosed_facts: BTreeSet<FactId>, // prevent fact repetition
}
```
The `last_disclosed_facts` set caps per-fact disclosure rate. Once a fact has been disclosed, it's excluded from candidates until the cooldown expires.
Global rate limit (prevent disclosure spam from multiple NPCs simultaneously): a per-tick counter on `SimulationTime` or a resource `DisclosureThisTick: u8`. Cap at 1 unprompted disclosure per tick globally. Simple, deterministic.
### Summary: Topic 3 implementation cost
- `DisclosureCandidates` component: **~15 lines**
- `derive_disclosure_candidates` system: **~50 lines**
- `DisclosureCooldown` component: **~15 lines**
- `process_unprompted_disclosure` system: **~60 lines**
- Trait filter predicates (3 basic traits): **~40 lines**
- Total: **~180 lines**, requires Paula + Gestalt to specify candidate selection criteria before implementation
**Tier 2. The pipeline exists; the NPC-side candidate derivation is the new work.** Cannot implement until this workshop produces: (a) the disclosure candidate selection algorithm and (b) the trait filter/delivery split decision.
---
## Topic 4: NPC Information Boundaries (#142)
### Priority order with implementation complexity
| System | Complexity | Value |
|--------|-----------|-------|
| `tell_state.rs` (add KG awareness) | Tier 1 | High |
| `conversation.rs` (KG-aware partner selection) | Tier 2 | Medium |
| `routine.rs` (KG-aware routine execution) | Tier 3 | Low for v0.1 |
| `path_follow.rs` (KG-aware pathfinding) | Tier 3 | Risky — see below |
### Minimum viable boundary: tell_state.rs
The `derive_tell_state` system at tell_state.rs line 129 queries six components. Adding `&KnowledgeGraph` to the query is a one-line addition:
```rust
pub fn derive_tell_state(
mut npcs: Query<
(
&Secret,
&ToleranceThreshold,
&Contentment,
&MoodState,
Option<&Relationships>,
Option<&RoutineDeviation>,
Option<&KnowledgeGraph>, // new
&mut DerivedTellState,
),
(With<Npc>, With<ActiveSim>),
>,
) {
```
The KG awareness change: suppress `Guarded` and `Nervous` tells if the NPC has `FactKnowledge` for `"meta.secret.safe"` or similar fact indicating their secret hasn't been threatened. This is a KG-gate on tell derivation. Practically: if the NPC's own KG has no entry suggesting the secret is at risk, show fewer tells.
More immediately useful: an NPC that KNOWS the player suspects them (because it has a `ToldBy` entry from another NPC saying "the detective is asking about you") should have elevated stress contributing to tell derivation. This is a new compute path, not just a filter.
**For Sprint 17 minimum viable: add `Option<&KnowledgeGraph>` to the query but only use it as a null check — NPCs without a KG retain current axis-based behavior, NPCs with a KG can have their tell overridden by specific KG facts.** This preserves all existing tests while opening the KG pathway.
### Conversation partner selection
Adding KG awareness to partner selection requires checking: "does NPC A know NPC B exists (i.e., has an EntityKnowledge entry for B's StableId)?"
The pairing loop at conversation.rs lines 332-371 currently pairs any two proximate eligible NPCs. With KG: pair only if `npc_a_kg.knows_entity(&npc_b_sid)`.
**Edge case:** brand-new NPCs have empty KGs. If neither NPC knows the other, they never converse — which breaks the natural "first meeting" scenario. Resolution: NPCs can converse with `Unknown` entities (proximity-driven meeting), but knowledge transfer on first meeting is minimal (only public facts, regardless of trust tier). Trust tier defaults to `surface` when there is no relationship history.
The query constraint: `run_npc_conversations` at line 284 already has 8 fields in the query tuple. Adding `Option<&KnowledgeGraph>` makes 9. Bevy ECS supports this but query ergonomics degrade. The conversion to KG-aware pairing is Tier 2 because of the query complexity, not because the logic is hard.
### Pathfinding: DO NOT retrofit for v0.1
`path_follow.rs` using KG for walkability creates a failure mode with no safe recovery path in v0.1: NPC forgets a path node is blocked → NPC navigates into a blocked tile → physics/movement system conflicts → undefined behavior. The fallback must be ground truth, and if the fallback is always ground truth, the KG check adds complexity without behavioral change.
**Recommendation: no pathfinding KG retrofit until v0.2.** Document as deferred decision.
### D-026 tier interaction
Background-tier NPCs should receive NO KG-based boundary changes. The `With<ActiveSim>` filters in both `derive_tell_state` and `run_npc_conversations` already enforce this. The tier boundary is already correct in the existing code.
### Summary: Topic 4 implementation cost (MVP only)
- Add `Option<&KnowledgeGraph>` to `derive_tell_state` query + minimal KG gate: **~25 lines**
- KG-aware conversation partner matching (Sprint 17 scope decision needed): **~30 lines**
- Total MVP: **~25-55 lines** depending on scope decision
**Tier 1 for tell_state MVP. Tier 2 for conversation pairing.** Pathfinding deferred.
---
## Topic 5: Contradiction Detection Pipeline (Q-026)
### Prerequisite dependency is real and hard
The detection algorithm requires `ToldBy` entries. Without Topics 1 and 2 being implemented first:
- `KnowledgeSource::ToldBy` is never constructed (confirmed — it exists at types.rs line 97 but no code calls it)
- There is nothing to contradict against `DirectObservation` entries
- The detection system would scan KGs and find zero contradiction candidates on every tick
**The simulation guarantees: if `ToldBy` sources don't exist, `detect_contradictions` is a no-op. It will not error. But it will also do nothing useful.** Topics 1 and 2 are genuinely blocking.
### Location contradiction algorithm
For each entity E in observer KG where `last_known_position.is_some()`:
```
// Check all knowledge entries for E
// (currently one entry per entity — extension needed for multi-source tracking)
```
**Critical architectural gap:** `EntityKnowledge` (types.rs line 154) is a single struct per target entity. It holds ONE `last_known_position`, ONE `source`, ONE `confidence`. It cannot represent "NPC said X was at Dock" AND "I saw X at Corridor B-7" simultaneously — the second observation overwrites the first.
**This is the core design problem for contradiction detection.** To detect contradictions, we need to retain multiple conflicting observations. Options:
A. Add `Vec<PositionClaim>` to `EntityKnowledge` alongside the primary entry
B. Extend the `known_attributes` map with structured claim keys (e.g., `"claim.tick.1234.position" = "dock_7"`)
C. Create a separate `ContradictionCandidate` component attached to entities that need cross-source comparison
**My recommendation: Option B for Sprint 17 (lowest structural impact).** Encode position claims as structured attribute strings. The contradiction detector parses them. Ugly but zero schema change. Option A is cleaner and should be the Sprint 18 refactor target.
Option B implementation:
```
key: "claim.{tick}.position"
value: "{x},{y},{z},{source_stable_id}"
```
Contradiction detection then iterates `known_attributes` keys matching `"claim.*"`, parses the values, and checks for conflicting positions within a configurable tick window.
### Detection timing
Event-driven is correct. Run `detect_contradictions` as part of `process_knowledge_events` handling — check for contradictions only when a new `KnowledgeGranted` or `DirectObservation` event is processed. Do not run as a separate per-tick system.
Implementation: after applying a `KnowledgeGranted` event to the KG, call `check_for_contradictions(&mut kg, target_stable_id, tick)` inline. This is ~40 lines of detection logic added to `process_knowledge_events`.
### Event emission chain for THE FRIEND arc
Walk through the full sequence with the proposed design:
1. **Dialogue selection tick T1:** Sera's line "Kael was at the dock during second shift" is selected. `knowledge_grant` on the line fires → `KnowledgeGranted { fact_id: FactId("entity.kael.position.second_shift"), confidence: KnowsOf, source_id: sera_sid }` pushed to queue.
2. **`process_knowledge_events` tick T1:** Event processed → `kg.facts.insert(FactId("entity.kael.position.second_shift"), FactKnowledge { confidence: KnowsOf, source: ToldBy { source_id: sera_sid, tick: T1 }, ... })`. Also: add structured attribute claim to Kael's `EntityKnowledge`: `"claim.T1.position" = "dock_7,T1,sera_sid"`.
3. **`detect_contradictions` check tick T1:** No DirectObservation of Kael at a different position yet. No contradiction.
4. **DirectObservation tick T2:** Player sees Kael in corridor B-7. `DirectObservation` event → `observe_entity(kael_sid, corridor_B7, T2)`. Also adds structured attribute claim: `"claim.T2.position" = "corridor_b7,T2,direct"`.
5. **`detect_contradictions` check tick T2:** Parses claims for Kael. `"claim.T1.position" = "dock_7"` vs `"claim.T2.position" = "corridor_b7"`. T2 - T1 < contradiction_window_ticks. Positions differ → **CONTRADICTION DETECTED**. Both KG entries receive `KnowledgeState::Contradicted`. `ContradictionDetected` event pushed (new event type, or reuse `KnowledgeEventQueue` with a new variant).
6. **`detect_anomalies` tick T3:** Iterates player KG, finds Kael with `state == Contradicted` → attaches `AnomalyMarker` to Kael entity. (anomaly.rs line 64 — this fires on `Contradicted` already.)
7. **Monologue system tick T3+:** `ContradictionDetected` event triggers selection of contradiction monologue line: "Sera said Kael was at the dock. I just saw him in B-7." (Content team writes this line; the trigger tag fires here.)
8. **Relationship update:** `set_relationship(&kael_sid, RelationshipState::PersonOfInterest)` → D-033 amber color transition.
**The simulation guarantees this chain fires correctly given the proposed event additions.** The existing downstream consumers (anomaly.rs, monologue.rs) already handle `Contradicted` state — tested and passing.
### Attribute contradiction
`known_attributes: BTreeMap<String, String>` is untyped (types.rs line 171). Typed attribute keys are a Sprint 18 concern. For Sprint 17:
- Location contradictions: algorithmic (position claim parsing as described above)
- Attribute contradictions: **content-authored only** — a YAML file defines explicit contradiction pairs. The detector checks a lookup table: `if known_attributes.get("role") == Some("legitimate") && kg.knows_fact(&FactId("entity.kael.smuggler")) → CONTRADICTION`.
This is a simple O(N) check per KG update, where N is the authored contradiction pair count.
### New types needed
```rust
// In events.rs
KnowledgeEventType::ContradictionDetected {
observer: Entity,
entity_a: StableId,
entry_a_source: KnowledgeSource,
entity_b: StableId, // same as entity_a for location contradictions
entry_b_source: KnowledgeSource,
contradiction_type: ContradictionType,
}
pub enum ContradictionType {
Location,
Attribute { key: String },
FactConflict { fact_id_a: FactId, fact_id_b: FactId },
}
```
The monologue system subscribes to `ContradictionDetected` events and selects the appropriate content line based on the `ContradictionType`.
### Summary: Topic 5 implementation cost
- `EntityKnowledge` structured claim addition (Option B): **~15 lines type + ~20 lines claim recording**
- `detect_contradictions` function (location only): **~60 lines**
- `ContradictionDetected` event variant + type: **~20 lines**
- Monologue subscription to contradiction events: **~30 lines**
- Content-authored attribute contradiction table: **~25 lines + YAML schema**
- Total: **~170 lines**, plus content schema
**Tyre's 3-day estimate for location detection + event chain is correct.** The critical gap is the `EntityKnowledge` single-entry model — this must be addressed (Option B minimally, Option A properly) before detection can work.
---
## Cross-Topic Dependencies and Implementation Order
The simulation guarantees a specific ordering constraint:
```
Topic 1 (KnowledgeGranted event + dialogue wire)
↓ enables
Topic 2 (ToldBy source construction + NPC-to-NPC transfer)
↓ enables
Topic 5 (contradiction detection — needs ToldBy entries to exist)
```
Topics 3 and 4 are partially independent:
- Topic 4 MVP (`tell_state.rs` KG awareness) can be implemented in parallel with Topic 1
- Topic 3 (`DisclosureCandidates` system) requires Topic 2 to have populated NPC KGs with meaningful content
- Topic 3 requires Paula + Gestalt decisions on candidate selection and trait behavior before implementation
**Recommended implementation order for Sprint 17:**
1. Topic 1 fact grant mechanism (unblocks everything)
2. Topic 4 MVP (tell_state KG awareness — isolated, low risk)
3. Topic 2 NPC-to-NPC transfer (unblocks Topic 5)
4. Topic 5 location contradiction detection (immediately useful for THE FRIEND arc)
5. Topic 3 unprompted disclosure (requires decisions from this workshop + populated NPC KGs)
---
## Open Positions for Round 2 Discussion
1. **Entity grant schema:** I've proposed deferring to Sprint 18 and encoding entity-knowledge grants as structured FactIds. Tyre's opinion needed on whether the single-entry `EntityKnowledge` model needs an architectural fix before Sprint 17 or can carry the Option B workaround.
2. **`EntityKnowledge` multi-source model:** The contradiction detection gap (single entry per entity) is the most structurally significant problem in this workshop. My Option B (structured attributes) is a workaround. What's the correct path to Option A (proper multi-claim model)?
3. **Overheard knowledge precision:** I've proposed `Suspects` confidence for overheard NPC-NPC conversations regardless of occlusion fidelity. Is this the right tradeoff, or does the game need per-fact overheard grants for a mechanically satisfying experience?
4. **Trait filtering (#173):** My position is traits should filter WHAT is disclosed AND HOW it is delivered. Paula needs to confirm whether the narrative design requires both, or if HOW-only is sufficient for the first NPC characters.
5. **Q-025 formal closure:** Tyre's 30KB memory analysis is correct at current NPC counts. I support formal closure. At 500+ Active NPCs, the question should be reopened. This can be a one-line decision.
@@ -0,0 +1,494 @@
# Workshop Round 2 — Dudley: Cross-Review and Synthesis
**Workshop:** Knowledge Flow & NPC Information Boundaries
**Author:** Dudley (Server Developer)
**Date:** 2026-02-23
**Focus:** Tension resolution, implementation ticket specs, implementation order
---
## Reading Summary
After reviewing all Round 1 outputs:
- Tyre and Gestalt have converged on architecturally identical solutions for Tension B (`ContradictionClaim` struct). Different field names, same structure.
- Paula makes the blocking case for Tension A (entity grants in Sprint 17) that I cannot dismiss on architectural grounds — only on infrastructure grounds I am now reconsidering.
- Gestalt raises the only substantive dissent (no global disclosure rate limit) that I will address.
- Two open questions are directed at me specifically: the `DisclosureCandidates` compute trigger and the StableId→name lookup in the monologue system. Both have answers.
---
## Tension A: Entity Grants — Position Change
**I am changing my position. Entity grants are Sprint 17 scope.**
My Round 1 concern was: entity grants require a "name → StableId" content registry that doesn't exist, and building it is non-trivial. Let me examine this more carefully.
### The actual infrastructure needed
Tyre's proposal stores `entity_ref: "kael"` in the YAML and resolves it to `StableId` at content load via a `BTreeMap<String, StableId>`. The question I raised: where does this map come from?
The answer: **resolve at spawn time, not content load time.** When an authored NPC is spawned from its content definition (which includes an authored identifier like `"kael"`), the spawn system inserts `"kael" → StableId(N)` into a new `ContentEntityRegistry` resource. The content definition already has the authored identifier — NPCs need to be referenced in dialogue YAML. The spawn registration is ~5 lines per spawn site.
At grant processing time, `process_knowledge_events` queries `ContentEntityRegistry` to resolve `entity_id: "kael"` → `StableId(N)`. If the entity is not yet in the registry (not yet spawned), the grant is dropped with `tracing::warn!` and a log entry. This is graceful — no panic, no undefined behavior.
```
ContentEntityRegistry: BTreeMap<String, StableId>
|
├── Populated at: NPC spawn from authored content
├── Read by: process_knowledge_events (entity grant processing)
└── Size: O(authored NPCs) — small, ~20-50 entries for v0.1
```
This is genuinely minimal infrastructure. I was treating it as a large unknown; it is a ~60-line addition.
### Why my workaround was worse
My Round 1 alternative — structured FactId strings like `FactId("entity.kael.position.second_shift_dock")` — fails Paula's test: contradiction detection operates on `EntityKnowledge.last_known_position`, not on `FactKnowledge`. A `DirectObservation` updates `EntityKnowledge` (via `observe_entity`). If Sera's testimony only creates a `FactKnowledge` entry, the contradiction detector in `observe_entity` has nothing to compare against. The chain breaks at step 4 of THE FRIEND arc.
Tyre and Gestalt are correct. Paula's blocking case is sound.
### Confirmed schema: two-type grant
For Sprint 17: `FactGrant` and `EntityGrant`. `Compound` (grants that do both simultaneously) can be Sprint 18.
```yaml
# Fact grant (existing format, backwards-compatible):
knowledge_grant:
fact_id: "poi.dock_7_restricted"
confidence: "knows_of"
# Entity grant (new format):
knowledge_grant:
entity_ref: "kael"
attributes:
location: "dock-7"
shift: "second"
confidence: "knows_of"
# source is always ToldBy { source_id: speaking_npc_sid, tick } — inferred by system
```
The Rust type:
```rust
// In server/src/content/types.rs — replaces current KnowledgeGrant struct
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum KnowledgeGrant {
Fact {
fact_id: String,
confidence: String,
},
Entity {
entity_ref: String,
#[serde(default)]
attributes: std::collections::BTreeMap<String, String>,
confidence: String,
#[serde(default)]
disclosure_blocked: bool,
},
}
```
The `#[serde(untagged)]` attribute allows the existing `fact_id/confidence` YAML format to deserialize as `Fact` variant without any content changes to existing YAML files. Backwards compatible.
---
## Tension B: ContradictionClaim Struct — Position Change
**I am conceding Option B. The `ContradictionClaim` struct approach is correct.**
Tyre and Gestalt have proposed structurally identical solutions. My Option B (encoding position claims as structured `known_attributes` strings) has three problems I underweighted:
1. **String parsing is fragile.** A malformed string `"claim.T1.position" = "dock_7,T1,sera_sid"` silently fails. The struct approach fails loudly at compile time.
2. **It doesn't help the monologue system.** The monologue needs to display "Sera said Kael was at the dock" — which requires `ToldBy { source_id: sera_sid }` as a typed field, not a parsed string. Paula's requirement for source-named contradiction monologues is correct and it requires the struct.
3. **Zero schema changes was a false economy.** One optional field on `EntityKnowledge` is a smaller change than the refactor obligation Option B creates.
### Agreed struct design
Aligning on Tyre's naming (`ContradictionClaim`) — the field is specifically "the claim that was contradicted," which is precise:
```rust
// In server/src/knowledge/types.rs — add to EntityKnowledge
pub struct EntityKnowledge {
// ... all existing fields unchanged ...
/// When state is Contradicted: the prior claim that conflicts with the
/// current observation. Populated by contradiction detection in observe_entity().
/// None while state is Active or Stale.
pub contradicted_claim: Option<ContradictionClaim>,
}
pub struct ContradictionClaim {
/// Who made the contradicted claim (typically ToldBy { source_id, tick }).
pub source: KnowledgeSource,
/// Where they claimed the entity was.
pub claimed_position: Option<TilePosition>,
/// Tick when the contradiction was detected.
pub detected_at_tick: u64,
}
```
Detection in `observe_entity()` at graph.rs line 111, before the overwrite:
```rust
// BEFORE updating the entry: check for contradiction
if let Some(entry) = self.entities.get_mut(&target) {
if let KnowledgeSource::ToldBy { tick: told_tick, .. } = entry.source {
if let Some(old_pos) = entry.last_known_position {
if old_pos != position
&& current_tick.saturating_sub(told_tick) < CONTRADICTION_WINDOW_TICKS
{
entry.state = KnowledgeState::Contradicted;
entry.contradicted_claim = Some(ContradictionClaim {
source: entry.source.clone(),
claimed_position: entry.last_known_position,
detected_at_tick: current_tick,
});
// Don't return — fall through to update position and source
}
}
}
}
```
The `CONTRADICTION_WINDOW_TICKS` constant: **600 ticks (1 game-hour)**. This is Gestalt's proposal. Rationale: within a game-hour, a claim about "where Kael was during second shift" is not stale — second shift hasn't ended. Beyond a game-hour, the decay system should mark entries Stale, not Contradicted. The contradiction is a fresh conflict, not an archaeology project.
---
## Open Questions Directed at Dudley — Answers
### DisclosureCandidates compute trigger
Gestalt asks: is there a "player entered dialogue range" event or does this need a range-query every N ticks?
**Answer: range-query in the existing Active NPC processing loop, every tick, gated on distance.**
There is no "player entered dialogue range" event — the game doesn't have a proximity-event system. The disclosure system should piggyback on the conversation system's existing proximity check pattern:
```rust
// In derive_disclosure_candidates system:
// Query Active NPCs within CONVERSATION_PROXIMITY of the player.
// Same O(N_active) scan the conversation system already does.
let player_pos = player_query.single().0;
for (npc_entity, npc_pos, npc_kg, ...) in npc_query.iter() {
let dist = npc_pos.manhattan_distance(player_pos).unwrap_or(u32::MAX);
if dist > DISCLOSURE_RANGE {
continue; // Skip NPCs far from player
}
// Derive candidates for this NPC
}
```
`DISCLOSURE_RANGE` can match `CONVERSATION_PROXIMITY` (3 tiles) or be slightly larger. This is O(N_active) per tick — at 30-80 Active NPCs, this is ~60 comparisons per tick. Negligible.
The `DisclosureCandidates` component acts as a cache. It's only populated for NPCs within range and expires after a configurable number of ticks (suggest 30 ticks = 3 game-minutes). If the NPC moves out of range or the player moves away, the component decays and is not repopulated.
### Monologue StableId → displayable name lookup
Gestalt asks: is `EntityRegistry + NpcName` available from the monologue system context?
**Answer: yes, with a small signature addition.**
The monologue system at `server/src/simulation/monologue.rs` currently takes:
- `ContentStoreResource`
- `SimRng`
- `SimulationTime`
- Player entity queries
Adding `EntityRegistry` and a `Query<&NpcName>` to the system signature is ~5 lines. The lookup chain:
```rust
fn resolve_name(
source_id: &StableId,
registry: &EntityRegistry,
name_query: &Query<&NpcName>,
) -> String {
registry.to_entity(source_id)
.and_then(|entity| name_query.get(entity).ok())
.map(|name| name.0.clone())
.unwrap_or_else(|| format!("Unknown({})", source_id.0))
}
```
This is available and works. The monologue system CAN produce "Sera said Kael was at the dock" as Paula requires.
For THE FRIEND arc specifically: Paula's Option 3 (generic fallback + hand-authored override) is the right authoring model. The monologue pool gets a `trigger: contradiction_detected` variant. The system selects a line, then substitutes `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` from the `ContradictionClaim` struct. For FRIEND-pattern NPCs (Sera, Kael), Mellanie authors specific lines that are selected first via the priority system; the generic template serves all other cases.
Parameterized string substitution is ~20 lines in the monologue emission path. Worth it for the emotional payoff.
---
## Secondary Disputes — Positions
### Global disclosure rate limit (3-vs-1 split with Gestalt dissenting)
Gestalt argues: a global limit is invisible to the player and creates unintelligible competition.
This is correct but incomplete. The global limit is NOT a gameplay-visible competition — it is a degenerate-case safeguard. Consider: the player enters a crowded room with 15 Active NPCs, all at Friendly relationship, all with disclosure candidates. Without a global limit, 15 NPCs may attempt disclosure in the same tick. The per-NPC disclosure cooldown (300 ticks) prevents repeat disclosures from ONE NPC but does nothing about simultaneous first-time disclosures from MANY NPCs.
**Resolution: include the global limit, but make it predictable rather than random.** NPCs are evaluated in `StableId` order (ascending). The first NPC in StableId order whose gates all pass fires its disclosure. The cap is 1 per tick. This means:
- The competition is deterministic (D-010 principle 4)
- The same-tick scenario resolves gracefully
- No NPC is silently blocked from EVER disclosing — they simply fire on a different tick
The cap is a tick-granularity concern only. Over game-minutes, every NPC with a valid disclosure eventually fires. Gestalt's concern applies to a cap that permanently suppresses NPCs; this one does not.
### Trust-weighted transfer count (Paula's 0-1/1-2/up-to-3 vs flat 1-3)
Paula's weighting adds a trust-tier dependency. I adopt it — it's cleaner narratively and the implementation is trivial:
```rust
let max_transfers = match trust_tier {
TrustTier::None => return, // no transfer
TrustTier::Surface => rng.random_range(0..=1),
TrustTier::Real => rng.random_range(1..=2),
TrustTier::Secret => rng.random_range(2..=3),
};
```
Surface trust NPC pairs may transfer 0 facts (nothing worth saying). This makes surface trust feel surface-level.
### Major secret `disclosure_blocked` flag (Paula's addition)
Paula's proposal: a per-KG-entry flag preventing transfer even at maximum trust tier.
**Include it.** Implementation: `disclosure_blocked: bool` field on `FactKnowledge` (default `false`). The `transfer_npc_knowledge` system skips entries where `disclosure_blocked == true`. Content authors mark Major-secret facts as `disclosure_blocked: true` in the initial KG YAML.
This is ~15 lines total and correctly models "some things you never tell anyone regardless of trust."
### Witness inhibition + location privacy gate (Gestalt + Paula additions)
Both are additive trigger gates for unprompted disclosure. I include both:
**Witness inhibition** (Gestalt): count Active NPCs within 5 tiles of the disclosing NPC. If `count > 2`, disclosure is suppressed unless the NPC's trait overrides it (a `Talkative` NPC ignores witnesses).
**Location privacy** (Paula): disclosure candidates can be tagged `"location_privacy: private|semi_private|any"` in content. The disclosure trigger checks the NPC's current location zone tag. A `private`-tagged fact won't fire at `The Terminal`. This creates the spatial behavior pattern Paula describes: "If you want Sera to confide, find her at Lera's."
Implementation of both gates: ~30 lines combined. Both use data already available in the system (NPC positions for witness count, location zone tags for privacy).
### Runtime NPC KG guardrail
Tyre proposes a 3-line runtime check; I called it Tier 3 in Round 1.
**I recalibrate: include it.** Tyre's 3-line check is straightforward once the entity grant architecture is in place. The check is:
```rust
// For fact grants: verify granting NPC knows the fact
if let Some(npc_kg) = npc_kg_query.get(granting_npc_entity).ok() {
if !npc_kg.knows_fact(&fact_id) {
tracing::warn!("NPC {} granted unknown fact {}", npc_sid.0, fact_id.0);
continue;
}
}
// For entity grants: verify granting NPC knows the target entity
// (check entities BTreeMap contains target_sid at appropriate confidence)
```
This handles the runtime KG decay case Gestalt identifies: a dialogue line remains eligible after the NPC's KG decays — the runtime check catches it. Include in Sprint 17.
---
## Implementation Ticket Specifications
### Ticket A: KnowledgeGrant schema + ContentEntityRegistry
**Depends on:** Nothing (foundational)
**Blocks:** Tickets B, D, E, F
**Changes:**
- `server/src/content/types.rs`: Replace `KnowledgeGrant` struct with `KnowledgeGrant` enum (`#[serde(untagged)]`); add `disclosure_blocked: bool` field to `Entity` variant
- `server/src/content/line_pool.rs`: Update `IndexedDialogueLine.knowledge_grant` type; update index building to parse both variants
- `server/src/knowledge/types.rs`: Add `disclosure_blocked: bool` to `FactKnowledge` (default `false`)
- New file `server/src/knowledge/content_registry.rs`: `ContentEntityRegistry` resource — `BTreeMap<String, StableId>` + `register(content_id, stable_id)` + `resolve(content_id) -> Option<StableId>`
- NPC spawn sites: add `content_registry.register(npc_content_id, sid)` call
**Line estimate:** ~120 lines total
---
### Ticket B: KnowledgeGranted event + process_knowledge_events handler
**Depends on:** Ticket A (ContentEntityRegistry, schema)
**Blocks:** Tickets D, E, F
**Changes:**
- `server/src/knowledge/events.rs`: Add `KnowledgeEventType::KnowledgeGranted` variant:
```rust
KnowledgeGranted {
recipient: Entity,
grant: ProcessedKnowledgeGrant,
granting_npc: Option<Entity>, // None for evidence/POI discovery
}
pub enum ProcessedKnowledgeGrant {
Fact { fact_id: FactId, confidence: KnowledgeConfidence },
Entity { target_sid: StableId, attributes: BTreeMap<String, String>, confidence: KnowledgeConfidence },
}
```
- `server/src/knowledge/events.rs`: Add match arm in `process_knowledge_events` for `KnowledgeGranted`:
- For `Fact`: `observer_kg.facts.insert(fact_id, FactKnowledge { confidence, source: ToldBy/DirectObservation, ... })`
- For `Entity`: `observer_kg.entities.entry(target_sid).or_insert_with(...)` with `source: ToldBy { source_id: granting_npc_sid, tick }`
- Runtime guardrail: if `granting_npc.is_some()`, verify granting NPC's KG contains the granted fact/entity before applying
- `server/src/knowledge/types.rs`: Add `TryFrom<&str> for KnowledgeConfidence` for parsing confidence strings
- `server/src/simulation/dialogue.rs`: In `process_talk_interaction`, after line selection, if `selected_line.knowledge_grant.is_some()`, push `KnowledgeGranted` event
**Line estimate:** ~150 lines
---
### Ticket C: ContradictionClaim struct + detection in observe_entity
**Depends on:** Nothing (changes only `types.rs` and `graph.rs`)
**Blocks:** Ticket F (contradiction monologue)
**Changes:**
- `server/src/knowledge/types.rs`: Add `ContradictionClaim` struct; add `contradicted_claim: Option<ContradictionClaim>` to `EntityKnowledge`; add `CONTRADICTION_WINDOW_TICKS: u64 = 600` constant
- `server/src/knowledge/graph.rs`: In `observe_entity()`, add pre-overwrite contradiction check (see algorithm above); push `ContradictionDetected` event when detected
- `server/src/knowledge/events.rs`: Add `KnowledgeEventType::ContradictionDetected { observer: Entity, entity_sid: StableId }` variant; add processing in `process_knowledge_events` that fires relationship shift for the `ToldBy` source entity
- `server/src/knowledge/graph.rs`: Tests — location contradiction, time window boundary, no-false-positive for non-ToldBy sources
**Line estimate:** ~120 lines + ~40 lines of tests
---
### Ticket D: NPC-to-NPC knowledge transfer system
**Depends on:** Ticket B (KnowledgeGranted event infrastructure)
**Blocks:** Ticket E (contradiction detection needs ToldBy entries)
**Changes:**
- New file `server/src/simulation/npc_knowledge_transfer.rs`: System `transfer_npc_knowledge`
- Queries `NpcConversation` components (identifies conversation pairs)
- Reads speaker's `KnowledgeGraph` + `Relationships` for trust-tier lookup
- Calls `KnowledgeEventQueue.push(KnowledgeGranted { ... })` for each transferred fact
- Trust-weighted rate: `Surface → 0..=1`, `Real → 1..=2`, `Secret → 2..=3` (SimRng drawn)
- Confidence downgrade: `min(source_confidence, KnowsOf)` via `KnowledgeConfidence::min()`
- Skips entries with `disclosure_blocked == true`
- `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: current_tick }`
- Overheard grant for player: if player within `VOICE_RANGE_TILES`, push entity-level `KnowledgeGranted` at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: Medium }`
- `server/src/simulation/conversation.rs`: Register `transfer_npc_knowledge.after(run_npc_conversations)` in system ordering
- ECS constraint note: `transfer_npc_knowledge` uses `get_many_mut([entity_a, entity_b])` for dual-mutable KG access — this requires the system to own the query, not inline it in `run_npc_conversations`
**Line estimate:** ~160 lines
---
### Ticket E: tell_state.rs KG awareness (MVP boundary #142)
**Depends on:** Ticket B (needed for KG to contain meaningful content)
**Blocks:** Nothing (standalone improvement)
**Changes:**
- `server/src/npc/tell_state.rs`: Add `Option<&KnowledgeGraph>` to `derive_tell_state` query
- Replace the `Friendly` tell's direct `relationships.entries` read (lines 105-112) with `kg.relationship_with(&entity_sid)` query against observed entities
- Note: self-axes (`Secret`, `Contentment`, `Tolerance`) remain ground-truth reads — KG applies to other-entity state only
- Tests: existing tests continue to pass (KG is optional, None falls back to current behavior)
**Line estimate:** ~35 lines
---
### Ticket F: Contradiction monologue + event chain completion
**Depends on:** Tickets B + C (ToldBy entries + ContradictionClaim struct)
**Blocks:** Nothing (downstream consumers already exist)
**Changes:**
- `server/src/simulation/monologue.rs`: Add `EntityRegistry` + `Query<&NpcName>` to system signature; add `resolve_name(source_id, registry, name_query)` helper (~15 lines); add match arm for `ContradictionDetected` events that selects contradiction monologue line with name substitution
- `server/src/knowledge/events.rs`: In `ContradictionDetected` processing, call `kg.set_relationship(&told_by_source_sid, RelationshipState::PersonOfInterest)` — shifts Sera to amber (D-033 downstream via existing pipeline)
- Monologue pool (content): Add `trigger: contradiction_detected` line category with `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` substitution tokens. Generic fallback line authored by Mellanie; FRIEND-specific lines separately authored.
- Test: THE FRIEND arc integration test — Tick 100 grant creates ToldBy, Tick 150 DirectObservation triggers contradiction, monologue fires with correct names, Sera shifts to PersonOfInterest, AnomalyMarker set on Kael
**Line estimate:** ~90 lines + content (Mellanie)
---
### Ticket G: DisclosureCandidates + Unprompted Disclosure system (#172)
**Depends on:** Tickets B + D (KG must have meaningful NPC content before this is useful)
**Blocks:** Nothing
**Changes:**
- New file `server/src/npc/disclosure.rs`:
- `DisclosureCandidates` component with `candidates: Vec<FactId>`, `entity_candidates: Vec<(StableId, String)>`, `computed_tick: u64`
- `DisclosureCooldown` component with `per_fact_history: BTreeSet<FactId>`, `npc_cooldown_until: u64`
- `derive_disclosure_candidates` system: range-query Active NPCs near player, filter NPC KG by trust tier + trait filters + `disclosure_blocked`, populate component
- `process_unprompted_disclosure` system: 5-gate check (trust ≥ surface, mood ≠ Hostile, contentment ≥ -10, witness count ≤ 2, location privacy gate), global rate limit (1 per tick, StableId-ordered), push line to dialogue pipeline if gates pass
- `server/src/simulation/dialogue.rs` integration: Layer 4 reads `DisclosureCandidates` for NPC-initiated dialogue selection
- System ordering: `derive_disclosure_candidates.after(process_knowledge_events)`, `process_unprompted_disclosure.after(derive_disclosure_candidates)`
**Line estimate:** ~220 lines
---
## Implementation Order with Dependencies
```
SPRINT 17 CRITICAL PATH:
Ticket A: KnowledgeGrant schema + ContentEntityRegistry (~120 lines)
│
├──→ Ticket B: KnowledgeGranted event + dialogue wire (~150 lines)
│ │
│ ├──→ Ticket D: NPC-to-NPC transfer system (~160 lines)
│ │ │
│ │ └──→ [ToldBy entries now exist in KGs]
│ │
│ └──→ Ticket E: tell_state KG awareness (~35 lines) ← can parallel with D
│
└──→ Ticket C: ContradictionClaim + detect in observe_entity (~160 lines)
│
└──→ [Requires ToldBy from B+D to fire. But struct can land independently.]
After B + C + D complete:
└──→ Ticket F: Contradiction monologue + event chain (~90 lines)
After B + D complete (KG populated with meaningful NPC content):
└──→ Ticket G: DisclosureCandidates + unprompted disclosure (~220 lines)
TOTAL: ~935 lines across 7 tickets
```
### Parallelization notes
- **Ticket A** must complete first. It's the schema foundation for everything else.
- **Tickets B and C** can develop in parallel after A. B wires the grant pipe; C wires the detection pipe. They don't conflict.
- **Ticket D** requires B's `KnowledgeGranted` event type to exist (it pushes events). B's system changes don't affect D's query structure.
- **Ticket E** is fully independent of D-F. It can be done any time after A if someone needs a small task.
- **Ticket F** requires both B (for ToldBy sources to exist) and C (for `ContradictionClaim` struct). It's the last step on the critical path.
- **Ticket G** (unprompted disclosure) has no hard dependency on C or F, but benefits greatly from D having populated NPC KGs. Implement after D.
---
## Unresolved Items — For the D-Record
The following are forming positions that need to be captured in the decision document:
| Item | Resolution |
|------|------------|
| Entity grants Sprint 17 | **Yes** — via `ContentEntityRegistry` (spawn-time registration) |
| `ContradictionClaim` struct vs attribute encoding | **`ContradictionClaim` struct** — Tyre/Gestalt approach adopted |
| `ContradictionClaim` field name | `contradicted_claim: Option<ContradictionClaim>` on `EntityKnowledge` |
| `CONTRADICTION_WINDOW_TICKS` | **600 ticks (1 game-hour)** |
| Global disclosure rate limit | **1 per tick, StableId-ordered** (deterministic, includes Tyre/Paula's cap, addresses Gestalt's concern) |
| Trust-weighted transfer count | **Paula's proposal adopted** (Surface 0-1, Real 1-2, Secret 2-3) |
| `disclosure_blocked` flag on FactKnowledge | **Include** — for Major secret no-transfer override |
| Witness inhibition gate | **Include** (count Active NPCs ≤5 tiles; threshold = 2) |
| Location privacy gate | **Include** (disclosure candidate tag: private/semi_private/any) |
| Runtime NPC KG guardrail | **Include** — 3-line check in KnowledgeGranted processing |
| Contradiction monologue authoring | **Option 3: generic template + hand-authored override for FRIEND-pattern NPCs** |
| `DisclosureCandidates` compute trigger | **Range-query per tick in Active NPC loop** — no special event needed |
| StableId → name in monologue system | **Available** — add EntityRegistry + Query<&NpcName> to system signature |
| Q-025 formal closure | **Close** — 30KB per Active NPC at current scale is not a constraint |
---
## Final Notes
The downstream chain for THE FRIEND arc is complete from step 3 onward. The simulation guarantees: once `ContradictionClaim` is stored and `KnowledgeState::Contradicted` is set, the anomaly system, relationship system, and D-033 color pipeline fire correctly without additional work — all tested and passing.
The two new structural pieces this workshop adds are `ContradictionClaim` on `EntityKnowledge` (Topic 5 prerequisite) and `ContentEntityRegistry` as a new resource (Topic 1 prerequisite). Both are minimal. Everything else is new systems that plug into existing, tested infrastructure.
State consistency across all 7 tickets: each ticket produces a well-defined state transition. None of them reach across system boundaries in ways that break existing determinism guarantees. The BTreeMap iteration order mandate (D-010 principle 4) is preserved throughout — `ContentEntityRegistry`, `DisclosureCandidates`, and the transfer system all use `BTreeMap` for deterministic ordering.
@@ -0,0 +1,467 @@
# Gestalt Round 1 — Systems Design Analysis
## Knowledge Flow & NPC Information Boundaries Workshop
**Author:** Gestalt
**Date:** 2026-02-23
**Focus:** Mechanical interactions, "is this fun?", knowledge as gameplay lever
---
## Framing: The Master Mechanic
Before diving topic-by-topic, let me crack my knuckles and establish the frame I'm evaluating everything through.
**Asymmetric information is the game.** Not a feature, not a pillar — the entire experience of The Settled Reach is "you know different things than the world does." Every system in this workshop is a mechanism for managing that asymmetry. The question I'm asking for each topic is: **does this design produce interesting player decisions about what to do with information?**
The current state is: the KG data model is a perfect asymmetric information engine (D-041), but it's running in idle. Knowledge enters it via perception. Nothing comes out of it into NPC behavior. This workshop is about turning the engine on.
Let me break down what that actually means mechanically for each topic.
---
## Topic 1: Knowledge Flow — The Grant Mechanism
### Mechanical position summary
This is the INPUT side of the knowledge engine. The key design question is: what is the canonical moment a fact "enters" the player's mental model as a game state change?
### 1.1 When does grant fire?
**Position: Fire on line selection, server-side, before snapshot emission.**
Not on display. Not on client acknowledgment. Line selection is the authoritative moment.
Why this matters mechanically: if the grant fires at display, we have a timing problem when the player walks away mid-conversation (D-064 walk-away mechanic). The NPC started a sentence — did the player "hear" it? By firing on selection, the server authoritatively records "this information was transmitted at tick T." The walk-away event, if it fires, records an incomplete interaction but does NOT retract the knowledge already granted. This is diegetically correct: if someone starts telling you something, you heard the beginning.
The pipeline should be:
1. Layer 4 topic+mood scoring selects line
2. Server fires `KnowledgeGranted` event into `KnowledgeEventQueue`
3. Knowledge update system processes it that tick
4. Snapshot includes updated KG state
This puts knowledge grant in the same event pipeline as `DirectObservation` and `LeftLOS` (events.rs) — correct architectural fit.
### 1.2 Grant payload schema — the entity/fact split
**Position: `KnowledgeGrant` must support three payloads.**
Current `KnowledgeGrant { fact_id: String, confidence: String }` only handles fact-level grants. The "Kael handles cargo at Dock 7" example from the brief is NOT a fact grant — it's an entity grant: creates/updates an `EntityKnowledge` entry for Kael with source `ToldBy { source_id: sera_sid, tick }`.
Proposed schema (extend `content/types.rs`):
```
enum KnowledgeGrantPayload:
FactGrant { fact_id: FactId, confidence: KnowledgeConfidence }
EntityGrant { entity_id: String, attributes: BTreeMap<String,String>, confidence: KnowledgeConfidence }
Compound { grants: Vec<KnowledgeGrantPayload> } // "Kael is at Dock 7 AND contraband.ring_exists"
```
The `EntityGrant` maps to `known_attributes` on the `EntityKnowledge` entry. The source is always `ToldBy { source_id: speaking_npc_sid, tick }`. This is the missing piece that makes `KnowledgeSource::ToldBy` constructible.
**Is this fun?** Yes. It means a single dialogue line can tell you TWO things simultaneously — a new fact AND update your understanding of a person. These are the "information dense" moments players remember.
### 1.3 POI discovery
**Position: `FactId("poi.*")` namespace is correct. No new type needed.**
`FactId("poi.dock_7_restricted")` integrates cleanly with:
- `filter_by_access` → `KnowledgeGated("poi.dock_7_restricted")` — the access rule already reads from `knows_fact()` in graph.rs line 301
- Monologue prerequisites → `fact_at_least()` in graph.rs line 71 already works on any FactId
- Dialogue gating → same
The alternative (extend `EntityKnowledge` to cover locations) fragments the query model. POIs are facts, not entities. Extending `EntityKnowledge` for locations would mean adding a third BTreeMap to `KnowledgeGraph` and duplicating query methods. Reject.
### 1.4 Physical evidence
**Position: Same grant mechanism, different source variant.**
Evidence discovered via terminal/document/cargo manifest fires a `FactGrant` or `EntityGrant` with `source: DirectObservation { tick }` rather than `ToldBy`. This distinction matters for contradiction detection (Topic 5): a `DirectObservation` source carries higher credibility than `ToldBy`. It also means physical evidence cannot be contradicted by the NPC denial path — you saw it with your own eyes.
A separate `KnowledgeEventType::EvidenceDiscovered` variant is NOT needed. The payload type distinguishes it from regular perception updates; the source type (`DirectObservation`) distinguishes it from NPC-told knowledge. One new event type (`KnowledgeGranted`) handles all grant paths.
### 1.5 Author guardrails
**Position: Runtime enforcement with content-load validation.**
Rule: an NPC dialogue line can only grant knowledge that is consistent with the NPC's KG or their authored background facts.
Implementation: content validator (already exists in the build pipeline) checks that for any `EntityGrant { entity_id }`, the entity_id references a known entity in the NPC's profile or the station manifest. Runtime enforcement: the dialogue system, when processing `knowledge_grant`, first checks that the granting NPC has the relevant knowledge at the claimed confidence level. If the NPC's KG doesn't support the grant, the line fires but the grant is silently dropped with a `tracing::warn!`.
**Why not compile-time only?** NPCs' KGs are dynamic (they change at runtime via gossip, observation). A content-authored grant might be valid at session start and invalid after a KG decay cycle. Runtime enforcement handles this gracefully.
---
## Topic 2: NPC-to-NPC Knowledge Propagation (Q-024)
### Mechanical position summary
This is the CIRCULATION side of the knowledge engine. Knowledge isn't useful if it just accumulates in one NPC and decays. The gossip system is what makes knowledge a renewable resource — facts move through the world and reach the player through multiple channels.
### 2.1 Conversation system as the hook
**Position: Confirmed. The conversation system IS the routine intersection hook. No separate system needed.**
`run_npc_conversations` (conversation.rs line 278+) already has:
- Proximity detection within `CONVERSATION_PROXIMITY` tiles
- Cooldown management (`ConversationCooldown`)
- Deterministic pairing via StableId sort (D-010 compliant)
- Duration management (start tick → end tick)
Adding a knowledge transfer phase between conversation start and conversation end is architecturally clean. It fits between Phase 1 (start conversation) and the existing emission of `SoundEvent`/`ConversationEvent`. The transfer happens at conversation start — both NPCs exchange knowledge at the moment contact is made.
This satisfies the "queued at routine intersections" intent from Q-024. Routine-driven NPCs will naturally cross paths during their schedules, triggering conversations, triggering transfers.
### 2.2 Trust-gated filtering
**Position: Map trust tiers to KnowledgeConfidence caps.**
Proposed mapping (aligns with D-028 trust tiers):
| Trust tier | D-028 label | Facts eligible to transfer | Confidence cap on transfer |
|---|---|---|---|
| 0 (None) | — | No transfer | — |
| 1 (Surface) | `public` | Facts with confidence ≥ KnowsOf, state: Active only | KnowsOf |
| 2 (Real) | `real` | Facts with confidence ≥ KnowsOf, any Active state | KnowsOf (downgraded from KnowsDetails) |
| 3 (Secret) | `secret` | All Active facts including Suspects-level | KnowsOf (downgraded from KnowsDetails) |
The confidence cap is the critical design choice: **transferred confidence = `min(source_confidence, KnowsOf)`**.
Why `KnowsOf` as the cap, not `KnowsDetails`? Because gossip degrades information. NPC A telling NPC B something produces second-hand knowledge, never first-hand detail. This prevents gossip chains from propagating `KnowsDetails` — if a fact reaches someone via three intermediaries, it's still only `KnowsOf`. This is also why the player gaining `KnowsDetails` from an NPC requires DIRECT dialogue, not gossip relay.
`Suspects` stays `Suspects` across all tiers — a rumor is a rumor.
### 2.3 Rate limiting
**Position: 1-3 facts per conversation, randomly selected from eligible pool.**
Why random selection rather than "all eligible"? Knowledge explosion prevention. If NPC A has 30 eligible facts and meets NPC B every 10 game-minutes, uncapped transfer means every NPC converges to the same knowledge state quickly. This destroys the asymmetry that makes the game work.
Random selection also creates variance: each NPC-NPC meeting produces a different outcome. An NPC you talk to might not know the one thing they had a chance to overhear from someone else yesterday — they just weren't selected to share it. This is emergent and creates replay variance (nodding to Nigel).
Suggested cap: `rand.random_range(1..=3)` facts per conversation. Small enough to prevent explosion; large enough that relationships with high-trust NPCs provide real information value.
### 2.4 Observable by player — overheard knowledge
**Position: Overheard knowledge is always `Suspects` regardless of occlusion fidelity.**
The workshop raises this as a question: should the grant for overheard conversations follow word-level occlusion (D-078)? My answer: no. Here's why.
**The two systems answer different questions:**
- D-078 word occlusion: *what can the player READ/HEAR* — audio fidelity, narrative text
- Knowledge grant: *what can the player KNOW* — mechanical state
These should be decoupled. A player who overhears "...Kael...dock...second shift..." (heavily occluded) still walked away with a SUSPICION, not knowledge. The occluded words give narrative flavor; the fixed-confidence grant gives mechanical state.
Fixed `Suspects` for ALL overheard NPC-NPC conversations means:
- Player always gets SOMETHING from eavesdropping (reward for the behavior)
- Player never gets CERTAINTY from eavesdropping (incentive to follow up with direct dialogue)
- System is simple (no occlusion-weighted confidence calculation)
**Is this fun?** Yes. Eavesdropping becomes a preliminary investigation tool. You hear something suspicious, you go find the person involved, you talk to them directly, you escalate to `KnowsOf` or `KnowsDetails`. The eavesdrop is a lead, not a solution.
### 2.5 ToldBy source construction
The `source_id` for `ToldBy` is available at conversation.rs line 321: `sid.map(|s| s.0.0).unwrap_or(u64::MAX)`. This is the StableId of the source NPC. The `tick` is `time.tick` from the `SimulationTime` resource. ToldBy construction is a one-liner once the architecture confirms Topic 2.
---
## Topic 3: Unprompted Disclosure Design (#172)
### Mechanical position summary
This is the most design-dense topic. Unprompted disclosure is not just "NPC volunteers info" — it's the mechanic that makes NPCs feel like they have an independent relationship with information. Done correctly, it creates the "a stranger just told you something important you didn't know you needed" moments that define good immersive sims.
### 3.1 "Do I know something you don't?" — Option A is correct
**Position: NPC checks only own KG (Option A). Strongly opposed to cross-entity KG query (Option B).**
This is not primarily a technical decision — it's a design decision about NPC cognition. NPCs **do not know what the player knows**. This is both diegetically correct AND mechanically interesting.
Why is Option A MORE interesting, not less interesting?
Consider what happens with Option B: every time the player already knows something, no NPC will repeat it. The player's information state becomes a filter on the entire NPC disclosure system. This means players who investigate thoroughly get LESS disclosure from NPCs over time. That's punishing success.
With Option A: NPCs might tell you things you already know. That is information too. "Why is this NPC telling me this? Do they not know that I know? Are they testing me? Are they covering for someone by offering an explanation I already disproved?" This is the paranoid detective headspace the game is going for.
The only UX downside is redundant disclosure feeling noisy. Solved by per-fact cooldowns (see 3.5) — the player won't hear the same thing twice from the same NPC in rapid succession.
### 3.2 Connection to NPC KG — architecture
**Position: New `DisclosureCandidateList` component, NOT extending `DerivedTellState`.**
`DerivedTellState` is a behavioral signal (how the NPC APPEARS). Disclosure candidates are content selection state (what the NPC might SAY). These are different concerns.
Proposed: a separate `DisclosureCandidateList` component, populated by a system that runs after the KG update pass:
```
struct DisclosureCandidateList {
// Facts from this NPC's KG that pass the disclosure filter
candidates: Vec<FactId>,
// Entity knowledge entries that pass the filter
entity_candidates: Vec<(StableId, String)>, // (entity, key_attribute)
// Last computed at this tick
computed_tick: u64,
}
```
This component is computed lazily (only for Active-tier NPCs in dialogue range) and expires after a few ticks. Layer 4 of the dialogue pipeline reads it when selecting unprompted disclosure lines.
**Why not make `DerivedTellState` hold candidates?** Because tell derivation runs every tick for ALL Active NPCs (tell_state.rs line 129). Adding KG iteration to that loop for NPCs not in dialogue range wastes compute. `DisclosureCandidateList` only computes for NPCs the player is actually engaging with.
### 3.3 Trait filtering (#173) — BOTH, sequenced
**Position: Traits filter WHAT (candidate pool) then modify HOW (line selection). Two-stage.**
Stage 1 — candidate filtering (which facts are eligible):
- `Cautious` trait: removes facts with confidence > `Suspects` from candidate pool (shares only rumors, never certainties)
- `Loyal` trait: removes facts about entities with `relationship: Friendly/PersonOfInterest` (protects people they care about)
- `Talkative` trait: expands pool to include facts at all confidence levels
Stage 2 — delivery modification (which line from the pool wins):
- Trait tags bias the line pool selection weights in Layer 4
- A `Cautious` NPC who does disclose something delivers it obliquely ("I heard something... probably nothing")
- A `Talkative` NPC delivers it directly and with elaboration
This is the correct architecture because content authors can create trait-appropriate LINE variants without needing to touch the candidate selection logic. The filtering and the delivery are independently authorable.
**Is this fun?** Yes. Personality shapes WHAT the NPC is willing to reveal AND how it reads when they reveal it. Two NPCs with the same knowledge but different traits create different investigative experiences.
### 3.4 Trigger conditions
**Position: Four-gate system, all must pass.**
| Gate | Condition | Rationale |
|---|---|---|
| Trust | Trust tier ≥ `surface` toward player | You don't volunteer info to strangers |
| Mood | MoodState ≠ Hostile | Angry NPCs don't help |
| Contentment | Contentment ≥ -10 | Miserable NPCs are self-absorbed |
| Witness inhibition | No NPCs in radius OR trust override | NPCs are less forthcoming with an audience |
The witness inhibition gate is the interesting one. An NPC who is Friendly to the player might still not disclose sensitive information if their colleague is standing nearby. This creates the "can we talk privately?" dynamic — a moment of social positioning that feels diegetically real.
Trust tier ≥ `surface` maps to D-028 access tier: the NPC will speak to you at all (they haven't gone silent). Real disclosure of substantive facts should probably gate at `real` trust. Layer 4 line pool already handles trust tier filtering — unprompted disclosure can piggyback on this.
### 3.5 Rate limiting
**Position: Two-layer rate limiting.**
Layer 1 — per-fact per-NPC per-player: once an NPC has disclosed a fact to the player, that fact is marked `disclosed_to_player` in the `DisclosureCandidateList` and never selected again. Facts the player already knows (via other channels) are NOT filtered — see 3.1 above.
Layer 2 — per-NPC disclosure rate: 1 disclosure per `LINE_COOLDOWN_TICKS` (600 ticks = 1 game-hour, dialogue.rs line 39). This prevents disclosure spam from high-contentment NPCs.
No global rate limit across all NPCs. Global limiting would create invisible competition between NPCs for "disclosure slots" that the player can't see or understand. Keep complexity in the individual NPC state.
---
## Topic 4: NPC Information Boundaries (#142)
### Mechanical position summary
Let me be precise about what "NPC using their own KG for decisions" actually means at each level, because different systems have different stakes.
### 4.1 Priority order with justification
| Priority | System | What changes | Gameplay impact | Risk |
|---|---|---|---|---|
| 1 | `tell_state.rs` | Tell derivation reads NPC's relationship knowledge from KG | NPC tells reflect what they know about OTHERS, not just their internal state | Low — KG reflects observed state accurately |
| 2 | Unprompted disclosure (#172) | Disclosure candidates come from KG | NPCs only volunteer what they know | Low — this is the whole point of #172 |
| 3 | Conversation partner selection | NPC A checks KG before approaching NPC B | NPCs don't chat with "strangers" (entities not in their KG) | Medium — adds KG query to pairing loop |
| 4 | Routine execution | Routine decisions read KG state | NPCs take different routes based on what they know | High — requires careful fallback |
| 5 | Pathfinding | Path uses known (not ground-truth) walkability | NPCs might get lost after KG decay | Very high — don't touch in v0.1 |
### 4.2 On tell_state.rs specifically
There is a subtle point here worth calling out. The workshop brief says `tell_state.rs` uses axes directly and should use KG. I want to be careful: the `Secret`, `Contentment`, `Tolerance` axes ARE the NPC's own internal state — the NPC always knows their own secret severity. Using ground truth for self-knowledge is CORRECT.
What changes with KG integration is not reading self-knowledge differently, but **incorporating relationship knowledge** into tell derivation. Currently, `TellCategory::Friendly` checks `relationships.entries` directly (tell_state.rs lines 105-112). This should instead check the KG's `RelationshipState` for the entities in range — because an NPC's relationship state can be affected by observed behavior that's recorded in their KG.
More impactfully: an NPC who has a `Contradicted` entry in their KG (they know something doesn't add up about someone they thought they trusted) should show a modified tell. This is a new tell category or a modifier on existing tells, not yet designed. Worth flagging for a future sprint.
### 4.3 Minimum viable boundary
**Position: tell_state.rs relationship reads from KG + unprompted disclosure from KG = MVP.**
These two changes produce maximum gameplay-visible difference for minimum implementation risk. An NPC that:
1. Shows tells based on their actual observed relationship state (not just the raw relationship axis)
2. Only voluntarily discloses things they actually know
...is a meaningfully bounded NPC even if its pathfinding runs on ground truth.
### 4.4 Simulation tier
**Position: Background-tier NPCs (D-026, 500-2000 range) get NO KG-based behavior.**
Background NPCs run state machines. They don't have dialogue. They don't initiate conversations. Their KG is either empty or minimal. Adding KG queries to background-tier processing would blow the performance budget.
Active-tier NPCs (30-80 per D-026) are the only ones who can engage in dialogue and unprompted disclosure. KG-based behavior is gated on `With<ActiveSim>`, matching the existing `derive_tell_state` scope (tell_state.rs line 140).
### 4.5 Fallback behavior
**Position: Ground truth fallback with structured logging. No "ask around" behavior in v0.1.**
If an Active-tier NPC's KG has no relevant entry for a needed decision:
1. Fall through to ground truth
2. Log at `tracing::debug!` level: `"NPC {sid} falling back to ground truth for {decision_type}"`
The "ask around" emergent behavior (option C from the brief) is the CORRECT long-term design but it's a v0.2+ feature. It requires an "information-seeking" behavioral state, a system to resolve it, and content to support it. Not v0.1 scope.
---
## Topic 5: Contradiction Detection Pipeline (Q-026)
### Mechanical position summary
This is the payoff. Everything above feeds into this: ToldBy sources exist (from Topic 2 grants and Topic 1 dialogue grants), the player has observed entity positions directly, and now the system must detect when told information and observed reality diverge. This is THE FRIEND arc mechanic at its purest.
### 5.1 Critical structural issue I must raise
**There is a problem with the current `EntityKnowledge` data model for contradiction detection.**
`KnowledgeGraph.entities` is `BTreeMap<StableId, EntityKnowledge>` — **one entry per known entity**. When the player observes Kael at corridor B-7 via `observe_entity()`, the `last_known_position` is overwritten to B-7 and `source` is overwritten to `DirectObservation`. The original `ToldBy` source that said "dock during second shift" is **gone**.
Contradiction detection cannot fire on writes if the information being contradicted has already been overwritten.
**Position: Add `contradiction_basis: Option<ContradictionBasis>` to `EntityKnowledge`.**
```rust
pub struct ContradictionBasis {
/// The previous source, preserved when a contradiction is detected.
pub conflicting_source: KnowledgeSource,
/// The previous position at the time of the conflicting observation.
pub conflicting_position: Option<TilePosition>,
/// The tick when the contradiction was detected.
pub detected_at_tick: u64,
}
```
Contradiction detection fires in the `observe_entity()` write path BEFORE overwriting:
1. Read existing entry
2. If existing source is `ToldBy { source_id, tick: told_tick }` AND new position ≠ existing position AND time window overlaps (|current_tick - told_tick| < CONTRADICTION_WINDOW)
3. Preserve old source/position in `contradiction_basis`
4. Set entry state to `Contradicted`
5. Write new observation
6. Push `ContradictionDetected` event
This preserves both pieces of information for monologue text ("Sera said X was at the dock — I just saw them in corridor B-7").
### 5.2 Detection algorithm
**Position: Event-driven detection at KG write time. Never per-tick.**
Two detection categories for v0.1:
**Location contradiction (automatic):**
```
IF new_observation.position ≠ existing_entry.last_known_position
AND existing_entry.source == ToldBy { tick: told_tick }
AND |current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS
THEN contradiction detected
```
`CONTRADICTION_WINDOW_TICKS` = 600 (1 game-hour) is a reasonable default. Facts more than a game-hour old aren't "this moment" contradictions — they're just outdated info, which is handled by decay/stale.
**Attribute contradiction (semi-automatic):**
For `known_attributes: BTreeMap<String, String>`, contradiction fires when a new attribute write for an existing key produces a different value AND both have different source types (`ToldBy` vs `DirectObservation` or different `ToldBy` sources).
The `known_attributes` key convention needs to establish a typing scheme to make this meaningful: `"role:*"` keys are comparable (same role key, different values), `"event:*"` keys are not directly comparable. This can be a content convention rather than a type system change — lightweight for v0.1.
**Fact contradiction (content-authored):**
Content authors specify contradiction pairs in YAML: `contradicts: ["contraband.smuggling_denied", "contraband.ring_exists"]`. Detection fires when both facts exist in the same KG at `Active` state.
### 5.3 Event chain
```
KG write (observe_entity / grant processing)
→ ContradictionCheck (inline, before write completes)
→ ContradictionDetected event { observer, entity, type, conflicting_source, new_source }
→ MonologueSystem: "Wait, that doesn't add up" line selection
→ Prerequisite: both KG entries Contradicted
→ RelationshipSystem: target entity → PersonOfInterest
→ AnomalySystem: AnomalyMarker set on target
→ D-033: color transition → amber #e8c547
```
The monologue system reads from `EntityKnowledge.contradiction_basis.conflicting_source` to construct the text: `"[Name] said [entity] was at [conflicting_position]. I just saw them at [current_position]."` This requires the `ToldBy source_id` to be resolvable back to a name — which requires the `EntityRegistry` and the resolved NPC's `NpcName` component. Dudley should confirm this lookup is available from the monologue system context.
### 5.4 THE FRIEND arc — full mechanical sequence
This is the canonical integration test. Walking through every system:
**Setup:** Sera's dialogue pool includes a line with `EntityGrant { entity_id: "kael", attributes: { "location": "dock-7-second-shift" }, confidence: KnowsOf }` and `knowledge_grant: Some(...)`.
**Sequence:**
1. **Tick 100:** Player talks to Sera → dialogue pipeline selects her "Kael handles cargo at Dock 7" line → `KnowledgeGranted` event pushed → knowledge update system processes it → player's KG: `entities[kael_sid] = EntityKnowledge { last_known_position: dock_7_coords, source: ToldBy { source_id: sera_sid, tick: 100 }, confidence: KnowsOf, state: Active }`.
2. **Tick 150:** Player moves toward Dock 7, Kael is not there. Player continues through station. Passes through corridor B-7. Kael walks into LOS.
3. **Tick 150:** Perception system fires `KnowledgeEventType::DirectObservation { target: kael_entity, position: b7_coords }` → knowledge update system calls `observe_entity(kael_sid, b7_coords, 150)`.
4. **Inside `observe_entity()`:** Existing entry has `source: ToldBy { source_id: sera_sid, tick: 100 }`, `last_known_position: dock_7_coords`. New position `b7_coords ≠ dock_7_coords`. Time window: `|150 - 100| = 50 < 600`. **Contradiction detected.** Entry updated: `contradiction_basis: Some(ContradictionBasis { conflicting_source: ToldBy { sera_sid, 100 }, conflicting_position: dock_7_coords, detected_at: 150 })`, `state: Contradicted`, `last_known_position: b7_coords`, `source: DirectObservation { tick: 150 }`.
5. **Tick 150 (same frame):** `ContradictionDetected` event emitted. Monologue system fires: "Sera said Kael was at the dock. I just saw him in B-7." (line from monologue pool, prerequisite: `contradiction.kael_dock_b7` or whatever the fact tag is — needs content authoring). Relationship state for `Sera` → `PersonOfInterest`. `AnomalyMarker` set on Sera entity. D-033 color transition: Sera → amber.
6. **Player reaction:** Sera now appears amber on screen. Player is prompted to re-engage with Sera. Next conversation with Sera uses "Contradicted" entry in player's KG as a trust modifier in Layer 1-3 filtering → Sera's trust-tier dialogue unlocks confrontation lines.
**What breaks this sequence:** If `observe_entity()` does not check for contradiction before overwriting, step 4 fails silently. This is why the structural issue in 5.1 is blocking. Everything downstream (monologue, relationship shift, color change) is already built and tested — the detection algorithm is the missing link.
---
## Cross-Topic Interactions (the emergent machine)
Let me map the feedback loops, because this is where it gets interesting:
```
Topic 2 (NPC gossip) → produces ToldBy entries in NPC KGs
Topic 1 (grant mechanism) → NPC ToldBy entries can be transferred to player via dialogue
Topic 5 (contradiction) → player observation contradicts ToldBy → Contradicted state
Topic 3 (unprompted disclosure) → NPC with Contradicted knowledge discloses their confusion?
```
That last arrow is a bonus emergent loop: an NPC who has a `Contradicted` entry in their own KG (because they also observe Kael somewhere unexpected) could volunteer that confusion via unprompted disclosure. "I could have sworn Kael was supposed to be at the dock today, but..." This is not Topic 5, it's Topic 3 using Topic 5 outputs as candidate selection criteria. Worth noting for future sprint.
---
## Positions Summary Table
| Topic | Position | Rationale |
|---|---|---|
| 1.1 Grant timing | Fire on line selection | Server-authoritative, handles walk-away cleanly |
| 1.2 Grant payload | Three-type enum: Fact/Entity/Compound | Entity grants enable ToldBy construction |
| 1.3 POI | `FactId("poi.*")` namespace | No new type; integrates with existing KnowledgeGated |
| 1.4 Evidence | Same grant mechanism, DirectObservation source | Source type distinguishes from NPC-told |
| 1.5 Guardrails | Runtime enforcement + content-load validation | KGs are dynamic; compile-time-only is insufficient |
| 2.1 Gossip hook | Conversation system confirmed | Already has pairing, lifecycle, determinism |
| 2.2 Trust-gate | `min(source_confidence, KnowsOf)` cap | Prevents KnowsDetails propagation via gossip |
| 2.3 Rate limit | 1-3 random facts per conversation | Prevents knowledge convergence |
| 2.4 Overheard | Fixed `Suspects` regardless of occlusion | Eavesdrop = lead, not certainty |
| 3.1 "Do I know?" | Option A: NPC checks own KG only | Diegetically correct AND mechanically richer |
| 3.2 Architecture | New `DisclosureCandidateList` component | Separation from tell derivation |
| 3.3 Traits | Both: filter candidates AND modify delivery | Independently authorable |
| 3.4 Trigger | Four-gate: trust + mood + contentment + witnesses | Witness gate creates social positioning |
| 3.5 Rate limit | Per-fact per-NPC + per-NPC rate limit | No global limit (unintelligible to player) |
| 4.1 Priority | tell_state (KG relationships) → disclosure → conversation pairing | Most impact, lowest risk first |
| 4.2 tell_state | Read relationship state from KG, not raw axis | Self-knowledge stays on axes (correct) |
| 4.3 MVP | tell_state relationship reads + disclosure from KG | Visible difference, low risk |
| 4.4 Tier | Active-tier only (`With<ActiveSim>`) | Consistent with D-026 |
| 4.5 Fallback | Ground truth + logging, no "ask around" in v0.1 | "Ask around" is v0.2+ |
| 5.1 Structural | Add `contradiction_basis: Option<ContradictionBasis>` to `EntityKnowledge` | Overwrite problem — blocking |
| 5.2 Algorithm | Event-driven at KG write time | Off per-tick hot path |
| 5.2a Location | Automatic: position + time window | Tier 1 implementation |
| 5.2b Attribute | Semi-automatic: same key, different value, different source | Works with String BTreeMap + key conventions |
| 5.2c Fact | Content-authored contradiction pairs | Not all fact contradictions are algorithmic |
| 5.3 Event chain | KG write → ContradictionDetected → monologue → relationship → anomaly → color | All downstream consumers confirmed built |
| 5.4 FRIEND arc | Full sequence walks clean with proposed design | Pending structural fix from 5.1 |
---
## Open Questions I'm Flagging for Round 2
1. **Contradiction window (`CONTRADICTION_WINDOW_TICKS`).** I proposed 600 ticks (1 game-hour). Paula should weigh in: what timeframe makes narrative sense for "that's a contradiction, not just stale info"?
2. **Entity grant `entity_id` resolution.** `EntityGrant { entity_id: String }` needs to resolve to a `StableId` at grant processing time. Who owns the "name → StableId" registry? `EntityRegistry` does `StableId ↔ Entity` (Bevy Entity), but is there a "display name → StableId" path? Tyre should confirm.
3. **Monologue text generation from `contradiction_basis`.** The monologue pool currently has authored lines. For THE FRIEND arc, the line needs to reference specific entities by name ("Sera said Kael was at the dock"). Does this require parameterized monologue lines (dynamic text insertion)? Or do we just require dedicated authored lines per contradiction scenario? Paula should decide.
4. **`DisclosureCandidateList` compute trigger.** I said it computes lazily for NPCs in dialogue range. Tyre should confirm: is there a "player entered dialogue range" event or does this need a range-query every N ticks?
5. **Q-025 formal closure.** I agree with Tyre: ~30KB total at current NPC count, eviction not needed for v0.1/v0.2. Qatux, please record formal closure of Q-025 when this workshop produces its D-record.

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