Compare commits

...
37 Commits
Author SHA1 Message Date
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 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 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
103 changed files with 7872 additions and 1021 deletions
+1
View File
@@ -44,6 +44,7 @@
"Bash(make)",
"Bash(tea *)",
"Bash(tooling/tea-comment *)",
"Bash(chmod *)",
"Bash(ls *)",
+8
View File
@@ -134,6 +134,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}** |
+49
View File
@@ -6,6 +6,55 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
- `/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
- `/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
### 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
### Changed
- 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
## [v0.1.15] — 2026-02-23
### Added
+11 -7
View File
@@ -121,7 +121,7 @@ tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --o
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"
tooling/tea-comment <NUMBER> "comment body"
# Approve a PR
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
@@ -133,12 +133,7 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
Key rules:
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
- **Use `--output simple`** for machine-readable output (no table borders)
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
```bash
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
# Step 2: Post via cat
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
```
- **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.
@@ -154,6 +149,15 @@ Key rules:
Use conventional commits with project-specific scopes:
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
### 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)
### Pull requests
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
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

+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)
+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
+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()
+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]
@@ -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]
@@ -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]
+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`
+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.
@@ -0,0 +1,377 @@
# Gestalt Round 2 — Cross-Review, Synthesis, and D-Record Contributions
## Knowledge Flow & NPC Information Boundaries Workshop
**Author:** Gestalt
**Date:** 2026-02-23
**Sources reviewed:** tyre-round1.md, dudley-round1.md, paula-round1.md, round-1-notes.md
---
## Opening: What Round 1 Actually Settled
The consensus table in Qatux's notes is largely correct and the fundamentals are solid. Before the tensions: I want to note that the four-way agreement on Option A (NPC checks own KG only) and on event-driven contradiction detection is meaningful — these were the design questions with the most surface area for disagreement, and we landed in the same place independently. That's a signal worth acknowledging before we argue about the edges.
Now: the two tensions. These are the critical path. Everything else in this document is secondary.
---
## Tension A: Entity Grants — Sprint 17 vs Sprint 18
### My position: Sprint 17, but scoped narrowly.
Let me map the debate precisely, because Dudley's objection is valid but not blocking.
**Dudley's concern:** "entity_ref → StableId" name registry doesn't exist, Sprint 17 shouldn't take on that infrastructure.
**Tyre's counter:** It's a `BTreeMap<String, StableId>` populated at content load. 0.5 days.
Both are right, but they're talking past each other. Dudley is imagining a general entity-name-to-StableId registry that works for ANY authored entity reference in ANY dialogue line. That is Sprint 18+ scope. Tyre is imagining a narrow content-load-time map for the specific entities involved in narrative-critical dialogue. That is Sprint 17 scope.
**The actual minimum for Sprint 17:**
The contradiction detection chain for THE FRIEND arc requires exactly one thing that entity grants provide: `EntityKnowledge` for Kael in the player's KG with `source: ToldBy { source_id: sera_sid }`. This is not a general capability — it's a specific authoring requirement for one dialogue line in one NPC's pool.
Dudley's workaround (structured FactId `"entity.kael.position.second_shift_dock"`) does NOT cleanly substitute for this. Paula identified the exact failure mode:
1. Sera's dialogue grants `FactId("entity.kael.position.second_shift_dock")` — creates `FactKnowledge` entry with `source: ToldBy { sera_sid }`
2. Player observes Kael in B-7 — calls `observe_entity(kael_sid, B7_pos, tick)` — updates `EntityKnowledge`
3. Contradiction check in `observe_entity`: existing entry has `source: DirectObservation` (the player's PREVIOUS direct observation of Kael, not ToldBy!) — no contradiction fires
The workaround breaks because the player's `EntityKnowledge` for Kael is populated by direct observation BEFORE any testimony. The first time the player sees Kael, `observe_entity` creates a `DirectObservation` entry. When Sera then tells them "Kael was at the dock," if this is only a FactGrant, the `EntityKnowledge` entry still shows `DirectObservation`. The contradiction between Sera's testimony and the player's later observation has no anchor in `EntityKnowledge`.
Dudley's Option B (structured attribute strings `"claim.T1.position" = "dock_7,T1,sera_sid"`) does address this — it adds the ToldBy position claim INTO the existing EntityKnowledge entry's `known_attributes`. But it introduces a new problem: brittle string parsing in a hot code path, plus the contradiction detector needs a string parser instead of typed reads.
**My verdict on Tension A: Sprint 17, narrow entity grant scope.**
Deliverable for Sprint 17:
- `KnowledgeGranted` event supports TWO payload types: `FactGrant` and `EntityPositionGrant`
- `EntityPositionGrant { entity_ref: String, position_claim: String, confidence: KnowledgeConfidence }` — resolved to StableId at content load via a static `BTreeMap<String, StableId>` seeded from `entity-attributes.yaml` (which already defines all authored entities)
- This creates/updates `EntityKnowledge` for the referenced entity with `source: ToldBy { source_id: speaker_sid, tick }` and stores the position claim in `contradiction_basis` (see Tension B resolution below)
- Scope restriction: `entity_ref` must reference an entity in `entity-attributes.yaml` — no dynamic resolution
The full `EntityGrant { attributes: BTreeMap<String,String> }` capability (for arbitrary attribute grants) defers to Sprint 18 as Dudley prefers.
This gets THE FRIEND arc working. It does NOT require a general entity name registry — just a static lookup against the existing entity manifest.
---
## Tension B: `EntityKnowledge` Structural Fix
### My position: Tyre's approach. Unify on `ContradictionClaim`. Reject Dudley's Option B.
Tyre and I independently proposed the same architecture with different field names:
- Tyre: `contradicted_claim: Option<ContradictionClaim>`
- Gestalt: `contradiction_basis: Option<ContradictionBasis>`
These are structurally identical. I defer to Tyre's naming: **`contradicted_claim: Option<ContradictionClaim>`**. The struct fields:
```rust
pub struct ContradictionClaim {
pub source: KnowledgeSource, // Who made the conflicting claim
pub position: Option<TilePosition>, // Where they claimed the entity was
pub tick: u64, // When the claim was made
}
```
The detection fires inside `observe_entity` before overwriting, sets `entry.state = Contradicted`, populates `contradicted_claim` with the prior source data, then overwrites normally.
**Why not Dudley's Option B?**
Dudley's structured attribute approach (`"claim.T1.position" = "dock_7,T1,sera_sid"`) has three concrete problems:
1. **String parsing in a hot path.** `detect_contradictions` would iterate `known_attributes` keys matching `"claim.*"` and parse comma-delimited values on every KG write. This is not expensive in absolute terms (Tyre's budget analysis puts it well within budget), but it is brittle. A malformed string crashes the detector. A content author misspelling a claim key silently skips detection.
2. **`known_attributes` is the wrong data structure for this.** It's typed `BTreeMap<String, String>` for entity metadata — role, faction, relationships. Using it for contradiction tracking mixes concerns. When Dudley suggests a Sprint 18 refactor, they're acknowledging this is wrong — but the refactor creates breaking changes to any contradiction content authored in Sprint 17. Technical debt with a deadline is not "pragmatic workaround," it's committed future work.
3. **Downstream consumers read typed fields.** The monologue system needs `contradicted_claim.source` to identify Sera by name. With Option B, it needs to parse `"dock_7,T1,sera_sid_as_string"` and reassemble a `ToldBy` source. This creates a second parsing site and doubles the failure modes.
**The counterargument:** "zero schema change" is attractive. My response: adding one optional field to `EntityKnowledge` is not a meaningful schema change. The struct has 8 fields already. Adding one optional field with `Default: None` is backward-compatible and requires no migration of existing state.
**Resolution:** Tension B resolves to Tyre's approach. One optional struct field. Zero breaking changes. Typed reads throughout.
---
## Secondary Disagreements
### Global disclosure rate limit — concession with modification
Round 1 had a 3-vs-1 split: Tyre/Dudley/Paula for global limit, Gestalt against.
Let me be precise about what I objected to and where I was wrong.
**What I said:** "Global limiting creates invisible competition between NPCs that the player can't see or understand."
**Where I was wrong:** In the specific scenario where a global limit fires — multiple NPCs simultaneously ready to disclose in the same player proximity — the player is looking at ONE NPC who is speaking. They are not watching the other NPCs not-speak. The "invisible competition" framing assumed the player was tracking all NPCs in parallel, which is not how occlusion-based single-character perspective works.
**Where I was still right:** Dudley proposed "1 disclosure per tick globally." At 10 ticks/game-minute, this is essentially no limit in normal play (per-NPC cooldowns of 300 ticks prevent individual NPCs from firing every tick). The global limit only matters when several NPCs have their cooldowns expire simultaneously AND are all within player range AND pass all their trigger conditions. That's a degenerate case, and limiting it to 1-per-tick is correct.
**My concession:** I accept the global rate limit as a degenerate-case safeguard, with one condition: the NPC that "wins" the slot must be selected by deterministic StableId ordering (D-010 principle 4), not random. A per-tick random selection would make the global limit a source of non-determinism. Lowest StableId in the ready-to-disclose set wins the tick.
**Full rate limiting model (synthesis):**
1. Per-fact, per-NPC: once disclosed, fact is in `last_disclosed_facts` set; never repeated to same player until intentionally cleared
2. Per-NPC cooldown: 300 ticks minimum between any disclosures from one NPC
3. Global: 1 disclosure per tick maximum; when multiple candidates, select by lowest StableId (deterministic)
### Paula's `disclosure_threshold_override` on KG entries
This is not a tension — it's a new design element that doesn't conflict with anything. I support it.
A per-entry flag (`never_disclose: bool` on `FactKnowledge` or per-entry override in `DisclosureCandidates` filtering) that prevents specific facts from entering the candidate pool regardless of trust tier. This handles the "Major secret that would never be shared even with closest confidant" scenario (Kael's ring membership, Voss's blackmail knowledge).
**Implementation:** Add an optional field `disclosure_blocked: bool` to `FactKnowledge`. Content authors set this on the authored initial KG for NPCs who hold facts they would NEVER share. This is a content-level attribute, not a systemic change.
### Paula's location privacy gate
I proposed witness inhibition; Paula proposed location privacy. These are additive gates, not competing ones. Both should be in the trigger conditions for `process_unprompted_disclosure`.
The combined trigger condition set (final proposal, see Disclosure Algorithm section below):
| Gate | Condition |
|------|-----------|
| Trust | NPC-player relationship >= Surface tier |
| Mood | `DerivedTellState.category` not Angry; mood not Hostile |
| Contentment | `Contentment.level > -10` |
| Candidates | `DisclosureCandidates.candidates.is_not_empty()` |
| NPC cooldown | `DisclosureCooldown` not active for this NPC |
| Global limit | At most 1 disclosure this tick (StableId ordering) |
| Location privacy | Candidate's `location_privacy` tag compatible with current location type |
| Witness inhibition | No non-trusted NPCs in radius (≤3 tiles), OR NPC-player trust >= Real |
The witness inhibition gate: I defined it as "no NPCs nearby OR trust high enough to override." Paula's location privacy gate: "current location must match candidate's privacy requirement." These are independent checks that both apply.
**Implementation note for Dudley:** The location privacy check applies at LINE SELECTION (Layer 4), not at candidate derivation. The candidate pool surfaces eligible facts. When Layer 4 selects a line to deliver a candidate fact, the line may have a `location_privacy` tag that gates against current location. This keeps candidate derivation clean (KG query only) and puts environmental context check at the correct pipeline layer.
### Contradiction monologue authoring — Paula's three options
Paula's Option 3 (generic fallback + authored override) is correct and I endorse it with one addition.
For v0.1, the only FRIEND-pattern NPCs are Kael and Sera. Their contradiction monologue lines should be hand-authored with explicit entity names: "Sera said Kael was at the dock. I'm looking at him in B-7." This is possible because the monologue system has access to `contradicted_claim.source` (a `ToldBy { source_id: sera_sid }`) and can resolve `sera_sid → NpcName` via `EntityRegistry`.
**The resolution lookup must be confirmed by Dudley:** Is `EntityRegistry + NpcName` available in the monologue system context? This is the technical prerequisite for named contradiction monologue lines. If yes: hand-author the FRIEND arc lines now, design the template system for Sprint 18+. If no: the template system becomes a Sprint 17 blocker.
For auto-generated NPC contradictions (future scope): generic fallback "Something doesn't add up about [entity_name]'s whereabouts" is sufficient.
---
## The Disclosure Candidate Selection Algorithm
This is the co-production Dudley requested. Paula and I need to specify this before implementation can proceed. Here is my full design; Paula should confirm the narrative assumptions inline or amend in her Round 2 document.
### Algorithm: `derive_disclosure_candidates`
**System name:** `derive_disclosure_candidates`
**Runs:** Once per game-minute (every 10 ticks) for Active-tier NPCs within player dialogue range
**Input components:** `KnowledgeGraph`, `DerivedTellState`, `Relationships`, `DisclosureCooldown`
**Output component:** `DisclosureCandidates { candidates: Vec<FactId>, computed_tick: u64 }`
**The algorithm (pseudocode):**
```
fn derive_disclosure_candidates(
npc_kg: &KnowledgeGraph,
npc_traits: &TraitProfile, // from NPC's authored profile
player_trust_tier: TrustTier, // derived from NPC Relationships
disclosed_facts: &BTreeSet<FactId>, // from DisclosureCooldown
) -> Vec<FactId>:
// Step 1: Determine minimum confidence threshold based on trait
min_confidence = match npc_traits.primary_trait:
Cautious => KnowsDetails // only shares things they're certain of
Gossipy => Suspects // shares everything including rumors
_ => KnowsOf // default: established knowledge only
// Step 2: Scan KG facts
candidates = []
for (fact_id, fact) in npc_kg.known_facts_iter():
// Exclude: non-Active state (Stale = unreliable, Contradicted = NPC is confused)
if fact.state != Active: continue
// Exclude: below trait-modulated confidence threshold
if fact.confidence < min_confidence: continue
// Exclude: already disclosed to this player
if fact_id in disclosed_facts: continue
// Exclude: entry has disclosure_blocked = true (Major secret / never-share)
if fact.disclosure_blocked: continue
// Exclude: trust gate — some fact categories require higher trust
// IMPORTANT: This is a ROUGH gate only. Layer 4 trust filtering (D-028 Layers 1-3)
// does the precise trust check. This prevents obviously sensitive facts from
// reaching Layer 4 at all for low-trust players.
if player_trust_tier < Surface: continue
if fact_id.category == "secret.*" && player_trust_tier < Real: continue
// Trait: Loyal — don't disclose facts about entities the NPC trusts/protects
// (entity-linked facts use "entity.{sid}.*" namespace convention)
if npc_traits.has(Loyal):
if fact is linked to a protected entity: continue
candidates.push(FactId, fact.confidence, fact.last_updated_tick)
// Step 3: Order candidates
// Primary: higher confidence first (NPC leads with what they're most sure of)
// Secondary: more recently updated first (current-game-state relevance)
candidates.sort_by(|a, b|
b.confidence.cmp(a.confidence)
.then(b.last_updated_tick.cmp(a.last_updated_tick))
)
// Step 4: Cap the candidate list
// Layer 4 line selection narrows further based on location, witnesses, etc.
// We pre-filter to avoid unnecessary iteration downstream.
candidates.truncate(10)
return candidates.map(|(id, _, _)| id)
```
### Notes on the algorithm
**Why no entity candidates in v0.1?** Entity-specific disclosure ("Kael does X") flows through the LINE CONTENT and the `knowledge_grant` on the line, not through the candidate derivation. The candidate is the FactId that TRIGGERS line selection (e.g., `FactId("kael.cargo_intake_role")` is in Sera's KG, triggers selection of her "Kael runs a tight intake process" line, which then grants both the FactKnowledge and the EntityKnowledge via compound grant). Entity candidate derivation as a separate category is Sprint 18.
**The "Loyal" trait implementation note for Dudley:** Requires a convention: FactIds about specific entities use the namespace `"entity.{stable_id_as_hex}.*"`. The Loyal check then queries the NPC's Relationships component to see if the linked entity has `trust_level > threshold`. This is a convention, not a type change.
**Why Contradicted facts are excluded:** An NPC who knows something is contradicted (they received conflicting information themselves) shouldn't volunteer the contradicted entry as if it's fact. However: this creates an interesting future case where an NPC might disclose their OWN confusion ("I heard Kael was at the dock but I could have sworn I saw him elsewhere"). This is emergent from system collision and is exactly the kind of thing to design into Sprint 18+. For v0.1: exclude Contradicted entries from candidates.
**The `fact_id.category` trust gate:** The check `if fact_id.category == "secret.*" && trust < Real` is a rough filter using the existing FactId namespace convention (`"category.topic"`). Facts in the `secret.*` namespace require Real trust to even appear as candidates. Facts in `poi.*`, `event.*`, `cargo.*`, etc. require only Surface. This is content-convention enforcement at the algorithm level, not a schema change.
### What Paula needs to confirm
1. **Is "Contradicted facts excluded from candidates" correct?** Or should there be a separate `DisclosureMode::Confusion` that surfaces contradicted facts as uncertain disclosure ("I'm not sure about this, but...")?
2. **For THE FRIEND arc specifically:** Sera's Phase 2 disclosures about Kael — are these `FactId("kael.*")` entries in her initial KG, or are they `EntityKnowledge` attributes? My algorithm treats them as facts (the trigger mechanism). The compound grant on the line creates the EntityKnowledge as a side effect. Does this match your narrative design for Phase 2?
3. **Witness inhibition threshold:** I said "no non-trusted NPCs within 3 tiles OR trust >= Real overrides." Does "trusted" mean trust_level >= Real toward the NPC, or toward the player? (It should be: no NPCs with low trust toward the disclosing NPC, not toward the player — Sera won't confide to the detective in front of a coworker she doesn't trust, regardless of how much she trusts the detective.)
---
## D-Record Draft Sections (Mechanical Interactions and Fun Factor)
The following are my contributions to the workshop's D-record. The full D-record synthesis will be Qatux's job; I'm writing my sections for inclusion.
### Section: Mechanical Interactions
**The knowledge engine: how the five topics form a closed loop**
Topics 1-5 are not independent features — they form a directed graph of mechanical interactions. Understanding this graph is essential for implementation prioritization and for assessing whether the design is "interesting" (vs merely functional).
```
[NPC Gossip (Topic 2)] ──ToldBy sources──► [EntityKnowledge populated with ToldBy]
│ │
▼ ▼
[Player Dialogue (Topic 1)] ──grants──► [Player KG: ToldBy + DirectObservation]
│
Position/attribute mismatch?
│
▼
[Contradiction Detection (Topic 5)]
│
┌───────────────────────┼────────────────────┐
▼ ▼ ▼
[Monologue fires] [Relationship → POI] [D-033 amber]
[NPC KG-aware Behavior (Topic 4)] ─────────────────────────────────────────►
tell_state reads relationships, not axes; disclosure uses own KG
[Unprompted Disclosure (Topic 3)] ◄── DisclosureCandidates ── NPC's own KG
│
└── Knowledge grants to player (via line knowledge_grant)
└── feeds back to player KG → possible future contradictions
```
The critical path through this graph for THE FRIEND arc:
- Topic 1 (entity grant) → ToldBy EntityKnowledge exists → Topic 5 can fire → monologue lands
The enrichment path (makes the world feel inhabited):
- Topic 2 (gossip) → NPC KGs populate → Topic 3 (disclosure) → player learns via NPCs → Topic 1 (grant mechanism) converts disclosure to KG state
**Every system interaction should produce asymmetric information.** If a mechanical interaction produces only symmetric effects (player and world learn the same thing at the same time), it's not serving the core design. Rate-check for each topic:
| System | Asymmetry produced |
|--------|-------------------|
| Topic 1 grant | Player's KG gets ToldBy source; NPC doesn't know what player now knows |
| Topic 2 gossip | NPC A and B share knowledge; player may or may not observe the conversation |
| Topic 3 disclosure | NPC volunteers info from own KG; doesn't know what player already has |
| Topic 4 boundaries | NPC's behavior reflects what it knows, not ground truth; player sees the difference |
| Topic 5 detection | Player knows there's a contradiction; NPC doesn't know player noticed |
Every topic in this workshop produces or amplifies asymmetric information. This is the confirmation that the design is correct at its foundations.
### Section: Fun Factor — "Is This System Interesting?"
The core question for each mechanic: **does it require the player to make a decision about information?**
**Topic 1 (Grant mechanism) — Fun factor: HIGH**
The grant mechanism creates the moment where "talking to people produces game state changes, not just text." The player realizes: "I should have talked to Sera before going to the dock — she knew where Kael was." Or the reverse: "I went to the dock first, now Sera's testimony is suspicious." The order you gather information changes what contradictions you can detect. This produces player choice about investigation sequence.
**Topic 2 (NPC gossip) — Fun factor: HIGH (with variance as the key)**
The random 1-3 fact selection per conversation means gossip is probabilistic. The player can KNOW that two NPCs regularly interact (observable from conversation system, D-078 eavesdropping) and try to exploit that channel — but what they overhear is never complete or reliable. This creates the "I need to check this against a direct source" loop. The trust-cap (`min(source_confidence, KnowsOf)`) means gossip gives leads, never certainties. Correct design.
**Topic 3 (Unprompted disclosure) — Fun factor: VERY HIGH — the emotional engine**
This is the system that makes NPCs feel like they have an independent relationship with information. The player notices: "Sera keeps telling me about cargo processes. She seems to trust me." This builds the expectation of Sera as a friendly source. When the contradiction lands, the betrayal has weight because the prior gift-giving happened. Without disclosure, Sera is just a dialogue tree. WITH disclosure, she's a character who chose to share things with you.
The decision the player makes with disclosure information is whether to trust it. "Sera just told me Kael's been great — but I found those discrepancies. Is she lying? Does she not know? Is she covering for him?" This is the investigative headspace the game is built around.
**Topic 4 (NPC boundaries) — Fun factor: MEDIUM (enablement layer)**
NPC information boundaries don't create direct player decisions — they prevent the system from cheating. An NPC who tells you about cargo discrepancies because the DATA says there are discrepancies (not because their KG says they know about discrepancies) is an NPC player can intuit as hollow. Boundaries make NPCs feel real; real NPCs make player decisions feel meaningful. Topic 4 is infrastructure for fun, not fun itself.
Exception: the `tell_state` KG integration (NPC shows more stressed tells when they know they're being watched) creates a detection mechanic. Player observes elevated tell. Player infers: "This NPC knows I'm investigating." Player decision: confront now or gather more evidence first. That's a direct fun output from the boundary change.
**Topic 5 (Contradiction detection) — Fun factor: CRITICAL MOMENT**
Contradiction detection produces the defining moment of the game's design: the moment asymmetric information systems collide. The player has been building a mental model. The system detects the model is wrong. The monologue fires. The world shifts.
The key design success criterion: this should feel like a DISCOVERY, not a notification. The player shouldn't feel like the system told them "contradiction detected." They should feel like THEY noticed something wrong. The monologue text makes this work: "Sera said Kael was at the dock. I just saw him in B-7." This is the player-character's thought, not a system alert.
This is why Paula's requirement (named-source contradiction monologue) is non-negotiable for fun. Generic "that doesn't add up" is a notification. "Sera said..." is a thought.
---
## Implementation Scope Recommendations for Sprint 17
Based on the full Round 2 analysis, here is my recommendation for what Sprint 17 should and should not attempt:
**Sprint 17 MUST include:**
1. `KnowledgeEventType::KnowledgeGranted` (FactGrant + EntityPositionGrant payloads)
2. `ContradictionClaim` struct field on `EntityKnowledge`
3. Location contradiction detection in `observe_entity` (Tyre's Approach B)
4. `ContradictionDetected` event + downstream chain (already built, just needs the trigger)
5. Wire `knowledge_grant` field in `process_talk_interaction`
6. NPC-to-NPC transfer via `transfer_npc_knowledge` system
7. `DisclosureCandidates` component + `derive_disclosure_candidates` system
8. `process_unprompted_disclosure` stub (trigger gates + candidate selection; content-authored lines can be sparse for Sprint 17)
9. `tell_state.rs` add `Option<&KnowledgeGraph>` to query (MVP boundary)
10. Formal Q-024, Q-025, Q-026 closure
**Sprint 17 should NOT include:**
- Full `EntityGrant { attributes: BTreeMap }` variant (Sprint 18)
- Conversation partner KG-awareness check (Sprint 18)
- Attribute contradiction via YAML-authored pairs (Sprint 18 — requires typed attribute keys)
- Template monologue system for named contradictions (Sprint 18; Sprint 17 hand-authors the FRIEND NPCs)
- "Ask around" emergent information-seeking behavior (v0.2)
- Pathfinding KG integration (v0.2 or never)
---
## Formal Q-Closure Positions
| Question | My position | Rationale |
|----------|------------|-----------|
| Q-024 (Gossip timing) | CLOSE — resolves to conversation system hook | Workshop consensus; `transfer_npc_knowledge` system is the implementation |
| Q-025 (KG memory pressure) | CLOSE — not needed at current scale | Tyre's 6MB total confirmed; re-evaluate at 500+ Active NPCs |
| Q-026 (Contradiction detection) | CLOSE — resolves to event-driven write-time detection with `ContradictionClaim` | Workshop consensus on architecture; Entity grant scope (Tension A) resolves to Sprint 17 narrow scope |
---
## Dissent Register
Items where I'm NOT conceding despite consensus pressure:
**1. Dudley's Option B workaround for `EntityKnowledge`:** I am not withdrawing my objection. String parsing in contradiction detection is wrong. Tyre's struct approach is correct. The only reason to choose Option B is "Sprint 17 time pressure" — and I dispute that adding one optional struct field is meaningfully more time-consuming than implementing a string encoding scheme plus parser. The workaround costs more total time when Sprint 18 refactor is included.
**2. The `EntityPositionGrant` must be Sprint 17:** Paula is correct that without this, THE FRIEND arc cannot fire its canonical contradiction sequence. Dudley's structured FactId workaround does not faithfully substitute. This is the FRIEND arc's critical path and it must ship in Sprint 17. If the team decides to defer, the decision should be recorded as "THE FRIEND arc contradiction sequence cannot fire in v0.1" — not "we found a workaround."
@@ -0,0 +1,309 @@
# Paula — Round 1: Narrative Analysis
## Knowledge Flow & NPC Information Boundaries Workshop
**Date:** 2026-02-23
**Role:** Narrative and Political Depth
---
## Framing
The technical gap this workshop addresses — NPCs running on ground truth, gossip not transferring knowledge, contradiction detection not firing — is not just a systems problem. It is a narrative problem. Every one of these missing pieces corresponds to a moment in THE FRIEND arc that cannot yet land.
Sera Venn volunteers information that the detective doesn't yet know. That's unprompted disclosure (#172). The detective later observes something that contradicts Sera's account. That's contradiction detection (Q-026). The monologue that fires ("Sera said Kael was at the dock. I just saw him in B-7.") is the emotional centerpiece of v0.1 — and it currently cannot happen because the `ToldBy` source that would tie Sera's testimony to that contradiction was never constructed.
This workshop is asking what it means for an NPC to *know* something. My job is to make sure the answers we reach don't just pass technical review — they have to feel true to how people actually hold information, share it, and get caught in contradictions.
---
## Topic 1: Knowledge Flow — The Grant Mechanism
### The authoring philosophy question underneath the schema question
Before discussing whether `KnowledgeGrant` needs an `entity_grant` variant, there is a prior question: *What does it mean for an NPC to "tell" the player something?*
In the current system, dialogue lines are content artifacts. They sit in line pools, get scored and selected, get displayed. They don't *do* anything to the world's information state. The player reads "Kael runs the cargo intake" and nothing changes in the KG. The dialogue was informative in the sense that a book is informative — it delivered text — but it didn't create a knowledge relationship between the observer and the subject.
Testimony is not just text delivery. When Sera says "Kael was at the dock during second shift," she is creating a *source-attributed claim about a specific person at a specific time*. That claim has provenance (Sera, tick N), it has a confidence level (KnowsOf — she's not guessing, she observed), and it can later be contradicted. The grant mechanism is what makes the claim real in the system.
### Position: `KnowledgeGrant` needs two variants
The current schema (`KnowledgeGrant { fact_id: String, confidence: String }`) only covers fact knowledge — entries in `facts: BTreeMap<FactId, FactKnowledge>`. But Sera's claim about Kael creates **EntityKnowledge**, not just a fact. It should update `entities: BTreeMap<StableId, EntityKnowledge>` with a new entry for Kael, sourced `ToldBy { source_id: sera_sid, tick }`.
I propose `KnowledgeGrant` be extended to a sum type:
```
enum KnowledgeGrantTarget {
Fact { fact_id: FactId },
Entity { entity_sid: StableId, attribute_key: String, attribute_value: String },
EntityPosition { entity_sid: StableId, location_description: String },
}
```
This is not just a schema preference. The downstream consequences are:
- **Contradiction detection requires EntityKnowledge**, not just facts. The canonical contradiction (Sera says "dock", player observes "B-7") operates on `EntityKnowledge.last_known_position` and `known_attributes`. If Sera's testimony only creates a `FactKnowledge` entry, the contradiction detector has nothing to compare against the `DirectObservation` that updates `EntityKnowledge`.
- **Source attribution requires the grant to create a `ToldBy` entry.** If the detective's KG entry for Kael is created `source: ToldBy { source_id: sera_sid, tick }`, the contradiction later marks BOTH the DirectObservation entry AND the ToldBy entry as `Contradicted`. The system can then surface: "Sera told you X. You observed Y. These conflict." Without the entity grant, you lose the source chain.
### Content authoring guardrail: what NPCs can grant = what they know
The workshop brief raises the question of content validation. My position: **NPCs should only be able to grant knowledge they hold in their own KnowledgeGraph.**
This is not merely an authoring convenience. It is a narrative truth constraint. If Sera's own KG doesn't contain an EntityKnowledge entry for Kael-at-the-dock, she cannot have told the detective about it — because she doesn't know. An author who writes a dialogue line with `knowledge_grant: { entity: kael_sid, position: "dock 7" }` on Sera's line has asserted that Sera knows this. The validator should check: does Sera's authored initial KG state include this entry? If not, flag it.
Runtime enforcement is harder (NPC KGs evolve during play), but the authoring-time check catches the obvious cases and forces authors to make NPC knowledge explicit.
### POI discovery as knowledge flow
For POI knowledge, I prefer option (a): a `FactId` category "poi.*" (e.g., `poi.dock_7_restricted`). The reasons are narrative:
- POI knowledge is *about a location*, not about a person. It fits the `FactKnowledge` model cleanly.
- The player "discovering" a POI is precisely a `DirectObservation` event, just targeted at a location rather than an entity.
- Dialogue-granted POI knowledge (an NPC saying "there's a restricted cargo bay off corridor B-7") should create a `ToldBy` FactKnowledge entry at `Suspects` or `KnowsOf` confidence — lower than direct discovery.
- This integrates cleanly with `filter_by_access`: a player needs `poi.dock_7_restricted` in their KG at KnowsOf+ to get dialogue options about that location.
### Physical evidence vs dialogue grants
I see these as the *same grant mechanism* but with different triggering events:
- Dialogue-triggered: post-line-selection, the dialogue system constructs a `KnowledgeEvent` and pushes it to the queue
- Evidence-triggered: player "reads" a document or examines evidence, a similar event fires
The difference is the `source` field: dialogue creates `ToldBy`, evidence examination creates `DirectObservation` (you're directly observing the document's contents). A new `KnowledgeEventType::KnowledgeGranted { source: KnowledgeSource, target: KnowledgeGrantTarget }` covers both cases cleanly without a separate variant for evidence.
---
## Topic 2: NPC-to-NPC Knowledge Propagation (Q-024)
### The routine intersection hook is narratively correct
The existing `run_npc_conversations` system pairing NPCs by proximity is *already* modeling something true about information flow in a community: people share information when they're in physical proximity, during routine overlap, not via some abstract information broadcast. This is the right hook. No separate system needed.
What I want to advocate for is the *content* of what transfers, not just the mechanism.
### Trust-gated filtering: not just tiers, but relationship character
The proposed mapping (surface trust = public facts, real trust = observations/rumors, secret trust = sensitive knowledge) is correct in structure, but misses something. The *character* of what NPC A shares with NPC B depends not just on trust level but on *subject matter*:
- Voss will share public knowledge about cargo schedules with anyone. He'll share his suspicions about manifest discrepancies only with people he trusts. He'll never share that he knows about the ring — regardless of trust tier — because he's terrified.
- Kael will share warmth freely. His secret (trying to exit the ring) is not something he'd share even at the highest trust tier, because sharing it is dangerous.
This suggests trust-gated filtering should be necessary-but-not-sufficient. Alongside trust tier, NPCs should have a `disclosure_threshold_override` on individual KG entries — a flag that says "even at maximum trust, don't share this." This is how Major secrets should behave: they are withheld at the KG entry level, not filtered by trust tier.
### Confidence downgrade on transfer: the proposed rule works
The proposed rule (`ToldBy` confidence = `min(source_confidence, KnowsOf)`) is right. When NPC A tells NPC B something, B doesn't have A's direct observation — B has A's report. That's `KnowsOf` at best. `Direct` observation never transfers through gossip; you can't tell someone else what it's like to *see* something and have them receive that experience.
The narrative effect is important: gossip chains dilute information. By the time the fifth NPC in a chain hears something, it's `Suspects`-level, not `KnowsDetails`. This is accurate to how communities work.
### Rate limiting: 1-3 facts per conversation, but weighted by relationship
A fixed cap of 1-3 facts per conversation works mechanically. But I'd weight by trust level:
- Surface trust: 0-1 facts (pleasantries and public knowledge only)
- Real trust: 1-2 facts
- Secret trust: up to 3 facts, but only if NPC has relevant high-trust entries
The goal is to prevent knowledge-explosion (correctly identified in the brief) while also preventing "people with deep trust never share anything." The weighting makes NPCs feel like they're calibrated to the relationship.
### Overheard NPC conversations: always Suspects, no exceptions
If the player overhears an NPC-NPC conversation (D-078), the player's KG entry should always be at `Suspects` confidence, regardless of how much of the conversation survived occlusion. Here's why:
1. **The player lacks context.** They heard words, not an explanation. "He said Kael was at the dock" means nothing without knowing who "he" is, what "the dock" refers to, whether this is past or present.
2. **Suspicion is the correct epistemic state.** You overheard something. You don't know if it's true. `Suspects` is honest.
3. **It creates investigative motivation.** You need to confirm. You seek out sources. You build the case. `KnowsOf` for overheard information would shortcut this.
The source variant should be `KnowledgeSource::Heard { tick, range: Close }` — not `ToldBy`, because the NPC didn't tell *you* anything; you were eavesdropping. This distinction matters for contradiction detection: an overheard claim is harder to attribute for contradiction purposes than a direct testimony.
---
## Topic 3: Unprompted Disclosure Design (#172)
This is where I have the strongest opinions, because this is where THE FRIEND arc's emotional setup lives.
### The setup that has to work
Sera Venn's relationship with the detective follows this arc (from D-034):
- Phase 1 (trust): Commission tech, bar regular, warm and informative, socially comfortable
- Phase 2 (data): Begins volunteering information about cargo processes, casually — this is unprompted disclosure
- Phase 3 (recognition): Player observes a behavioral tell — Sera avoids Torek Lintar
- Phase 4 (question): Player realizes Sera is sitting on unreported evidence
- Phase 5 (contamination): Trust is re-evaluated
The whole arc *requires* that Phase 2 actually happen mechanically. Sera must volunteer information to the player before the contradiction. If she only speaks when spoken to (dialogue tree), the player has no expectation of Sera as a source — and when the contradiction lands, it doesn't mean anything. The sense of betrayal requires prior gift-giving.
This is the narrative case for getting unprompted disclosure right: without it, there is no FRIEND arc. There is only an NPC who sometimes lies.
### "Do I know something you don't?" — Option A, firmly
NPCs should NOT check the player's KG before deciding to disclose. Option A (NPC checks own KG only) is the right choice, for narrative reasons that I think are non-negotiable:
1. **Dramatic irony requires asymmetric knowledge.** When Sera tells the detective "Kael runs a tight intake process — never a discrepancy on his watch" in Phase 2 — and the player has already found manifest discrepancies — the dramatic irony is crushing. Sera *doesn't know* the player already knows. She's praising someone whose cover is already partially blown. If Sera checked the player's KG and saw KnowsOf for manifest discrepancies, she might not say this. You've lost the moment.
2. **NPCs not knowing what you know is a feature, not a bug.** It's the source of almost all the interesting social texture. Characters talk at cross-purposes. People are still defending someone the player has already made a case against. The community is behind the detective.
3. **Option B creates a surveillance panopticon.** If NPCs know what the player knows, they're behaving as if they have access to the player's cognitive state. That's not a knowledge boundary — that's a boundary violation.
The result is that NPCs may repeat information the player already has. This is fine. The player has heard this before — but hearing it from THIS person, at THIS trust level, after THIS much has been discovered, has different weight. Authors should lean into it: "She's still defending him. Doesn't she know?"
### Trait filtering: both, with specific meaning for each
The brief asks: do traits affect WHAT is disclosed (filtering) or HOW (delivery)? The answer must be both, but they operate at different layers:
- **What (filtering):** Cautious NPCs have a higher disclosure threshold — they need more trust and more of their specific mood conditions before any fact makes it to the disclosure candidates list. This is a KG-level filter. A cautious Sera might have 40 facts in her KG but only 2 that would clear her disclosure threshold at any given trust level.
- **How (delivery):** Once a fact clears the threshold, Sera's specific personality shapes the line. Her warmth, her slight formality, her tendency to frame things in terms of professional competence. This is the line pool scoring modifier — same facts, different pool weighting.
The two-layer model means: trait changes "how many things Sera would ever say unprompted" (filtering) AND "how she says the things she does say" (delivery). This is richer than either alone and avoids the edge case where a cautious NPC with a Major secret that must be disclosed has no way to shape the delivery.
### Trigger conditions
Proposed trigger set for unprompted disclosure:
1. **Trust threshold met** (RelationshipState: Friendly minimum)
2. **Contentment above neutral** (> 0 on the Contentment axis)
3. **Mood compatible** (not Hostile, Anxious above threshold, or Frustrated) — use the 8-mood vocabulary from D-035/D-035 Amendment Sprint 14
4. **Location appropriate** — this condition is missing from the brief and I want to argue for it explicitly (see below)
5. **Rate limit not exceeded** (the existing `LINE_COOLDOWN_TICKS: u64 = 600` from dialogue.rs line 39 should apply)
**Location appropriateness as a disclosure gate:**
Sera won't confide sensitive information at the Terminal (too many witnesses, professional context). She will at The Last Shift in a quiet corner. This maps to D-025's public/private spatial distinction — "functional cluster defines spatial identity including public/private."
Content authors should be able to tag disclosure candidates with a `location_privacy: [private, semi-private, any]` field. The disclosure trigger checks the NPC's current location against this. A `private`-tagged disclosure won't fire at The Terminal. This is not just realism — it creates spatial behavior patterns the player can learn: "If I want Sera to talk, I need to catch her at Lera's, not at work."
### Rate limiting structure
Three-layer rate limiting:
1. **Per-fact cooldown** (highest priority): once a fact has been disclosed, mark it in the NPC's disclosure state. Don't repeat it. The player has heard it. Repeating it is noise.
2. **Per-NPC cooldown** (second): prevents disclosure spam in a single interaction. The `LINE_COOLDOWN_TICKS` (600 ticks) seems right as a floor.
3. **Global rate limit across all NPCs** (third, lightest): exists mainly to prevent a degenerate case where the player visits 8 NPCs in rapid succession and gets flooded with disclosures in one game-hour.
The per-fact cooldown is the most important narratively: an NPC who says the same thing twice is a quest marker, not a person.
---
## Topic 4: NPC Information Boundaries (#142)
### The narrative argument for minimum viable first
The workshop brief correctly identifies minimum viable boundary as `tell_state.rs` + unprompted disclosure. I want to reinforce *why* this is the right order from a narrative standpoint — not just a feasibility standpoint.
`tell_state.rs` currently derives tell category from raw axes: Secret severity, stress vs. threshold, contentment, mood, relationships. This produces behaviors like "NPC is Guarded because they have a Major secret." That's correct. But what if the NPC's *knowledge* of their secret's exposure changes their behavior? Sera's `Guarded` tell should intensify if she *knows* the detective is investigating Kael — not just because her Secret severity is Major, but because her KG contains entries showing investigation proximity.
The minimum viable boundary retrofit for `tell_state.rs` is: **allow KG-derived knowledge to influence the Secret's effective stress level.** If the NPC knows someone is getting close to their secret, the threat multiplier on `current_stress` increases. This doesn't require a full KG query — just a check: "does my KG contain entries related to the entities/facts that my Secret references, at PersonOfInterest or higher?"
This is a very small addition that produces a very meaningful behavioral change: NPCs who *know they're being watched* act more stressed. NPCs who are unaware remain calm.
### Priority order for system retrofits
From a narrative impact standpoint, I'd prioritize:
| Priority | System | Narrative Payoff |
|----------|--------|-----------------|
| 1 | `tell_state.rs` | Direct: NPC observable behavior reflects what they know. Every investigation interaction benefits. |
| 2 | Unprompted disclosure (#172) | Direct: Enables Phase 2 of THE FRIEND arc. Without this, Sera is mute pre-contradiction. |
| 3 | `conversation.rs` | Medium: NPC-to-NPC conversations become information events, not just ambient noise. |
| 4 | `routine.rs` | Low for v0.1: routine deviation is already a tell signal (D-027). Whether the routine query uses KG is less impactful initially. |
| 5 | `path_follow.rs` | Very low / deferred: KG-based pathfinding creates stuck-NPC risk (noted in brief). For v0.1, ground truth pathfinding with KG-based decisions is the right split. |
### Fallback behavior: ground truth with logging, not silent
For all retrofitted systems, when the NPC's KG has no relevant information, **fall through to ground truth with structured logging** (not silent). The logging serves two purposes:
1. QA visibility: we can see which systems are still operating on ground truth and why
2. Design signal: if we see many log entries for a specific NPC/system, it indicates an authoring gap (NPC's initial KG state is missing entries that should be there)
Option (c) — "ask around" behavior — is compelling for future sprints. An NPC who doesn't know something seeking out information creates exactly the emergent scenes D-029 needs for the mundane 80%. But it's correctly deferred for v0.1.
---
## Topic 5: Contradiction Detection Pipeline (Q-026)
This is where the mechanical and narrative come into direct contact, and I have specific things to say about the emotional sequence.
### The THE FRIEND arc — full mechanical sequence
The brief asks for a complete walkthrough confirming every system fires correctly. Here is the canonical version from my narrative perspective:
**Precondition:** Sera's KG contains `EntityKnowledge` for Kael: `last_known_position: Some(DockTile)`, `source: DirectObservation { tick: T0 }`, confidence: `KnowsDetails`. She observed Kael at the dock during second shift.
**Step 1: The testimony**
- Detective initiates Talk with Sera. Dialogue pipeline selects a line with `knowledge_grant: { entity: kael_sid, position: "dock intake, second shift", confidence: KnowsOf }`.
- `KnowledgeEvent::KnowledgeGranted` fires. Player's KG gains `EntityKnowledge` for Kael: `source: ToldBy { source_id: sera_sid, tick: T1 }`, confidence: `KnowsOf`, position claim: dock, time window: second shift.
**Step 2: The observation**
- At tick T2 (same game-shift or overlapping time window), player's LOS includes Kael in corridor B-7.
- `KnowledgeEvent::DirectObservation` fires. Player's KG entry for Kael: `source: DirectObservation { tick: T2 }`, confidence: `Direct`, position: B-7.
**Step 3: Contradiction detection fires**
- Event-driven: on KG write (T2), contradiction detector runs against the new `DirectObservation` entry.
- Check: same entity (Kael_sid), overlapping time window (T1 and T2 within second-shift window), different positions (dock vs B-7).
- Both entries receive `KnowledgeState::Contradicted`.
- `ContradictionDetected` event emits: observer = detective, entries = [ToldBy(Sera, dock), DirectObservation(B-7)], type = location.
**Step 4: Downstream cascade**
- Monologue system picks up `ContradictionDetected`. Fires contradiction monologue line — **must be authored to name Sera specifically**. Generic "wait, that doesn't add up" is not sufficient here. The line must say: *"Sera said Kael was at the dock during second shift. I'm looking at him in B-7 right now. One of them is wrong."*
- Anomaly system marks Kael AND Sera as `PersonOfInterest` (both entities are implicated — Kael by being in the wrong place, Sera by the testimony that is now contradicted).
- D-033: both entities shift to amber.
- Available dialogue with Sera unlocks Confrontation option.
**Step 5: The ambiguity**
- The monologue fires `Contradicted` — not "Sera lied." The player doesn't know if Sera is lying, mistaken, or manipulated. The engine is correct to be epistemically neutral. The content must be too.
### On the "both entries Contradicted" design
I want to explicitly endorse the design choice that *both* entries receive `Contradicted` state. This is narratively correct. When you have contradictory sources, you can't know which is wrong. The testimony might be wrong (Sera lied/was confused). The observation might be wrong (Kael has a double? Player misidentified?). The engine marks both as uncertain, which is the honest epistemic state.
Authors writing Sera's post-contradiction dialogue must NOT have Sera behave as if she knows she's been caught. She doesn't know the player has contradicted her testimony. Her behavior changes only if the player CONFRONTS her. This is the "no player-special-casing" principle (D-010) expressed in character psychology.
### Contradiction content authoring requirements
This is a gap in the current framework that I want to surface for Round 2 discussion:
**Contradiction monologue lines must be authored with source attribution.** A generic `trigger: contradiction_detected` monologue line ("that doesn't add up") is insufficient. The player needs to understand WHO provided the contradicted claim. This requires the monologue system to receive the source entity's `StableId` and map it to a displayable name.
Options:
1. **Templated monologue lines** with entity name substitution: `"{source_name} said {entity_name} was at {claimed_location}. I just saw {entity_name} at {actual_location}."` — clean but requires template string support in the monologue system
2. **Pre-authored lines per relationship phase** — for THE FRIEND NPCs (Kael, Sera), write specific lines for each phase of the relationship that fire on contradiction. This is hand-authored, but THE FRIEND NPCs are already "no generation expansion, all hand-authored" per D-034.
3. **Generic fallback + authored override** — generic template for auto-generated NPCs, hand-authored lines for FRIEND-pattern NPCs
My preference is option 3. For v0.1 with only two FRIEND NPCs, the hand-authored lines (option 2 flavored content in the generic system) are achievable. The template system is the right long-term architecture.
### Attribute contradiction — typed keys vs authored pairs
The brief notes that `known_attributes: BTreeMap<String, String>` is untyped. For contradiction detection on attributes, I'd prefer **content-authored pairs with structured key conventions over a full typing system**.
Reasoning: attribute contradiction is rare and narratively significant. You don't want the engine silently marking two minor attribute discrepancies as contradicted. You want the author to say "this specific attribute combination is a contradiction that the player should notice."
Proposed approach: YAML schema extension with an optional `contradicts` field on `KnowledgeGrant`:
```yaml
knowledge_grant:
entity: kael_sid
attribute_key: "occupation_status"
attribute_value: "legitimate_cargo_handler"
# authored contradiction pair:
contradicts_attribute_value: "ring_member"
```
This surfaces the contradictions that *matter narratively* and keeps the detection system from firing on attribute mismatches that are informationally trivial.
---
## Summary: Narrative Requirements for Round 2 Decisions
The decisions this workshop produces need to satisfy the following narrative requirements to make THE FRIEND arc work:
| Requirement | Topic | System | Priority |
|-------------|-------|--------|----------|
| Testimony creates EntityKnowledge with ToldBy source | 1 | KnowledgeGrant schema | Critical — blocks contradiction detection |
| NPCs cannot grant knowledge they don't hold | 1 | Content validation | High — authoring integrity |
| POI knowledge via `poi.*` FactId | 1 | KnowledgeGrant schema | Medium |
| Trust-gated NPC-to-NPC sharing with Major secret override | 2 | Propagation model | High — prevents secret leakage |
| Overheard conversations always grant Suspects confidence | 2 | Event model | High — preserves investigation motivation |
| Unprompted disclosure uses Option A (NPC checks own KG only) | 3 | Disclosure algorithm | Critical — enables dramatic irony |
| Location privacy gates unprompted disclosure | 3 | Trigger conditions | High — spatial behavior patterns |
| Per-fact cooldown prevents disclosure repetition | 3 | Rate limiting | High — NPC authenticity |
| tell_state KG awareness (secret exposure intensity) | 4 | tell_state.rs retrofit | High — behavioral authenticity |
| Contradiction monologue names the source entity | 5 | Content system | Critical — emotional payoff |
| Both contradiction entries marked (epistemic neutrality) | 5 | Detection algorithm | Critical — no premature accusation |
| Authored attribute contradiction pairs | 5 | Schema | Medium |
The three "Critical" items are preconditions for D-027 success criterion #3 ("player names an NPC they felt conflicted about"). If testimony doesn't create ToldBy EntityKnowledge, contradiction detection cannot fire on THE FRIEND arc. If Option B is chosen for unprompted disclosure, the dramatic irony that makes Sera's contradiction land is destroyed. If the contradiction monologue is generic and nameless, the emotional moment dissipates into noise.
Everything else can be iterated. These three cannot.
@@ -0,0 +1,314 @@
# Paula — Round 2: Cross-Review and Synthesis
## Knowledge Flow & NPC Information Boundaries Workshop
**Date:** 2026-02-24
**Role:** Narrative and Political Depth
---
## Reading the Room
The consensus is strong. On Option A, on FactId POI, on event-driven detection, on both-entries-Contradicted — every participant reached the same conclusions independently. The disagreements are real but narrow, and Qatux has mapped them accurately.
My Round 2 work is:
1. **Tension A** — Respond to Dudley's registry concern with a concrete narrative-minimum position
2. **Tension B** — Take a position from narrative integrity grounds (I deferred in Round 1; I shouldn't have)
3. **Secondary disagreements** — Resolve or concede on several minor items
4. **Contradiction monologue** — Specify exactly what Mellanie needs to author
5. **D-record draft** — Narrative consequences, NPC disclosure behavior, THE FRIEND arc mechanical sequence
---
## Tension A: Entity Grants — My Revised Position
My Round 1 position was "Sprint 17, critical prerequisite, entity grants required." Dudley responded with a structured FactId workaround. I've now read his full walkthrough carefully, and I'm revising.
**The honest truth is: Dudley's workaround preserves the three narrative requirements I called non-negotiable.**
Let me check each against his proposal:
**Requirement 1: ToldBy source attribution** — Dudley's `KnowledgeGranted` event includes `source_id: StableId` (the NPC who granted the knowledge). When the fact `FactId("entity.kael-davan.location.second-shift-dock")` is inserted into the player's `facts` map, the `FactKnowledge.source = ToldBy { source_id: sera_sid, tick: T1 }` is preserved. Source attribution survives in the FactKnowledge entry. ✓
**Requirement 2: Contradiction detection fires on THE FRIEND arc** — Dudley's walkthrough shows this working: the fact grant encodes the claimed location, the DirectObservation writes the actual location as a structured attribute, the contradiction detector cross-references them. Complicated, but it works. ✓ (with caveats — see below)
**Requirement 3: Monologue can name Sera** — If the `FactKnowledge.source = ToldBy { source_id: sera_sid }` is accessible to the monologue system at contradiction time, Sera can be named. The key open question (Gestalt flagged for Dudley) is whether `StableId → NpcName` resolution is available in monologue system context. If yes, Requirement 3 survives. ✓ (contingent on Dudley confirming the lookup)
So: **I accept Dudley's Sprint 17 workaround, with three specific conditions.**
### Conditions on accepting the FactId workaround
**Condition 1: FactId naming convention must encode entity identity in a resolver-compatible way.**
`FactId("entity.kael-davan.location.second-shift-dock")` requires the contradiction detector to map `"kael-davan"` → `kael_sid` at runtime. The lookup should work via `EntityRegistry` + `NpcName` component (query: "find the entity whose canonical slug is 'kael-davan'"). This requires NpcName to carry a canonical slug field alongside the display name, or a separate slug-to-StableId lookup. If NpcName only carries `"Kael Davan"` (display), the detector needs a slug derivation function.
This is a small but load-bearing detail. **Dudley must confirm this resolution path before Sprint 17 implementation begins.** The FactId convention is useless if the contradiction detector can't cross-reference it against EntityKnowledge entries.
**Condition 2: The FactKnowledge `ToldBy` source must flow through the ContradictionDetected event.**
When contradiction detection fires on the Kael location fact, the `ContradictionDetected` event must include `entry_a_source: KnowledgeSource::ToldBy { source_id: sera_sid, tick: T1 }`. This is what lets the monologue system name Sera. If the event only carries `StableId`s and not the full `KnowledgeSource` (Dudley's event sketch in his Round 1 does include this — see his `entry_a_source: KnowledgeSource` field), this is already satisfied.
**Condition 3: Sprint 18 entity grants are a formal commitment, not an aspiration.**
The FactId workaround is architecturally inelegant. It creates a cross-reference dependency between `facts` and `entities` maps that doesn't exist today. It requires string-parsing entity slugs at contradiction detection time. It means the player's KG doesn't gain an `EntityKnowledge` entry for Kael just because Sera mentioned him — the player only "knows Kael exists" after directly observing him.
That last point is narratively acceptable for v0.1 (the detective doesn't put Kael in their mental entity roster from hearsay alone; they have to encounter him). But it limits what other systems can do with entity-level testimony before the player has LOS on the target.
**The D-record for this workshop should explicitly record Sprint 18 entity grants as committed scope, not "future enhancement."**
---
## Tension B: EntityKnowledge Structural Fix — Taking a Position
I deferred in Round 1. I shouldn't have. Here's my position:
**The Tyre/Gestalt typed struct approach (`contradicted_claim: Option<ContradictionClaim>`) is narratively required, and I advocate for it over Dudley's Option B string-encoding even for Sprint 17.**
The reason is specific and non-trivial: Dudley's Option B encodes the prior claim's source as a string within `known_attributes`: `"claim.{tick}.position" = "{x},{y},{source_stable_id}"`. The `source_stable_id` is an integer serialized as a string. Parsing it back to a `StableId` for monologue source lookup adds another string→typed-value step on top of the already-required slug→StableId step in Condition 1 above.
The Tyre/Gestalt approach stores `ContradictionClaim { source: KnowledgeSource, position: TilePosition, tick: u64 }` — typed. When the monologue system reads the `ContradictionDetected` event, it gets `ToldBy { source_id: StableId }` directly. No parsing.
**This is a 15-line struct addition to `EntityKnowledge` versus an encoding/parsing convention that creates two points of fragility.** From a narrative integrity standpoint, the source attribution must be clean at every step in the chain. The monologue system is the consumer — it must be able to name the source reliably.
I endorse Tyre and Gestalt's approach. I'll note that Gestalt's naming (`contradiction_basis`) and Tyre's naming (`contradicted_claim`) are identical architecture — they should align on one name in Round 2 synthesis. My preference: `prior_claim` — it's the most semantically precise ("the claim made before this observation contradicted it").
---
## Secondary Disagreements — Resolved Positions
### Global rate limit (3-vs-1 with Gestalt dissenting)
I'm softening. Gestalt's concern — that a per-tick global cap creates invisible NPC competition — is valid. If Voss and Sera both have disclosure candidates and are both in range, and the cap silently selects Voss, the player never knows Sera had something to say. That's an invisible cost.
**Revised position:** Support the trigger conditions being strict enough that simultaneous disclosure is rare by design rather than enforced by a hard cap. If the requirements (Friendly relationship + positive Contentment + compatible mood + location privacy + witness inhibition) are all gates, the probability of two NPCs meeting them simultaneously is low. No global cap needed for v0.1. If it turns out disclosure spam occurs in playtesting, add the cap then.
**I withdraw the global rate limit requirement.** Gestalt is right.
### Location privacy gate (my proposal, no opposition but no support)
I'm maintaining this as a v0.1 trigger condition. It's the only position from my Round 1 that none of the engineers addressed — not because they disagreed, but because it requires a content-side decision before implementation.
Concretely: disclosure candidates should be taggable with `disclosure_context: [private, semi-private, any]` in the YAML schema. The `process_unprompted_disclosure` system checks the NPC's current location type (itself a fact in the environment data) against this tag before firing.
For THE FRIEND arc, Sera's Phase 2 disclosures about Kael's professional behavior should be `semi-private` — she'd say them at The Last Shift over a drink, not at The Terminal while colleagues are nearby. This creates a spatial behavior pattern the player can learn and exploit.
**This is a Mellanie authoring question as much as a system design question**: authors must choose `disclosure_context` when writing each disclosure candidate. I'll make sure the authoring guide covers it.
### Trust-weighted rate limiting vs flat rand 1-3
I concede. The flat `rand.random_range(1..=3)` is simpler and Tyre, Gestalt, and Dudley all converge on it. My trust-weighted variant (Surface: 0-1, Real: 1-2, Secret: up to 3) adds complexity without strong mechanical payoff — the trust tier already gates which facts are eligible, so the count cap is just a ceiling on an already-filtered pool. Flat random is fine.
**I withdraw the trust-weighted rate limit proposal.**
### Major secret `disclosure_threshold_override`
I'm maintaining this, but reframing it more precisely for engineering clarity.
The core requirement: **Kael should never disclose his ring membership even at maximum trust, because doing so is dangerous to him regardless of relationship closeness.**
This doesn't need a new per-entry override field. It can be implemented as a **disclosure eligibility tag on KG entries**. When Kael's KG is authored, his ring-membership fact is tagged `disclosure_eligible: false`. The `derive_disclosure_candidates` system filters out any entry with this tag regardless of trust tier.
This is the same as a pre-filter, but it's expressed as content authoring (an attribute on the KG entry's initial state) rather than a runtime flag. It reads cleanly: "this fact exists in the NPC's knowledge, but they will never volunteer it." Authors set it for secrets that have survival stakes.
**Proposed addition**: `disclosure_eligible: bool` field on authored KG entries in the NPC profile YAML. Defaults to `true`. Set to `false` for Major secrets that must never be disclosed unprompted (only revealed via confrontation dialogue, not voluntary disclosure).
---
## Contradiction Monologue — Specification for Mellanie
This is the open question from Qatux's notes that must be resolved before Mellanie can author contradiction lines. I'm resolving it here.
### What the monologue system needs from the ContradictionDetected event
The monologue system must receive:
- `source_id: StableId` — who told the player the contradicted claim (Sera's StableId)
- `subject_id: StableId` — who/what the contradicted claim is about (Kael's StableId)
- `contradiction_type: ContradictionType::Location` (or Attribute)
- `claimed_state: String` — the prior claim in human-readable form (resolved from the FactKnowledge entry or ContradictionClaim struct)
- `observed_state: String` — what the player actually observed
The source_id → displayable name resolution (Gestalt's open question) works via: `EntityRegistry.to_entity(source_id) → NpcName.display_name`. **Dudley must confirm this is accessible in monologue system context.** If it is, everything else follows.
### Lines Mellanie needs to author: Sera/Kael contradiction
**Trigger specification:**
- Character: `detective`
- Trigger: `contradiction_detected`
- Prerequisite: `{ entity: sera_sid, state: Contradicted }` AND `{ entity: kael_sid, state: Contradicted }`
- Trust phase at detection time: should vary by relationship phase (Phase 2 vs Phase 3)
**Required lines per phase:**
**Phase 2 contradiction (trust established but not deep — the blindsiding):**
Primary: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."* (flat factual statement, no emotional interpretation — the character is still processing)
Secondary beat (immediate follow-up, 3-5 seconds later): *"One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."* (cognitive dissonance without accusation — the detective is genuinely uncertain)
**Phase 3 contradiction (later in the arc, if detective has more context on Sera's avoidance patterns):**
If additional context has accumulated (Sera's avoidance of Torek is already observed), a different line fires: *"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."* (connecting the dots, still not accusatory but seeing a pattern)
**Generic fallback (for auto-generated NPCs with location contradictions):**
*"{source_name} placed {subject_name} at {claimed_location}. I just saw them at {actual_location}."* — template substitution, no authored emotional tone. This is the system default; FRIEND-pattern NPCs always use hand-authored lines.
### The authoring principle
The contradiction monologue must express cognitive dissonance, not accusation. The detective doesn't know who or what is wrong. The emotional weight comes from the uncertainty, not from naming a villain. Mellanie should write as if the detective is genuinely confused first, suspicious second, and making accusations never (that's the player's job after further investigation).
Both Sera and Kael shift to amber (PersonOfInterest) from this moment. The Confrontation option appears for both. The detective's available topics with Sera narrow — she can no longer be engaged on subjects related to Kael without the option to confront. This is mechanical consequence; the monologue just supplies the character's internal experience of the moment.
---
## D-Record Draft Sections (My Domain)
These are draft contributions to the workshop's D-record. Tyre/Gestalt/Dudley hold the architecture sections; these are mine.
---
### D-0XX §N: Narrative Consequences of Knowledge Flow Design
**What it means for an NPC to tell you something:**
When a dialogue line fires a `KnowledgeGrant`, the grant creates a source-attributed knowledge entry — not just text delivery. The player's KG gains a `FactKnowledge` entry with `source: ToldBy { source_id: npc_sid, tick }`. This models testimony correctly: the claim is attributed to a specific person at a specific time. It can be confirmed, contradicted, or re-evaluated as the player learns more.
This design enforces that NPCs are *sources*, not just speakers. The same information from two different sources has different provenance — and different vulnerability to contradiction. What Sera told you on Tuesday is not the same epistemic object as what you saw at the dock on Wednesday.
**Epistemic neutrality as design principle:**
When contradicting claims are detected, both entries receive `KnowledgeState::Contradicted`. The engine does not determine which claim is wrong. This is not an evasion — it is the correct epistemic stance for a detective story. The player may have misidentified someone. The source may have been deceived. The contradiction may have an explanation that exonerates everyone. The engine marks uncertainty; the player investigates to resolve it.
**Dramatic irony requires Option A:**
NPCs checking only their own KG (not the player's) before deciding what to disclose preserves the most valuable structural feature of the narrative: NPCs can say things that are loaded with meaning the player already knows. When Sera praises Kael's operational integrity after the player has found manifest discrepancies, the irony is crushing — and it only works if Sera doesn't know what the player knows. Giving NPCs access to the player's KG would collapse this and produce NPCs who stay silent at exactly the moments their speech would be most dramatically charged.
---
### D-0XX §N+1: NPC Disclosure Behavior (Unprompted Disclosure Design)
**Disclosure candidate selection:**
NPC A compiles a `DisclosureCandidates` list by filtering its own KG:
1. Active entries only (not Stale, not Contradicted)
2. Confidence ≥ KnowsOf (Suspects-level facts are not voluntarily disclosed)
3. Not tagged `disclosure_eligible: false` (Major secrets, ring membership, dangerous knowledge)
4. Not recently disclosed to this player (per-fact per-NPC cooldown)
The NPC does NOT check the player's KG. Facts the player already knows may be disclosed again — this is correct behavior. Repeating information has different weight when the relationship or investigation context has changed.
**Trigger gate (all conditions must be met):**
| Condition | Implementation |
|-----------|---------------|
| RelationshipState ≥ Friendly toward player | From player's KG entity entry for this NPC |
| Contentment > 0 | From Contentment axis component |
| Mood not Hostile or Anxious-above-threshold | From MoodState component |
| Disclosure candidates not empty | From DisclosureCandidates component |
| No other NPCs present within N tiles (witness inhibition) | Proximity query (Gestalt's condition) |
| NPC's current location matches candidate's disclosure_context | Environment + YAML tag comparison |
| Per-NPC disclosure cooldown not active | From DisclosureCooldown component |
**Trait effects — two-stage:**
Stage 1 (what): Cautious trait removes candidates sourced from `ToldBy` (won't pass on rumors). Gossipy trait includes `Suspects` confidence candidates. Loyal trait suppresses candidates that implicate faction members. Trait → candidate filter predicate applied before trigger check.
Stage 2 (how): Surviving candidates select from role-specific line pool weighted by mood and topic. Trait modifiers in the line pool (D-028 trait transformation guide) shape delivery. Same fact, different voice.
**Per-fact cooldown as primary rate limit:**
Once a fact has been disclosed to the player, it is excluded from candidates until `LINE_COOLDOWN_TICKS` expires (currently 600 ticks = 1 game-hour, from dialogue.rs line 39). This prevents the same NPC from repeating the same information, which is the most authenticity-destroying behavior. NPCs are not quest markers.
---
### D-0XX §N+2: THE FRIEND Arc Mechanical Sequence (Canonical)
**This sequence is the primary validation test for the knowledge flow and contradiction detection systems. All design decisions in this workshop must be consistent with this sequence firing correctly.**
The canonical example uses the Detective's FRIEND arc: Sera Venn and Kael Davan (D-034).
**Preconditions:**
- Sera's authored KG contains: EntityKnowledge for Kael at `KnowsOf`, position claim "dock intake, second shift," `source: DirectObservation` (she has seen this herself)
- Sera has a dialogue line: `"Kael runs intake. Always at dock during second shift — never a discrepancy on his watch."` with `knowledge_grant: { fact_id: "entity.kael-davan.location.second-shift-dock", confidence: "knows_of" }`
- Player's KG has no entry for Kael (has not yet encountered him)
**Step 1 — Testimony grant (tick T1):**
Detective initiates Talk with Sera. Dialogue pipeline layers 1-3 pass. Layer 4 selects Sera's line. Server fires `KnowledgeGranted { fact_id: FactId("entity.kael-davan.location.second-shift-dock"), confidence: KnowsOf, source_id: sera_sid }` into `KnowledgeEventQueue`.
`process_knowledge_events` tick T1: inserts `FactKnowledge { confidence: KnowsOf, source: ToldBy { source_id: sera_sid, tick: T1 }, state: Active }` into player's `facts` map.
`detect_contradictions` check tick T1: no prior claim for this entity. No contradiction. ✓
**Step 2 — Direct observation (tick T2, same game-shift as T1 or overlapping window):**
Player LOS includes Kael in corridor B-7. Perception system fires `DirectObservation`. `observe_entity(kael_sid, B7_position, T2)` runs.
Before overwriting (Tyre/Gestalt's `prior_claim` approach): check whether `EntityKnowledge` for kael_sid exists. It does not yet (player hasn't seen him) — no prior claim to preserve. New `EntityKnowledge { last_known_position: Some(B7), source: DirectObservation { tick: T2 }, confidence: Direct }` created.
`detect_contradictions` check tick T2: queries player's `facts` map for entries matching "entity.kael-davan.*". Finds `FactId("entity.kael-davan.location.second-shift-dock")` from T1. Resolves "kael-davan" slug → kael_sid via EntityRegistry + NpcName. Compares: fact claims dock at T1, observation finds B7 at T2. T2 - T1 < `CONTRADICTION_WINDOW_TICKS`. Positions differ. **CONTRADICTION DETECTED.** ✓
**Step 3 — Both entries marked Contradicted (tick T2):**
`FactKnowledge("entity.kael-davan.location.second-shift-dock").state = Contradicted`.
`EntityKnowledge(kael_sid).state = Contradicted`.
`ContradictionDetected` event pushed: `{ observer: detective_entity, source_entry: ToldBy { source_id: sera_sid, tick: T1 }, subject_id: kael_sid, contradiction_type: Location }`. ✓
**Step 4 — Downstream cascade (tick T2, T3):**
- Anomaly system: `AnomalyMarker` attached to Kael entity (anomaly.rs already handles `Contradicted` state — tested)
- Relationship update: `EntityKnowledge(kael_sid).relationship = PersonOfInterest`. D-033 amber on Kael.
- Contradiction also implicates Sera as source: relationship system sets `EntityKnowledge(sera_sid).relationship = PersonOfInterest`. D-033 amber on Sera.
- Monologue system: receives `ContradictionDetected` event. Resolves `source_id: sera_sid` → `NpcName.display_name: "Sera"`. Selects hand-authored contradiction line for Phase 2 detective-Sera relationship. Fires: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."*
**Step 5 — New dialogue options unlock (next Talk interaction):**
Confrontation option appears for Sera (new topic, previously invisible per D-062).
Confrontation option appears for Kael.
Available topics with Sera narrow: Kael-related topics now flagged as confrontation-eligible.
Sera's dialogue pool shifts to post-contradiction phase lines (different access tier scoring, trust tier holds but topic weights shift).
**Step 6 — What Sera knows (the epistemic neutrality moment):**
Sera does NOT know the detective has seen Kael in B-7. Her KG has no entry suggesting the detective observed Kael anywhere. She has no reason to change her behavior. She may greet the detective warmly the next time they meet. This is correct — and this is what makes the confrontation dramatically charged when it comes.
Sera's dialogue does not shift on the detective's side until the detective CONFRONTS her. Before that, the detective holds the contradiction alone. The internal monologue is the only witness.
**Validation result:** All systems fire in the correct sequence. The downstream consumers (anomaly, monologue, relationship, color) are tested and operational. The critical path dependencies (ToldBy source constructible, prior claim preserved, source_id resolvable to name) are specified above and must be confirmed before implementation closes Sprint 17 work.
---
## Open Questions I'm Closing
**`CONTRADICTION_WINDOW_TICKS` value — what is "same time window"?**
Gestalt proposes 600 ticks (1 game-hour). I asked what timeframe makes contradiction "not stale."
My answer: **600 ticks is right for location contradictions.** The FRIEND arc example — Sera says Kael is at the dock during second shift, player sees Kael elsewhere — needs to fire within a game-shift (8 game-hours = 4800 ticks) but should probably not fire if the observation is a whole game-day apart. 600 ticks (1 game-hour) is a reasonable window for "were you really in both places at once?"
But the window should be configurable per contradiction type, not global. A schedule-based claim ("Kael is always at dock, second shift") should have a longer window than a "I just saw X at location Y" claim. For v0.1, 600-tick default with content-authored override is the right approach.
**Monologue `StableId` → displayable name — is it available?**
This is Gestalt → Dudley's open question. **I'm flagging it as a Sprint 17 blocker for contradiction monologue.** If the monologue system context cannot read `NpcName` components (because it runs on a different schedule or doesn't have entity query access), the source-named lines cannot fire correctly. The workaround (pre-resolve names into the `ContradictionDetected` event payload) is simple: when the detector fires the event, resolve both `source_id` and `subject_id` to display names immediately via EntityRegistry + NpcName, and include the resolved strings in the event struct. The monologue system then reads `String` fields, not StableIds. No ECS query required at monologue system run time.
**Recommended addition to `ContradictionDetected` event:**
```
ContradictionDetected {
...,
source_display_name: Option<String>, // resolved at detection time
subject_display_name: Option<String>, // resolved at detection time
}
```
This moves the resolution burden to the detection system (which already has EntityRegistry access) and makes the monologue system a pure consumer of pre-resolved strings.
---
## Summary: My Positions for Round 2 Synthesis
| Topic | My Position | Status |
|-------|-------------|--------|
| Tension A: Entity grants | Accept Dudley's Sprint 17 workaround (FactId with slug naming convention + sprint 18 entity grant commitment) | **Resolved, with 3 conditions above** |
| Tension B: EntityKnowledge structural fix | Tyre/Gestalt typed `prior_claim` struct required (not Dudley's Option B) because monologue source attribution must be clean | **Position taken** |
| Contradiction monologue naming | `ContradictionDetected` event pre-resolves display names; Mellanie gets authored spec (see above) | **Resolved** |
| Global rate limit | Withdrawn; Gestalt is right | **Conceded** |
| Location privacy gate | Maintained as v0.1 trigger condition; content-authored `disclosure_context` field on YAML | **Maintained** |
| Trust-weighted rate limit | Withdrawn; flat rand 1-3 is sufficient | **Conceded** |
| Major secret override | Maintained as `disclosure_eligible: false` YAML field on authored KG entries | **Maintained, reframed** |
| `CONTRADICTION_WINDOW_TICKS` | 600 ticks default, configurable per contradiction type | **Answered** |
| Monologue StableId resolution | Pre-resolve into event payload at detection time; monologue reads strings | **Answered** |
The design is coherent. The FRIEND arc fires. The downstream consumers are ready. The only open question blocking Mellanie is whether Dudley can confirm `StableId → NpcName` resolution path for the ContradictionDetected event — if yes, she can begin authoring the contradiction discovery lines immediately.
@@ -0,0 +1,215 @@
# Round 1 Notes — Knowledge Flow & NPC Information Boundaries Workshop
**Documented by:** Qatux
**Date:** 2026-02-23
**Sources:** tyre-round1.md, gestalt-round1.md, dudley-round1.md, paula-round1.md
---
## Summary
Four participants submitted Round 1 analyses across five topics: the grant mechanism (Topic 1), NPC-to-NPC gossip (Topic 2), unprompted disclosure (Topic 3), information boundaries (Topic 4), and contradiction detection (Topic 5). Consensus is strong on fundamentals. Two structural tensions require resolution in Round 2 before implementation can proceed.
---
## Consensus Positions (all four participants aligned)
### Topic 1: Grant Mechanism
| Position | Agreed by |
|----------|-----------|
| Grant fires at **line selection time**, server-side — not at client display | Tyre, Gestalt, Dudley, Paula |
| Single `KnowledgeGranted` event type; no separate `EvidenceDiscovered` variant | Tyre, Gestalt, Dudley, Paula |
| POI discovery uses `FactId("poi.*")` namespace; no new type | Tyre, Gestalt, Dudley, Paula |
| Physical evidence grants use `DirectObservation` source; dialogue grants use `ToldBy` — same event, different source | Tyre, Gestalt, Dudley, Paula |
### Topic 2: NPC-to-NPC Propagation
| Position | Agreed by |
|----------|-----------|
| Existing `run_npc_conversations` system is the correct hook; no separate propagation system | Tyre, Gestalt, Dudley, Paula |
| Confidence cap: `transferred_confidence = min(source_confidence, KnowsOf)` — `Direct`/`KnowsDetails` downgrade to `KnowsOf`, `Suspects` stays `Suspects` | Tyre, Gestalt, Dudley, Paula |
| Overheard NPC-NPC conversations grant player `Suspects` confidence regardless of word-level occlusion (D-078) | Tyre, Gestalt, Dudley, Paula |
| Rate limit: 1–3 facts per conversation (prevents knowledge explosion, creates asymmetry) | Tyre, Gestalt, Dudley, Paula |
### Topic 3: Unprompted Disclosure
| Position | Agreed by |
|----------|-----------|
| **Option A**: NPC checks own KG only when deciding what to disclose; no cross-entity KG query | Tyre, Gestalt, Dudley, Paula |
| Disclosure candidates stored in a **separate component** (`DisclosureCandidates` / `DisclosureCandidateList`), not added to `DerivedTellState` | Tyre, Gestalt, Dudley |
| Traits affect **both** what is disclosed (candidate filtering) AND how it is delivered (line-pool scoring) | Tyre, Gestalt, Dudley, Paula |
| Per-fact per-NPC cooldown prevents the same NPC disclosing the same fact twice | All four |
### Topic 4: NPC Information Boundaries
| Position | Agreed by |
|----------|-----------|
| MVP boundary = `tell_state.rs` + unprompted disclosure (#172); no other retrofits in v0.1 | Tyre, Gestalt, Dudley, Paula |
| Background-tier NPCs get no KG-driven behavior; scope to `With<ActiveSim>` | Tyre, Gestalt, Dudley, Paula |
| Pathfinding (`path_follow.rs`) must NOT be retrofitted in v0.1 — failure mode (stuck NPCs) has no safe recovery | Tyre, Gestalt, Dudley, Paula |
| Fallback on missing KG entry: ground truth with structured logging, not silent | Tyre, Gestalt, Dudley, Paula |
| tell_state reads self-knowledge from axis components directly; KG applies to OTHER-state only | Tyre, Gestalt (same conclusion, different framing) |
### Topic 5: Contradiction Detection
| Position | Agreed by |
|----------|-----------|
| Detection is **event-driven at KG write time** — fires in `observe_entity()` before overwrite, not per-tick scan | Tyre, Gestalt, Dudley, Paula |
| **Both** the `ToldBy` entry and the `DirectObservation` entry receive `KnowledgeState::Contradicted` (epistemic neutrality — the engine does not determine who is wrong) | Tyre, Gestalt, Paula |
| Event chain: KG write → `ContradictionDetected` → monologue → `AnomalyMarker` → relationship shift → D-033 amber. Downstream consumers already exist and are tested. | All four |
| Attribute contradiction requires **content-authored contradiction pairs** (YAML), not automatic string comparison | Tyre, Gestalt, Dudley, Paula |
| Location contradiction is **automatic**: position + time window (`CONTRADICTION_WINDOW_TICKS`) | Tyre, Gestalt, Dudley |
### Cross-cutting
| Position | Agreed by |
|----------|-----------|
| **Close Q-025** (KG memory pressure). Current budget ~14 KB per Active NPC, ~6 MB total at current scale. Not a constraint until 500+ Active NPCs. | Tyre, Gestalt, Dudley |
---
## Key Tensions Requiring Round 2 Resolution
### Tension A: Entity Grants — Sprint 17 vs Sprint 18
**The question:** Should `KnowledgeGrant` be extended to support entity-level grants (creating/updating `EntityKnowledge` entries via dialogue) in Sprint 17, or deferred to Sprint 18?
**Positions:**
| Participant | Position | Rationale |
|-------------|----------|-----------|
| **Tyre** | Sprint 17 — extend YAML schema to compound grant (facts + entities) | Architecture is clean; compound grant is the right shape; entity_ref resolves to StableId at content load via BTreeMap |
| **Gestalt** | Sprint 17 — three-type enum: `FactGrant`, `EntityGrant`, `Compound` | Entity grants are required to make `KnowledgeSource::ToldBy` constructible; this is the missing piece |
| **Paula** | Sprint 17 — critical prerequisite | Without entity grants, `ToldBy` EntityKnowledge is never created; contradiction detection in THE FRIEND arc **cannot fire** |
| **Dudley** | **Defer to Sprint 18** | Entity grants require a "name → StableId" content registry that doesn't exist; Sprint 17 can use structured FactIds instead (e.g., `FactId("entity.kael.location.second_shift_dock")`) |
**Dudley's Sprint 17 workaround:** Encode entity+attribute+location claims as structured `FactId` strings. Contradiction detection then matches against the `FactKnowledge` entry. Avoids the entity name registry problem.
**Paula's counter:** This workaround loses the `EntityKnowledge.last_known_position` and `ToldBy` source. Contradiction detection on THE FRIEND arc requires *both* entries in `EntityKnowledge` — the `ToldBy` entry from Sera's testimony, and the `DirectObservation` when the player sees Kael in B-7. A FactId workaround bypasses `EntityKnowledge` entirely and breaks the contradiction chain.
**Note for the record:** Tyre's Sprint 17 estimate is ~1.5 days (schema + content-load entity_ref registry). Dudley's concern is the missing registry infrastructure. This is the crux.
---
### Tension B: `EntityKnowledge` Structural Fix — `ContradictionClaim` vs `contradiction_basis` vs Option B attribute encoding
**The structural problem (agreed by all):** `KnowledgeGraph.entities` is `BTreeMap<StableId, EntityKnowledge>` — one entry per known entity. When `observe_entity()` writes a new `DirectObservation`, it overwrites the existing `ToldBy` source. The prior claim is lost. Contradiction detection has nothing to compare against.
**Three proposed solutions:**
| Participant | Proposal | Structural impact |
|-------------|----------|-------------------|
| **Tyre** | Add `contradicted_claim: Option<ContradictionClaim>` to `EntityKnowledge`. Detect at write time (Approach B): before overwriting, compare incoming vs current; if contradiction, set `Contradicted` + store prior claim in new field | Adds one optional struct field to `EntityKnowledge`. Zero breaking changes. |
| **Gestalt** | Add `contradiction_basis: Option<ContradictionBasis>` to `EntityKnowledge`. Same detection timing. Struct fields: `conflicting_source`, `conflicting_position`, `detected_at_tick` | Structurally identical to Tyre's proposal, different naming |
| **Dudley** | **Option B for Sprint 17**: encode position claims as structured attribute strings in `known_attributes` (`"claim.{tick}.position" = "{x},{y},{source_sid}"`). Contradiction detector parses these strings. Sprint 18: refactor to proper struct (Tyre/Gestalt's approach). | Zero schema change for Sprint 17. Acknowledged as "ugly but minimal impact." |
**Assessment:** Tyre and Gestalt are proposing the same architecture with different field names — they should align on naming in Round 2. Dudley's Option B is a pragmatic workaround that avoids Sprint 17 schema change at the cost of string-parsing brittleness and a Sprint 18 refactor obligation.
Paula does not take a position on implementation approach; defers to the engineers.
---
## Secondary Disagreements (lower priority, but noted)
### Topic 2: Major secret override on trust-gated sharing
**Paula** proposes a `disclosure_threshold_override` flag on individual KG entries — a per-entry "never share regardless of trust tier" mechanism for Major secrets (e.g., Kael will not disclose his ring membership even to his closest contact). No other participant mentions this. It is a design addition, not a conflict with existing positions.
**For the record:** This is a new design element not currently in the trust-tier model. It does not contradict Tyre, Gestalt, or Dudley's trust-mapping tables — it adds a pre-filter before the tier lookup.
### Topic 2: Trust-weighted rate limiting
**Paula** proposes trust-weighted fact counts (Surface: 0–1, Real: 1–2, Secret: up to 3). **Tyre, Gestalt, Dudley** propose a flat `rand.random_range(1..=3)`. No strong opposition in any direction. Paula's proposal adds a trust-tier dependency; the others prefer simplicity.
### Topic 3: Disclosure trigger — witness inhibition
**Gestalt** adds a witness inhibition gate: NPCs are less forthcoming with an audience nearby. **Paula** adds a **location privacy gate**: disclosure candidates tagged `private` will not fire at public locations (e.g., Sera won't confide at The Terminal). These are additive, not conflicting.
**Tyre and Dudley** do not address either condition. The team will need to specify the trigger gate list before Dudley can implement `process_unprompted_disclosure`.
### Topic 3: Global rate limit
**Tyre** and **Dudley** both support a global rate limit (1 disclosure per tick globally) to prevent simultaneous disclosure from multiple NPCs. **Gestalt** opposes it — argues the limit is invisible to the player and creates unintelligible competition between NPCs. **Paula** supports a light global limit as a degenerate-case safeguard. This is a 3-vs-1 split with Gestalt dissenting.
### Topic 5: Contradiction monologue content
**Paula** argues strongly that contradiction monologue lines must name the source entity explicitly ("Sera said Kael was at the dock — I'm looking at him in B-7"). A generic "that doesn't add up" line is insufficient for THE FRIEND arc. Proposes three options:
1. Templated monologue lines with entity name substitution
2. Hand-authored lines per NPC relationship phase (FRIEND pattern NPCs)
3. Generic fallback + authored override (Paula's preference)
This requires the monologue system to resolve `ToldBy.source_id` (StableId) to a displayable name via `EntityRegistry` + `NpcName` component. Gestalt flags this as an open question for Dudley to confirm. No other participant addresses it. **This is a content system requirement that must be resolved before Mellanie can author the contradiction monologue lines.**
### Topic 1: Runtime NPC KG guardrail
**Tyre**: 3-line runtime check in `process_knowledge_events` — NPC cannot grant facts not in its own KG; grants for unknown facts are dropped with `tracing::warn!`.
**Gestalt**: Runtime enforcement + content-load validation (build pipeline check).
**Dudley**: Runtime check is Tier 3 difficulty, **not Sprint 17 scope**; authoring-time validation only for Sprint 17.
**Paula**: Authoring-time check is mandatory for narrative integrity; agrees runtime is harder given dynamic KGs.
This is a scope decision: Dudley's position is the most conservative. Tyre's 3-line check is low-cost; Gestalt's build-pipeline check is moderate work. Team lead to decide Sprint 17 scope.
---
## Implementation Order (Dudley's recommendation, uncontested)
```
Topic 1 (KnowledgeGranted event + dialogue wire)
↓ enables
Topic 4 MVP (tell_state KG awareness — can parallel with Topic 1)
Topic 2 (NPC-to-NPC transfer — unblocks Topic 5)
↓ enables
Topic 5 (contradiction detection — requires ToldBy entries to exist)
Topic 3 (unprompted disclosure — requires workshop decisions + populated NPC KGs)
```
Tyre's total estimate: ~11 days across Topics 1–5, parallelizable after grant schema is agreed.
---
## Open Questions Raised This Round
| ID | Question | Raised by |
|----|----------|-----------|
| — | Entity grants Sprint 17 vs Sprint 18 (see Tension A) | Dudley (dissent) |
| — | `EntityKnowledge` structural fix approach (see Tension B) | Tyre, Gestalt, Dudley |
| — | `CONTRADICTION_WINDOW_TICKS` value — Gestalt proposes 600 (1 game-hour); Paula asks what narrative timeframe makes "contradiction not stale info" | Gestalt, Paula |
| — | `DisclosureCandidates` compute trigger — is there a "player entered dialogue range" event or does this need a range-query every N ticks? | Gestalt → Tyre to confirm |
| — | Monologue `StableId` → displayable name lookup — is `EntityRegistry + NpcName` available in monologue system context? | Gestalt → Dudley to confirm |
| — | Witness inhibition trigger (Gestalt) + location privacy trigger (Paula) — which conditions are in v0.1 trigger gate? | Requires team decision |
| — | Global disclosure rate limit — include or exclude? (3-vs-1 split: Tyre/Dudley/Paula for, Gestalt against) | Gestalt (dissent) |
| — | Contradiction monologue authoring — templated / hand-authored / hybrid? (Paula requires source-named lines) | Paula → Mellanie/team decision |
| — | Major secret `disclosure_threshold_override` on KG entries — include in trust-gate model? | Paula (new proposal) |
---
## Decisions This Round Is Producing (pending formal D-record after workshop closes)
The following are **forming consensus** — not yet D-records, but positions Round 2 should confirm or amend:
1. `KnowledgeGranted` event type is the single mechanism for all knowledge input (dialogue grants, evidence, POI discovery)
2. Grant fires at line selection (server-authoritative, tick-deterministic)
3. `FactId("poi.*")` for POI discovery — no new type
4. NPC-to-NPC gossip piggybacked on conversation system; separate `transfer_npc_knowledge` system (Bevy ECS dual-mutable constraint — Dudley's finding)
5. Confidence cap `min(source_confidence, KnowsOf)` on all gossip transfers
6. Option A for unprompted disclosure (NPC checks own KG only)
7. Traits act as two-stage filter: candidate pool (WHAT) + line delivery (HOW)
8. MVP boundary: tell_state + disclosure; no pathfinding retrofit
9. Contradiction detection event-driven at KG write; both entries marked `Contradicted`
10. Q-025 closure (formal record pending D-record production)
**Not yet forming consensus (blocked on Tensions A and B):**
- Entity grant schema and Sprint 17 scope
- `EntityKnowledge` structural fix approach
---
## Note to Participants
For the record: the downstream cascade from contradiction detection (anomaly marker, D-033 color shift, relationship shift to PersonOfInterest) is **already implemented and tested**. The entire narrative payoff of THE FRIEND arc is gated on two things: (1) `ToldBy` EntityKnowledge being constructible from dialogue grants, and (2) the `EntityKnowledge` structural fix that preserves the prior claim before overwrite. Everything else in Topics 3–4 is valuable but not blocking THE FRIEND arc for v0.1.
Paula's framing is correct: Tensions A and B are the session's critical path.
@@ -0,0 +1,236 @@
# Round 2 Notes — Knowledge Flow & NPC Information Boundaries Workshop
**Documented by:** Qatux
**Date:** 2026-02-24
**Sources:** tyre-round2.md, gestalt-round2.md, dudley-round2.md, paula-round2.md
---
## Summary
Both principal tensions are resolved. Tension B (EntityKnowledge structural fix) resolved unanimously to the typed struct. Tension A (entity grants Sprint 17 vs 18) resolved **unexpectedly** — three engineers converged on entity grants in Sprint 17 via `ContentEntityRegistry`, while Paula (who originally called entity grants "Critical") accepted Dudley's FactId workaround with three binding conditions. All secondary disputes are resolved or conceded. The workshop is ready for formal D-record production.
---
## Tension A Resolution: Entity Grants — Split Outcome
**Result: 3-1 split (not a simple resolution).**
| Participant | Round 2 Position | Changed from Round 1? |
|-------------|-----------------|----------------------|
| **Tyre** | Entity grants Sprint 17 via `ContentNameRegistry` (BTreeMap, ~30 lines). FactId workaround breaks contradiction chain — the two data maps never intersect. | No change |
| **Gestalt** | Entity grants Sprint 17, narrow scope: `EntityPositionGrant` only (full `EntityGrant { attributes }` defers to Sprint 18). Static lookup against existing `entity-attributes.yaml`. | Refined (scoped narrower) |
| **Dudley** | **Changed to entity grants Sprint 17.** `ContentEntityRegistry` resource, spawn-time registration (~60 lines total). Confirmed FactId workaround breaks because `observe_entity` contradiction check is in the `entities` map — FactKnowledge (in `facts` map) is never compared against it. | **Major change** |
| **Paula** | **Accepted FactId workaround with 3 conditions.** The conditions are binding; if any fail, entity grants are required. | Reversed from Round 1 |
**The unexpected inversion:** Paula (Round 1: entity grants Critical, non-negotiable) accepted the workaround. The engineers (Round 1: split on feasibility) converged on entity grants as the cleaner path.
### Paula's 3 conditions on the FactId workaround
These conditions are binding. If any are not met, the workaround fails and entity grants are required for Sprint 17:
1. **Resolver-compatible FactId naming.** `FactId("entity.kael-davan.location.second-shift-dock")` requires the contradiction detector to map the slug `"kael-davan"` → `kael_sid` at runtime. NpcName must carry a canonical slug field, or a separate slug-to-StableId lookup must exist. This path must be confirmed before implementation begins.
2. **ToldBy source flows through `ContradictionDetected` event.** The event must include the full `KnowledgeSource::ToldBy { source_id: sera_sid, tick }` so the monologue system can name Sera. Dudley's Round 1 event sketch already includes `entry_a_source: KnowledgeSource` — if this is preserved, Condition 2 is satisfied.
3. **Sprint 18 entity grants are a formal commitment, not an aspiration.** The D-record for this workshop must record Sprint 18 entity grants as committed scope. The FactId workaround leaves the player without an `EntityKnowledge` entry for Kael from hearsay alone (they only gain one on direct observation), which limits future systems.
**For the record:** Tyre argues that even if Paula's 3 conditions are met, the cross-structure lookup (FactKnowledge checked against EntityKnowledge entries) is more complex than simply writing the entity grant correctly. The workaround requires the contradiction detector to scan the `facts` map for `"entity.*"` prefixed entries and cross-reference against `entities` — a dependency the typed entity grant avoids entirely. Gestalt registers a standing dissent: "If the team decides to defer entity grants, the decision should record that THE FRIEND arc contradiction sequence using `EntityKnowledge.ToldBy` cannot fire in v0.1 — not that we found a workaround."
**Workshop leaves this split for the team lead to resolve.** The implementation path is either:
- (A) Entity grants + ContentEntityRegistry (Tyre/Gestalt/Dudley, ~60 lines extra Sprint 17 infra)
- (B) FactId workaround (Paula accepts, contingent on 3 conditions) + entity grants committed to Sprint 18
---
## Tension B Resolution: `ContradictionClaim` Struct — Unanimous
**Result: Full consensus. Dudley conceded Option B.**
| Participant | Position |
|-------------|---------|
| **Tyre** | `contradicted_claim: Option<ContradictionClaim>` on `EntityKnowledge`. Naming: `ContradictionClaim`. `claimed_position` field. |
| **Gestalt** | Same architecture. Defers to Tyre's naming: `ContradictionClaim`. |
| **Dudley** | **Concedes Option B.** String parsing is fragile, pollutes `known_attributes`, and requires Sprint 18 refactor. Adopts `ContradictionClaim` struct. |
| **Paula** | Supports typed struct; her preference for field name `prior_claim` noted but not held firm. |
**Agreed struct (Tyre/Dudley final spec):**
```rust
pub struct ContradictionClaim {
pub source: KnowledgeSource, // Who made the contradicted claim (ToldBy source)
pub claimed_position: Option<TilePosition>,
pub detected_at_tick: u64,
}
// Added to EntityKnowledge:
pub contradicted_claim: Option<ContradictionClaim>, // None until contradiction fires
```
**Why Dudley conceded Option B:**
- String parsing in contradiction detection hot path is fragile (malformed string = silent failure)
- Monologue system needs typed `ToldBy { source_id }` for source attribution — a parsed string adds a second fragility point
- `known_attributes` is semantic NPC metadata; filling it with internal bookkeeping strings mixes concerns
- One optional field with serde default is not a meaningful schema change (all existing state deserializes with `None`)
---
## Secondary Dispute Resolutions
### Global disclosure rate limit
**Round 1:** 3-for (Tyre, Dudley, Paula), 1-against (Gestalt).
**Round 2 movement:**
- Paula **withdraws** her support: "The trigger conditions being strict enough makes simultaneous disclosure rare by design rather than requiring a hard cap." Agrees with Gestalt.
- Gestalt **partially concedes**: "I accept the global rate limit as a degenerate-case safeguard, with one condition: the winning NPC must be selected by deterministic StableId ordering (D-010 principle 4), not random."
- Dudley **maintains**: 1 per tick, StableId-ordered. Addresses Gestalt's invisible-competition concern (player watches one NPC; doesn't observe others not-speaking).
- Tyre **maintains**: per-game-minute (1 per 10 ticks), not per-tick.
**Resolution:** Include global rate limit. Deterministic selection by ascending StableId. Per-game-minute granularity (1 per 10 ticks, per Tyre; more conservative than per-tick). This means in practice the limit only fires when 2+ NPCs have simultaneous first-time disclosures in the same game-minute — a degenerate edge case. Normal play is governed by per-NPC cooldowns (300 ticks).
### Contradiction window (`CONTRADICTION_WINDOW_TICKS`)
| Participant | Proposal |
|-------------|---------|
| Gestalt | 600 ticks (1 game-hour) |
| Tyre | 1800 ticks (3 game-hours) |
| Dudley | 600 ticks (1 game-hour) |
| Paula | 600 default, configurable per contradiction type |
**Resolution:** 600-tick default. Configurable per contradiction type via content authoring. A schedule-based claim can carry a longer window than a "just saw them" claim. `const CONTRADICTION_WINDOW_TICKS: u64 = 600` as the default constant. Tyre's 1800-tick argument (shift coverage + travel time) noted; the playtest will determine whether 600 is too tight.
### Trust-weighted transfer count (gossip)
**Paula's proposal** (Surface: 0-1, Real: 1-2, Secret: 2-3) accepted by Dudley; others not opposed.
**Paula withdraws** in Round 2: flat `rand.random_range(1..=3)` is sufficient. Trust tier already gates which facts are eligible; the count cap is a ceiling on an already-filtered pool.
**Resolution:** Flat `rng.random_range(1..=3)` per conversation, confirmed by all.
### `disclosure_blocked` / `disclosure_eligible` flag
All four participants agree a per-entry flag is needed for Major secrets (Kael's ring membership). Naming differs:
- Dudley/Tyre: `disclosure_blocked: bool` on `FactKnowledge` (field on struct)
- Paula: `disclosure_eligible: false` in authored YAML
**Resolution:** `disclosure_blocked: bool` on `FactKnowledge` (struct field, default `false`). Content authors set `disclosure_blocked: true` on authored KG entries for Major secrets in NPC profile YAML. The field name inverted to `_blocked` for cleaner boolean logic in the filter (`if fact.disclosure_blocked { continue }`).
### Witness inhibition + location privacy gates
Both confirmed for v0.1 trigger gate. Additive, not competing.
- **Witness inhibition** (Gestalt): count Active NPCs within 5 tiles; if > 2, suppress `real`/`secret`-tier disclosures unless NPC-player trust is `secret` tier (or NPC has `Talkative` trait override). NPC's trust toward nearby NPCs (not toward player) determines "witness" — Sera won't confide in front of coworkers she doesn't trust, regardless of her trust for the detective.
- **Location privacy** (Paula): `disclosure_context: [private, semi_private, any]` tag on authored disclosure candidates. Check fires at Layer 4 line selection, not at candidate derivation. Private-tagged candidates do not fire at public zones (e.g., The Terminal).
### Runtime NPC KG guardrail
- Tyre Round 1: runtime check Sprint 17 (3 lines)
- Dudley Round 1: Tier 3, defer
- Dudley Round 2: **recalibrates to include** — "once entity grant architecture is in place, the 3-line check is straightforward"
- Gestalt: runtime + content-load both Sprint 17
- Paula: authoring-time mandatory
**Resolution:** Both content-load validation (parse confidence strings, validate entity_refs) AND runtime guardrail (3-line check: if granting NPC's KG doesn't contain the fact, skip + warn) in Sprint 17.
### Monologue StableId → NpcName resolution
**Dudley confirms:** `EntityRegistry + Query<&NpcName>` is accessible from the monologue system with a ~5-line signature addition. The `resolve_name(source_id, registry, name_query)` helper is ~10 lines.
**Paula's additional proposal:** Pre-resolve display names into the `ContradictionDetected` event payload at detection time. This makes the monologue system a pure string consumer with no ECS query needed:
```
ContradictionDetected {
...,
source_display_name: Option<String>, // resolved at detection time
subject_display_name: Option<String>, // resolved at detection time
}
```
**Resolution:** Pre-resolve names into event payload (Paula's approach). Simpler monologue system, single resolution point. Fallback: `"Unknown({})"` if entity not in registry.
### DisclosureCandidates compute trigger
**Dudley confirms:** No "player entered dialogue range" event exists. Range-query per tick in Active NPC processing loop — O(N_active) position comparisons (~60 at max). Negligible. `DisclosureCandidates` component as cache, freshness checked by `computed_tick` field (expires after 30 ticks / 3 game-minutes).
### Contradiction monologue authoring
**Paula's specification (final):**
**Option 3 adopted:** Generic fallback template + hand-authored override for FRIEND-pattern NPCs.
- FRIEND NPCs (Sera, Kael): hand-authored lines per relationship phase. Phase 2 primary: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."* Secondary beat: *"One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."*
- Auto-generated NPC generic fallback: *"{source_name} placed {subject_name} at {claimed_location}. I just saw them at {actual_location}."*
- Authoring principle: cognitive dissonance, not accusation. The engine marks uncertainty; the detective reflects uncertainty. The player makes accusations.
This is a specification for Mellanie. It is unblocked once Dudley confirms the name resolution path (now confirmed above).
### Disclosure candidate selection algorithm
**Gestalt provides complete specification** (pseudocode in gestalt-round2.md §"Disclosure Candidate Selection Algorithm"). Paula asked to confirm narrative assumptions:
1. **Contradicted facts excluded from candidates** — Gestalt's proposal. Paula's Round 2 is silent on this specific item (confirming by non-objection). Excluded in v0.1; future sprint may add `DisclosureMode::Confusion` for NPCs surfacing their own contradicted knowledge.
2. **Sera's Phase 2 disclosures are FactId entries** — the compound grant mechanism creates EntityKnowledge as a side effect. Paula's canonical sequence uses FactId grants, consistent with Gestalt's algorithm.
3. **Witness inhibition "trusted" means NPC's trust toward nearby NPC** — not player's trust toward NPC. Gestalt and Paula agree on this framing.
---
## Positions That Did NOT Change This Round
For the record: the following Round 1 consensus positions were confirmed without challenge in Round 2:
- Option A (NPC checks own KG only for disclosure) — reaffirmed by all
- Event-driven contradiction detection at KG write time — reaffirmed
- Both entries marked `Contradicted` (epistemic neutrality) — reaffirmed
- POI discovery via `FactId("poi.*")` — reaffirmed
- Confidence cap `min(source_confidence, KnowsOf)` on gossip — reaffirmed
- Background-tier NPCs get no KG-driven behavior — reaffirmed
- No pathfinding KG retrofit — reaffirmed
- Q-025 close (memory not a constraint) — reaffirmed
---
## Standing Dissent Register
Items where a participant registered formal dissent in Round 2 that was not resolved:
1. **Gestalt (Tension A):** "If the team decides to defer entity grants, the decision should be recorded as 'THE FRIEND arc contradiction sequence cannot fire using EntityKnowledge.ToldBy in v0.1' — not that we found a workaround." This dissent stands regardless of which path is chosen. If (B) is selected, the D-record must accurately describe the scope limitation.
2. **Tyre (Tension A):** FactId workaround requires more code and is more fragile than entity grants + ContentNameRegistry. The workaround produces a cross-structure dependency that does not exist today. Registry cost is genuinely ~0.5 days.
---
## Open Items Still Requiring Decision
| Item | Status | Who decides |
|------|--------|-------------|
| Entity grants Sprint 17 vs FactId workaround | **Split: 3 engineers vs Paula's conditions** | Team lead |
| Paula's Condition 1 (slug → StableId resolution path) | Requires technical confirmation if workaround chosen | Dudley to confirm before implementation |
| `CONTRADICTION_WINDOW_TICKS` final value (600 vs 1800) | 3-to-1 for 600; playtest will calibrate | Can ship with 600, adjust |
| `Compound` grant variant (facts + entities in one line) | Deferred to Sprint 18 by all except Tyre who included it in schema | Minor — Sprint 18 either way |
---
## Summary Table: All Round 2 Decisions
| Topic | Decision | Agreed by |
|-------|----------|-----------|
| Tension B: struct | `contradicted_claim: Option<ContradictionClaim>` on `EntityKnowledge` | All 4 |
| `ContradictionClaim` naming | `ContradictionClaim` (Tyre's name) | Tyre, Gestalt, Dudley; Paula prefers `prior_claim` but concedes |
| `claimed_position` vs `position` field | `claimed_position` | Tyre, Dudley |
| `detected_at_tick` field | Include | Tyre, Dudley |
| Tension A: entity grants vs workaround | Split — see above | N/A |
| Gossip rate per conversation | Flat `rng.random_range(1..=3)` | All 4 |
| Contradiction window default | 600 ticks (1 game-hour), configurable | Gestalt, Dudley, Paula (Tyre prefers 1800) |
| Global disclosure rate limit | Include, deterministic StableId-ordered, 1/10 ticks | Tyre, Dudley, Gestalt (conditional); Paula withdrew |
| Witness inhibition gate | Include in v0.1 trigger conditions | All 4 |
| Location privacy gate | Include in v0.1 trigger conditions | All 4 |
| `disclosure_blocked: bool` on `FactKnowledge` | Include | All 4 |
| Runtime NPC KG guardrail | Include Sprint 17 | Dudley recalibrated; Gestalt; Paula |
| Monologue name resolution | Pre-resolve into `ContradictionDetected` event payload | Paula (proposed); Dudley (confirmed feasible) |
| Contradiction monologue authoring | Option 3: generic template + hand-authored FRIEND override | All 4 |
| Q-024 formal closure | Close | All 4 |
| Q-025 formal closure | Close | All 4 |
| Q-026 formal closure | Close (contingent on Tension A path) | All 4 |
@@ -0,0 +1,535 @@
# Round 1: Tyre -- Architecture Analysis
**Workshop:** Knowledge Flow & NPC Information Boundaries
**Domain:** System architecture, ECS patterns, performance budgets
**Date:** 2026-02-23
---
## Topic 1: Knowledge Flow -- The Grant Mechanism
### Position: Single event type with a compound grant payload
*cracks knuckles* -- let me be honest about what's actually hard here and what isn't.
**Sub-question 1: Wiring `knowledge_grant` on dialogue lines.**
The architecture is clean. The `KnowledgeEventQueue` (`server/src/knowledge/events.rs:56`) already handles the drain-per-tick pattern. We add one new `KnowledgeEventType` variant:
```rust
KnowledgeEventType::KnowledgeGranted {
fact_grants: Vec<(FactId, KnowledgeConfidence)>,
entity_grants: Vec<(StableId, EntityKnowledgeGrant)>,
}
```
The grant fires at **line selection time** in `process_talk_interaction` (dialogue.rs), NOT at client display time. The server is authoritative (D-010 principle 1). The dialogue system already has the player entity, the target NPC entity, the NPC's `StableId` (via `EntityRegistry`), and the selected `IndexedDialogueLine` with its `knowledge_grant` field (`server/src/content/line_pool.rs:297`). All the data needed to construct the event is available in one place.
The processing happens in `process_knowledge_events` (`server/src/knowledge/events.rs:85`). Add a match arm that calls `observer_kg.facts.insert()` for fact grants and `observer_kg.entities.entry().or_insert_with()` for entity grants. This is the same pattern as `DirectObservation` processing (events.rs:97-110).
**Difficulty: Tier 1.** ~1-2 days. The hard part is already done (event queue, processing system, line pool indexing). This is plumbing.
**Sub-question 2: Entity knowledge vs fact knowledge in grants.**
The current `KnowledgeGrant` schema (`server/src/content/types.rs:495-498`) is too narrow -- `{ fact_id: String, confidence: String }` only covers facts. The "Kael handles cargo at Dock 7" example from the workshop brief needs BOTH:
- An `EntityKnowledge` entry for Kael (the NPC now knows Kael exists and his role)
- A `FactKnowledge` entry for `"dock_7.kael_assignment"` (the specific claim that can later be contradicted)
My recommendation: **extend the YAML schema to support a compound grant.**
```yaml
knowledge_grant:
facts:
- fact_id: "dock_7.kael_assignment"
confidence: "knows_of"
entities:
- entity_ref: "kael" # resolved to StableId at content load
attributes:
role: "dock-worker"
location: "dock-7"
confidence: "knows_of"
source_type: "told_by" # auto-fills ToldBy { source_id: speaker_sid }
```
The `entity_ref` string maps to `StableId` via a content-time name registry (similar to how `EntityRegistry` works at runtime, but for authored content). This is new infrastructure but small -- a `BTreeMap<String, StableId>` populated at content load.
**Risk:** The compound grant schema is more complex for content authors. Mitigation: make `facts` and `entities` both optional. Most dialogue lines will only grant facts. Entity grants are for key narrative moments (THE FRIEND arc setup, character introductions).
**Sub-question 3: POI discovery as knowledge flow.**
Option (a) wins: **new `FactId` category `"poi.*"`**. Reasons:
1. `FactKnowledge` already has `confidence`, `source`, `state`, and `acquired_tick` -- exactly what POI discovery needs.
2. `filter_by_access` already handles `KnowledgeGated(String)` (`server/src/knowledge/graph.rs:299-302`) which checks `kg.knows_fact()`. POI-gated content "just works" with `ObserverAccess::KnowledgeGated("poi.dock_7_restricted")`.
3. `fact_at_least()` (`graph.rs:71-76`) already provides the prerequisite check for dialogue/monologue gating (D-035). A POI fact with `Suspects` confidence gates differently from one with `KnowsDetails`.
4. No new types needed. No new systems needed. The grant mechanism from sub-question 1 handles the write. The existing query infrastructure handles the read.
Extending `EntityKnowledge` to cover locations (option b) would conflate entities with places, and locations don't have `relationship: RelationshipState` or `last_observed_tick` semantics that make sense. Keep the type boundaries clean.
**Sub-question 4: Physical evidence discovery.**
Same mechanism as dialogue grants. A new `KnowledgeEventType::EvidenceDiscovered` variant is **not** necessary and would be premature. The `KnowledgeGranted` event type already covers it -- the source field distinguishes provenance. Evidence grants would use:
```rust
KnowledgeSource::DirectObservation { tick }
```
...because the player is physically looking at the terminal/document. The confidence is higher (`KnowsDetails` vs `KnowsOf` for hearsay) precisely because it's direct evidence. This is a content-authoring decision, not an architecture decision.
If we later need system-specific processing (e.g., "evidence triggers different monologue types"), we can branch on the confidence level or add a tag, not a new event type. Keep the event type enum tight.
**Sub-question 5: Content author guardrails.**
**Runtime enforcement, not authoring-time validation.** Here's why:
The NPC's KG is the authoritative source. When the grant mechanism fires, the system should check: does the NPC's KG contain the facts they're about to grant? If not, skip the grant and log a warning. This is a 3-line check in the event processing:
```rust
// In the KnowledgeGranted match arm
if let Ok(npc_kg) = knowledge_query.get(npc_entity) {
for (fact_id, confidence) in &fact_grants {
if !npc_kg.knows_fact(fact_id) {
tracing::warn!("NPC {} tried to grant unknown fact {}", npc_sid.0, fact_id.0);
continue;
}
}
}
```
Authoring-time validation is nice-to-have for the line previewer CLI but should not be the primary guardrail. Runtime enforcement catches edge cases where an NPC's KG changes during gameplay (e.g., they learned something, then forgot it via decay, but the dialogue line is still eligible). This is consistent with D-010 principle 2 -- the server is the authority.
### Architecture summary for Topic 1
| Component | Change | Effort |
|-----------|--------|--------|
| `KnowledgeEventType` enum | Add `KnowledgeGranted` variant | 30 min |
| `process_knowledge_events` | Add match arm for grant processing | 2 hours |
| `KnowledgeGrant` YAML schema | Extend to compound (facts + entities) | 1 day |
| Content load pipeline | Add entity_ref -> StableId resolution | 0.5 day |
| Runtime guardrail | NPC KG check before granting | 1 hour |
| **Total** | | **~2 days** |
---
## Topic 2: NPC-to-NPC Knowledge Propagation
### Position: Piggyback on existing conversation system, trust-gated, confidence-capped
**Sub-question 1: Existing conversation system IS the hook.**
Yes. Unambiguously yes. `run_npc_conversations` (`server/src/simulation/conversation.rs:278`) already does:
- Proximity detection (line 340: `manhattan_distance`, `CONVERSATION_PROXIMITY = 3`)
- Deterministic pairing via `StableId` sorting (line 324: `eligible.sort_by_key(|&(_, _, sid)| sid)`)
- Conversation lifecycle (start tick, end tick, cooldown)
- Active-tier scoping (line 295: `With<Npc>, With<ActiveSim>`)
A "knowledge transfer phase" inserts between conversation start and conversation end. The existing Phase 2 (line 374: tick active conversations) already has the per-conversation-tick loop. We add knowledge transfer on conversation termination, not per-line -- this is a deliberate architectural choice:
1. **Per-conversation, not per-line.** Knowledge transfer happens once when the conversation ends (in `terminate_conversation`, line 569). This is simpler, cheaper, and narratively appropriate -- you learn things from a conversation, not from individual sentences.
2. **Uses the same `KnowledgeEventQueue`.** The existing drain-per-tick pattern handles it. No new event infrastructure.
Building a separate system would violate DRY and create synchronization headaches (two systems managing NPC proximity conversations independently). The conversation system is the natural hook -- use it.
**Sub-question 2: Trust-gated filtering.**
The `RelationshipGraph` (`server/src/npc/relationships.rs:98`) tracks directed trust between entity pairs. `RelationshipEdge.trust` is an `i8` (-10..+10). We need a mapping from trust to eligible knowledge categories:
| Trust Range | Tier | What transfers |
|------------|------|---------------|
| -10..0 | None | Nothing (hostile/neutral, no sharing) |
| 0..3 | Surface | Public facts only (`FactKnowledge` with `Suspects` or `KnowsOf`) |
| 3..7 | Real | Facts + entity observations (non-secret `EntityKnowledge`) |
| 7..10 | Secret | Everything except `KnowledgeGated` entries |
This maps cleanly to the D-028 trust tier concept. The check is: look up `RelationshipGraph.get((speaker_sid, listener_sid))`, read the trust value, filter eligible entries.
**Implementation note:** The trust check requires reading the `RelationshipGraph` resource during conversation termination. `terminate_conversation` currently takes `Commands`, `EntityRegistry`, and player query. It needs `RelationshipGraph` access added. This is a signature change, not an architectural one.
**Sub-question 3: Confidence downgrade on transfer.**
The proposed rule is sound: `transferred_confidence = min(source_confidence, KnowsOf)`.
This means:
- `Direct` (I saw it) -> `KnowsOf` when told to someone else. Correct -- secondhand knowledge.
- `KnowsDetails` (I know the specifics) -> `KnowsOf`. Correct -- the retelling loses precision.
- `KnowsOf` -> `KnowsOf`. Correct -- stays at the same level.
- `Suspects` -> `Suspects`. Correct -- rumors stay rumors.
The `KnowledgeConfidence` enum already derives `Ord` (`server/src/knowledge/types.rs:32`), so `min()` works natively via the `PartialOrd` trait. The implementation is literally `confidence.min(KnowledgeConfidence::KnowsOf)`. One line.
This also prevents gossip chains from inflating confidence -- a critical property. If A tells B who tells C who tells D, the confidence never exceeds `KnowsOf`. Only direct observation can produce `KnowsDetails` or `Direct`. The hierarchy is load-bearing and this preserves it.
**Sub-question 4: Rate limiting.**
**Fixed cap of 1-3 facts per conversation.** Use `rng.random_range(1..=3)` for the transfer count, drawing from eligible entries sorted by `last_updated_tick` (most recent first). Rationale:
- Prevents knowledge explosion from a single conversation. Two NPCs with 50 entries each shouldn't dump 50 facts in one chat.
- The random 1-3 range creates natural information asymmetry between NPC pairs -- replay value (Nigel cares about this).
- Most recent first biases toward current-gamestate knowledge, which is more narratively relevant.
- At ~50 entries per NPC, selecting 1-3 from the eligible-and-trust-filtered subset is O(N) scan + O(1) selection. Negligible.
**Sub-question 5: Observable by player.**
When the player overhears an NPC-NPC conversation (D-078), the current system produces `ConversationEvent` with `occluded_line` (per-word Bernoulli occlusion, conversation.rs:214). The question is whether the player's KG should be updated from overheard conversations.
**Position: Overheard knowledge enters at `Suspects` confidence, regardless of occlusion fidelity.**
Reasoning:
- Trying to parse the occluded line to determine what "facts" survived is an AI problem, not a game engine problem. We're not building NLP.
- The `Suspects` confidence level already means "something seems off about X" or "I've heard the name" (types.rs:34-36). That's exactly what you get from half-hearing a conversation.
- The grant fires if the conversation involved knowledge about an entity the player doesn't already know about at higher confidence. Use the same `KnowledgeGranted` event, but cap at `Suspects` and use `KnowledgeSource::Heard { tick, range: SoundRange::Medium }` (types.rs:95).
- ListeningFocus (D-071) could upgrade the confidence to `KnowsOf`. This creates a meaningful gameplay choice: actively listen to get better intel vs passively overhear for vague leads.
**Sub-question 6: ToldBy source construction.**
The `KnowledgeSource::ToldBy { source_id: StableId, tick: u64 }` variant (types.rs:97) is defined and ready. Construction happens in the `terminate_conversation` knowledge transfer phase:
```rust
let source = KnowledgeSource::ToldBy {
source_id: speaker_sid, // already available at line 598
tick: current_tick, // already available at line 584
};
```
Both values are already in scope in `terminate_conversation`. This is plug-and-play.
### Performance budget for Topic 2
Per-conversation knowledge transfer:
- **Trust lookup:** O(1) hash/btree lookup in RelationshipGraph. ~50ns.
- **Eligible entry scan:** O(N) where N = speaker's KG entity count (~50). ~5us per NPC.
- **Transfer writes:** 1-3 BTreeMap inserts. O(log N) per insert. ~300ns total.
- **Per-tick overhead:** Max 1 conversation terminates per tick (rate limited by conversation pacing). So worst case ~6us per tick. Negligible against the 3ms knowledge budget (D-041 performance note).
This stays well within budget even if we increase conversation frequency later.
---
## Topic 3: Unprompted Disclosure Design (#172)
### Position: Disclosure as a filtered KG query feeding Layer 4, not a new system
**Sub-question 1: Connection to NPC KG.**
`tell_state.rs` (`server/src/npc/tell_state.rs`) currently derives tell categories from raw axes: `Secret`, `ToleranceThreshold`, `Contentment`, `MoodState`, `Relationships`, and `RoutineDeviation`. The `derive_category` function (tell_state.rs:73) reads these components directly -- no KG involvement.
For unprompted disclosure, the tell state system needs to know "what does this NPC know that might be interesting?" This is a KG query, not an axis derivation. My recommendation:
**Do NOT add `disclosure_candidates: Vec<FactId>` to `DerivedTellState`.** That couples the tell derivation system to the knowledge graph in a way that creates awkward data flow. Instead:
Add a **new system** `derive_disclosure_candidates` that runs AFTER `derive_tell_state` and BEFORE Layer 4 line selection. It produces a separate component:
```rust
#[derive(Component, Debug)]
pub struct DisclosureCandidates {
pub facts: Vec<(FactId, KnowledgeConfidence)>,
pub entities: Vec<(StableId, KnowledgeConfidence)>,
}
```
This system queries the NPC's `KnowledgeGraph`, the NPC's `DerivedTellState`, and the NPC-player trust level. It filters the KG into a candidate list. Layer 4 of the dialogue pipeline then uses `DisclosureCandidates` to select which dialogue line to volunteer.
**Why a separate component?** Because the disclosure derivation has different update frequency requirements than tell state. Tell state updates every tick (it's cheap -- axis comparisons). Disclosure candidate derivation involves KG scanning, trust lookups, and filtering -- it should run less frequently. Once per game-minute (every 10 ticks) is sufficient. The component acts as a cache.
**Sub-question 2: "Do I know something you don't?"**
**Option A: NPC only checks own KG.** Full stop.
Option B (cross-entity KG query) violates D-010 principle 2 -- information boundaries. An NPC reading the player's KG to decide what to say is omniscient behavior wearing a trenchcoat. The NPC doesn't know what the player knows. Period.
The UX concern (repeated info) is real but solvable without violating info boundaries:
- The dialogue line cooldown system already exists (`DialogueCooldownTracker`, dialogue.rs:88). If the NPC volunteers fact X and the player already has it, the line fires but won't repeat for 600 ticks (LINE_COOLDOWN_TICKS).
- The player hearing "old news" is realistic and can trigger different monologue responses: "I already knew that" vs "Wait, that's new."
- Paula/Gestalt should weigh in on whether "NPC tells you something you already know" is a bug or a feature narratively.
**Sub-question 3: Trait filtering (#173).**
**Both, in sequence.** Traits filter candidate facts FIRST, then modify delivery of surviving candidates.
Architecture:
1. `DisclosureCandidates` system applies trait-based filters: a `Cautious` NPC has a higher confidence threshold before sharing. A `Gossipy` NPC shares lower-confidence entries. This is a filter on the KG query.
2. Layer 4 line selection uses the filtered candidates to match against dialogue lines. The line's `tags` field (`IndexedDialogueLine.tags`, line_pool.rs:296) already supports this -- add trait tags like `"cautious_delivery"` or `"gossip_delivery"`.
The trait -> filter mapping is content-authorable, not hard-coded. A `traits.yaml` configuration that maps trait names to confidence thresholds and tag preferences. This keeps the system generic and content-driven.
**Sub-question 4: Trigger conditions.**
Layer 4 already has a trigger framework via the dialogue pipeline. Unprompted disclosure triggers when:
1. **Trust threshold met:** NPC-player trust >= Surface tier (trust >= 0). From `RelationshipGraph`.
2. **Mood permits:** `DerivedTellState.category` is not `Angry` or `Guarded`. Angry/Guarded NPCs don't volunteer info.
3. **Rate limit not exceeded:** Per-NPC disclosure cooldown (separate from line cooldown). Suggested: 300 ticks (30 game-minutes). Stored as a simple `DisclosureCooldown { until_tick: u64 }` component.
4. **Player in proximity:** Manhattan distance <= conversation range (3 tiles). Reuse `CONVERSATION_PROXIMITY` from conversation.rs:36.
5. **Not in active conversation:** NPC is not currently in an `NpcConversation` (check `Option<&NpcConversation>` in query).
Presence of other NPCs (witnesses) is a Gestalt/Paula question -- mechanically it's trivial (count nearby ActiveSim NPCs), but whether it affects behavior is a design call.
**Sub-question 5: Rate limiting.**
Layer the cooldowns:
- **Per-NPC disclosure cooldown:** 300 ticks (30 game-minutes). Prevents individual NPCs from being disclosure machines.
- **Per-fact cooldown:** Use the existing `DialogueCooldownTracker` mechanism. Once a fact-granting line fires, that line_id is on cooldown for 600 ticks.
- **Global rate limit across all NPCs:** One unprompted disclosure per game-minute (every 10 ticks). This is a `Resource` counter, not per-entity. Prevents the "five NPCs volunteer info simultaneously" problem.
The global limit is the most important one. Without it, entering a populated area could trigger 5 disclosure attempts in one tick. The per-NPC and per-fact limits handle repetition; the global limit handles spam.
### Difficulty assessment
**Tier 2.** The dialogue pipeline infrastructure exists (layers 1-3 work). The new work is:
- `DisclosureCandidates` component + derivation system (~1 day)
- Global disclosure rate limiter (~0.5 day)
- Layer 4 integration for unprompted lines (~1.5 days)
- Trait filter configuration schema (~0.5 day)
Total: ~3.5 days. Depends on Paula/Gestalt defining the candidate selection rules before Dudley can implement.
---
## Topic 4: NPC Information Boundaries (#142)
### Position: MVP is tell_state + disclosure only. Do NOT retrofit pathfinding.
**Sub-question 1: Priority order for retrofit.**
| System | Retrofit Tier | v0.1? | Rationale |
|--------|--------------|-------|-----------|
| `tell_state.rs` | Tier 1 | Yes | Direct read replacement: axes -> KG entries. NPC's own state is always in its KG (self-knowledge). |
| Unprompted disclosure (#172) | Tier 1 | Yes | Disclosure IS a KG query -- it's KG-native by design. |
| `conversation.rs` | Tier 2 | Sprint 18+ | "Does NPC A know NPC B exists?" check before pairing. Adds KG query to pair loop. |
| `routine.rs` | Tier 3 | No | Routines are static schedules. KG-driven routine would mean NPCs "forget" their daily schedule. Nonsensical. |
| `path_follow.rs` | Tier 3 | No | KG-driven pathfinding means NPCs forget where walls are. Broken gameplay. |
**Sub-question 2: Minimum viable boundary.**
`tell_state.rs` + unprompted disclosure (#172). Here's why this is the right MVP:
The tell system currently reads `Secret`, `ToleranceThreshold`, `Contentment`, `MoodState` directly (tell_state.rs:129-141). For an NPC's OWN axes, the KG always reflects ground truth -- the NPC observes itself continuously. So "reading from KG instead of axes" for self-state is a no-op in terms of behavior change. **The retrofit is conceptually correct but functionally invisible for self-state.**
Where the boundary BECOMES visible is in unprompted disclosure: the NPC uses its KG to decide what to share. This is a real information boundary that creates gameplay. An NPC who hasn't seen Kael today doesn't volunteer "Kael was at the dock" because that knowledge has decayed.
**This is the key architectural insight:** the MVP boundary isn't about restricting self-knowledge (NPCs always know their own state), it's about restricting knowledge of OTHERS that informs behavior.
**Sub-question 3: Simulation tier interaction (D-026).**
Clear answer: **Background-tier NPCs do not get KG-driven behavior.**
D-026 defines three tiers:
- Active (30-80 NPCs): Full simulation, full KG, full boundaries.
- Background (500-2,000 NPCs): State machine, minimal KG (10 entries per D-041 budget).
- State-saved (10,000+): Serialized, no active simulation.
Background NPCs run state machines, not the full AI pipeline. Their KGs exist for snapshot purposes (what does the player know about them?) but are not read for NPC decision-making. The `derive_tell_state` system already scopes to `With<ActiveSim>` (tell_state.rs:140). The disclosure system should do the same. Background NPCs don't disclose because they don't have the components driving disclosure.
The tier transition from Background -> Active DOES hydrate the KG. When an NPC enters the Active tier (player approaches), their KG gets populated from Background state + any accumulated gossip. This is a D-026 tier transition concern, not a knowledge boundary concern.
**Sub-question 4: What breaks?**
`tell_state.rs` retrofit: **Nothing breaks.** As noted above, an NPC's KG for its own axes is always up-to-date because the NPC is the direct observer of its own state. The `observe_entity` call (graph.rs:111) with `source: DirectObservation` happens continuously for entities in the Active tier's perception range, and an entity is always in its own "perception range."
Actually -- I should flag a subtlety. The current `tell_state.rs` reads components directly (Secret, Contentment, etc.). These are GROUND TRUTH components. The KG `EntityKnowledge` struct has `known_attributes: BTreeMap<String, String>` (types.rs:171) but doesn't currently store axis values. For the tell_state retrofit to work, either:
(a) The NPC's axes are mirrored into its own KG `known_attributes` (new system), or
(b) Tell state continues to read axis components directly for SELF-state, and only uses KG for OTHER-state.
Option (b) is correct. Tell state is about the NPC's own behavior signals. The NPC reads its own axes (ground truth for self). The information boundary applies to what the NPC knows about OTHERS. Don't over-engineer the self-observation path.
`path_follow.rs` retrofit: **Things break badly.** If pathfinding reads from KG, an NPC whose knowledge of a corridor has decayed (older than `stale_after = 3600 ticks = 6 game-hours`) might "forget" that a path exists. This produces stuck NPCs, pathfinding failures, and bizarre behavior. The fallback to ground truth (option a from the brief) would fire constantly, making the boundary meaningless. Don't do this for v0.1. Maybe not ever.
**Sub-question 5: Fallback behavior.**
For the MVP (tell_state + disclosure): **no fallback needed.** Tell state reads self-axes directly (option b above). Disclosure reads KG but gracefully handles "NPC has nothing to share" by simply not triggering.
For future retrofits (conversation partner selection): **fall through to ground truth with logging** (option a). If NPC A's KG doesn't know NPC B exists, it should still be ABLE to start a conversation if they're physically proximate. The KG check adds a bonus ("I recognize this person, let me chat") rather than a gate ("I've never seen this person, I refuse to interact"). This preserves functionality while adding KG-informed behavior.
Option (c) ("ask around" behavior) is Tier 3 at minimum. Cool for emergent gameplay but scope-dangerous. Defer to v0.2+.
### Architecture summary for Topic 4
**v0.1 scope: tell_state uses own axes for self-state. Disclosure uses KG for other-state. Both scoped to ActiveSim.**
No retrofit of `path_follow.rs`, `routine.rs`, or `conversation.rs` in Sprint 17. The conversation system KG check (does A know B?) is a Sprint 18 candidate -- low risk, moderate gameplay value, clean implementation (add `kg.knows_entity(&partner_sid)` to the pair eligibility filter at conversation.rs:314).
---
## Topic 5: Contradiction Detection Pipeline
### Position: Event-driven detection on KG write, location-first, content-authored attributes
**Sub-question 1: Location contradiction (simplest case).**
This is architecturally straightforward. The contradiction fires when:
1. NPC A tells player "X was at location L1 at time T" -> `EntityKnowledge` entry created with `source: ToldBy { source_id: A, tick: T }` and `last_known_position: Some(L1)`.
2. Player directly observes X at location L2 at time T' where T' is within a time window of T -> `EntityKnowledge` entry updated with `source: DirectObservation { tick: T' }` and `last_known_position: Some(L2)`.
3. Detection: same `StableId`, two entries? No -- there's only one `EntityKnowledge` per `StableId` in the BTreeMap (graph.rs:20: `entities: BTreeMap<StableId, EntityKnowledge>`).
**Architectural problem.** The current `EntityKnowledge` struct has a SINGLE `source` field and a SINGLE `last_known_position` (types.rs:154-172). There's no history. When the player observes X directly, the `observe_entity` method (graph.rs:111) OVERWRITES the previous `ToldBy` source with `DirectObservation`. The `ToldBy` information is gone. There's nothing to contradict against.
**This is the key design issue for contradiction detection.** We need source history on `EntityKnowledge`. Two approaches:
**Approach A: Add a `previous_sources` Vec.** When `observe_entity` or the grant mechanism writes a new source, push the old source onto a `previous_sources: Vec<(KnowledgeSource, Option<TilePosition>, u64)>` field. Contradiction detection scans `previous_sources` against the current entry.
**Approach B: Detect contradiction at write time.** Before overwriting the entry in `observe_entity`, compare the incoming observation against the current entry. If `current.source` is `ToldBy` and `current.last_known_position != incoming_position` and the time window overlaps, set `state = Contradicted` BEFORE overwriting.
I recommend **Approach B** -- detect at write time. It's event-driven (only fires on KG mutation), doesn't grow the struct, and the data needed for comparison is available in the same function call. The existing `observe_entity` method (graph.rs:111-136) already does conditional state updates (line 133: `if entry.state == KnowledgeState::Stale`). Adding a contradiction check follows the same pattern:
```rust
// In observe_entity, before overwriting source:
if let KnowledgeSource::ToldBy { source_id, tick: told_tick } = &entry.source {
if let Some(old_pos) = entry.last_known_position {
if old_pos != position {
// Location contradiction: told one place, observed another
entry.state = KnowledgeState::Contradicted;
// Don't return -- still update position and source
}
}
}
```
The time window check can be added as a configuration: contradictions only fire if `|told_tick - observation_tick| < CONTRADICTION_WINDOW_TICKS`. This prevents ancient hearsay from triggering contradictions with current observations.
**But wait** -- there's a subtlety. We need BOTH the old entry (ToldBy) and the new entry (DirectObservation) to exist for the monologue system to reference. "Sera said Kael was at the dock, but I just saw him in B-7" requires knowing both positions.
**Revised recommendation:** Approach B for detection, but also store the contradicting claim as a new field:
```rust
pub struct EntityKnowledge {
// ... existing fields ...
/// When Contradicted: the previous claim that conflicts with current observation.
pub contradicted_claim: Option<ContradictionClaim>,
}
pub struct ContradictionClaim {
pub source: KnowledgeSource, // Who said it
pub position: TilePosition, // Where they said the entity was
pub tick: u64, // When they said it
}
```
This preserves the contradiction evidence for downstream consumers (monologue, anomaly marker) without growing a full history Vec.
**Sub-question 2: Attribute contradiction.**
The `known_attributes: BTreeMap<String, String>` (types.rs:171) is untyped strings. For contradiction detection, we need to know which attribute keys are "same-subject" comparisons. "role: dock-worker" doesn't contradict "faction: transit-union" -- different keys. But "role: dock-worker" from ToldBy vs "role: smuggler" from DirectObservation is a contradiction.
**Content-authored contradiction pairs.** A YAML file defining which attribute key+value pairs contradict:
```yaml
contradictions:
- key: "role"
values: ["dock-worker", "smuggler"] # mutually exclusive
- key: "allegiance"
values: ["transit-union", "commission"] # mutually exclusive
```
This is cleaner than trying to auto-detect contradictions on untyped strings. The content team defines what's contradictory; the engine enforces it. Automatic detection would produce false positives (NPC changed shift? Not a contradiction. NPC changed faction? That IS a contradiction).
**Difficulty: Tier 2** for attribute contradiction. Needs the content schema + a lookup system for contradiction rules. ~2 days on top of the location detection work.
**Sub-question 3: Content-authored vs automatic.**
| Type | Detection | Rationale |
|------|-----------|-----------|
| Location | Automatic | Position comparison is objective and mechanical. No content authoring needed. |
| Attribute | Content-authored | Attribute semantics are content-defined. The engine can't know that "dock-worker" contradicts "smuggler" without being told. |
| Fact | Hybrid | Category-specific. `poi.*` facts use automatic location checks. `contraband.*` facts use authored contradiction pairs. |
**Sub-question 4: Detection timing.**
**Event-driven, on KG write.** This is the clear winner:
- `process_knowledge_events` (events.rs:85) already runs once per tick and handles all KG mutations. Adding contradiction detection as a post-write check is natural.
- Per-tick scanning of all KGs (80 Active NPCs x 50 entries = 4,000 comparisons per tick) is wasteful. 99% of ticks have no new information.
- Per-game-minute is too slow for the narrative. The player sees Kael in B-7 and the monologue should fire within seconds, not minutes.
- Event-driven means: detect on the tick when the contradicting observation occurs. Immediate. One comparison per KG write that has a `ToldBy` predecessor. Virtually free.
**Sub-question 5: Event emission chain.**
The full chain, system by system:
1. **Perception system** (`server/src/perception/`) detects entity in LOS.
2. **Knowledge event** (`KnowledgeEventType::DirectObservation`) pushed to `KnowledgeEventQueue`.
3. **Knowledge processing** (`process_knowledge_events`, events.rs:85) applies the observation.
- In `observe_entity` (graph.rs:111), contradiction check fires.
- `entry.state = KnowledgeState::Contradicted`.
- `entry.contradicted_claim = Some(ContradictionClaim { ... })`.
4. **Anomaly detection** (`detect_anomalies`, anomaly.rs:44) runs after knowledge processing.
- Finds `KnowledgeState::Contradicted` on the entity (anomaly.rs:64).
- Inserts `AnomalyMarker` component.
5. **Monologue system** (monologue.rs) detects `AnomalyMarker` or `Contradicted` state.
- Selects "Wait -- that doesn't add up" line (currently hardcoded in ANOMALY_LINES, monologue.rs:52-65).
- Future: content-pool line with trigger="contradiction" and prerequisite checking the specific fact.
6. **Relationship system** reads `Contradicted` state on the ToldBy source entity.
- Shifts the ToldBy source (Sera) to `RelationshipState::PersonOfInterest`.
- This triggers D-033 amber color via the existing color derivation pipeline.
Systems 4-6 already exist and are tested. They just need the `Contradicted` state to be SET, which is what this pipeline produces. The downstream chain fires automatically via existing `Changed<KnowledgeGraph>` patterns and per-tick detection.
**Sub-question 6: THE FRIEND arc mechanical sequence.**
Walking through D-034 with the proposed architecture:
1. **Sera tells detective "Kael was at Dock 7 during second shift."**
- Dialogue line selected via D-028 pipeline. Line has `knowledge_grant: { entities: [{ entity_ref: "kael", attributes: { location: "dock-7" }, confidence: "knows_of", source_type: "told_by" }] }`.
- `KnowledgeEventType::KnowledgeGranted` pushed to queue.
- `process_knowledge_events` creates `EntityKnowledge` for Kael in player's KG: `source: ToldBy { source_id: sera_sid, tick: T1 }`, `last_known_position: Some(dock_7_pos)`, `confidence: KnowsOf`.
2. **Detective walks to B-7 corridor and observes Kael.**
- Perception system fires `DirectObservation { target: kael_entity, position: b7_pos }`.
- `process_knowledge_events` calls `observe_entity(kael_sid, b7_pos, T2)`.
- **Contradiction check fires:** `entry.source` is `ToldBy`, `entry.last_known_position` is `dock_7_pos`, incoming `position` is `b7_pos`. They differ. `entry.state = KnowledgeState::Contradicted`. `entry.contradicted_claim = Some(ContradictionClaim { source: ToldBy { sera_sid, T1 }, position: dock_7_pos, tick: T1 })`.
- Entry updated: `source: DirectObservation { tick: T2 }`, `last_known_position: Some(b7_pos)`, `confidence: Direct`, `state: Contradicted`.
3. **Anomaly detection fires.** `detect_anomalies` (anomaly.rs:44) finds Kael's entry has `state == Contradicted`. `AnomalyMarker` inserted on Kael's entity.
4. **Monologue fires.** Monologue system selects contradiction line: *"Sera said Kael was at the dock. I just saw him in B-7."* (Future: content-authored line that references `contradicted_claim.source` to name Sera and `contradicted_claim.position` for the claimed location.)
5. **Relationship shift.** The contradiction involves `ToldBy { source_id: sera_sid }`. System identifies Sera as the source of the contradicted claim. Sera's `RelationshipState` shifts to `PersonOfInterest`. D-033 color pipeline renders Sera in amber (#e8c547).
6. **Player experiences:** Kael appears as Direct (visible, in LOS) with Contradicted state (visual indicator TBD). Sera's color shifts to amber even though she's not present. The detective's internal monologue narrates the contradiction. The investigation has begun.
Every system in this chain exists and is tested EXCEPT steps 1 (grant mechanism, Topic 1) and 2 (contradiction detection in `observe_entity`). The downstream chain (steps 3-6) works today -- all tests manually set `Contradicted` state and verify the cascade.
### Performance budget for Topic 5
- **Location contradiction detection:** One `Option<TilePosition>` comparison per `observe_entity` call where `source` is `ToldBy`. ~10ns. Negligible.
- **Attribute contradiction:** One BTreeMap lookup per attribute write into a known_attributes map, checked against authored contradiction pairs. ~100ns per attribute. With 2-5 attributes per entity, ~500ns per contradiction check.
- **Total per-tick:** Only fires when KG is written (event-driven). At most 5-10 KG writes per tick during active gameplay. Total: ~5us per tick. Within the 3ms knowledge budget by three orders of magnitude.
### Implementation estimate
| Component | Effort |
|-----------|--------|
| `ContradictionClaim` struct + field on `EntityKnowledge` | 1 hour |
| Location detection in `observe_entity` | 0.5 day |
| Contradiction event emission for relationship shift | 0.5 day |
| Attribute contradiction pairs (YAML schema + lookup) | 1 day |
| Monologue integration (content-authored contradiction lines) | 0.5 day |
| Tests (location, attribute, THE FRIEND arc sequence) | 1 day |
| **Total** | **~3.5 days** |
---
## Cross-cutting: Q-025 Closure
Agree with formal closure. Current memory analysis from D-041: ~14 KB per Active NPC KG (50 entities + 20 facts). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries each = ~5 MB. Total: ~6 MB.
The gossip propagation from Topic 2 increases entry counts slightly (1-3 per conversation, capped). Even if every NPC's KG doubles from gossip, we're at ~12 MB. Not a concern until 500+ Active NPCs, which is beyond v0.2 scope.
**Recommendation:** Close Q-025. Re-evaluate if Active NPC count exceeds 200 or if profiling shows KG memory exceeding 50 MB.
---
## Summary of Key Architectural Positions
1. **Single `KnowledgeGranted` event type** with compound payload (facts + entities). Don't fragment the event enum.
2. **Piggyback gossip on conversation termination.** Don't build a separate propagation system.
3. **Confidence cap at `KnowsOf` on transfer.** Gossip chains never produce high-confidence knowledge.
4. **Disclosure as filtered KG query**, separate from tell state derivation. Different update frequencies, different concerns.
5. **NPCs check own KG only** for disclosure. No cross-entity KG reads. D-010 principle 2 is non-negotiable.
6. **MVP boundary: tell_state + disclosure only.** Don't retrofit pathfinding or routines.
7. **Contradiction detection at KG write time** in `observe_entity`. Event-driven, not polling.
8. **Store contradicted claim** for downstream monologue/relationship references. One struct, not a full history.
9. **Close Q-025.** Memory is not a concern at current scale.
10. **Total implementation estimate: ~11 days** across Topics 1-5. Parallelizable -- grant mechanism and contradiction detection can be developed simultaneously after the compound grant schema is agreed.
@@ -0,0 +1,470 @@
# Round 2: Tyre -- Cross-Review and Synthesis
**Workshop:** Knowledge Flow & NPC Information Boundaries
**Domain:** Architecture resolution, ECS patterns, performance, D-record draft
**Date:** 2026-02-23
---
## Tension A Resolution: Entity Grants are Sprint 17
*Let me be honest about what this means technically.*
Dudley's concern is real: entity grants require resolving a content-authored name like `"kael"` to a runtime `StableId`. No such registry exists today. `EntityRegistry` (`server/src/knowledge/registry.rs:20`) maps `StableId <-> Entity` (bevy ECS), but there's no `String -> StableId` path for content references.
Dudley proposes deferring entity grants to Sprint 18 and using structured FactIds (`FactId("entity.kael.location.second_shift_dock")`) as a workaround.
Paula argues this workaround breaks THE FRIEND arc: contradiction detection requires comparing `EntityKnowledge.last_known_position` between a `ToldBy` entry and a `DirectObservation`. A FactId encoding bypasses `EntityKnowledge` entirely -- the two data paths never intersect.
**Paula is right. The FactId workaround does not work for contradiction detection.** Here's the specific failure:
1. Sera's dialogue grants `FactId("entity.kael.location.second_shift_dock")` -- this creates a `FactKnowledge` entry in `KnowledgeGraph.facts`.
2. Player observes Kael in B-7 -- `observe_entity(kael_sid, b7_pos, tick)` updates `KnowledgeGraph.entities[kael_sid]`.
3. Contradiction detection fires on `entities[kael_sid]` write. It checks the existing `EntityKnowledge.source` for a `ToldBy` variant. There is none -- the ToldBy information is in `facts`, not `entities`. **The detection algorithm has nothing to compare.** The two data structures don't cross-reference.
You could build a cross-structure lookup that checks both `facts` and `entities` -- but that's more code, more complexity, and more fragile than just writing the entity grant correctly in the first place.
**But Dudley's concern about the registry is also valid.** So let me size the actual work.
### The content name registry: ~30 lines of new infrastructure
What we need:
```rust
/// Resource: maps content-authored entity names to runtime StableIds.
/// Populated at NPC spawn time. Read at dialogue content indexing time.
#[derive(Resource, Debug, Default)]
pub struct ContentNameRegistry {
names: BTreeMap<String, StableId>,
}
impl ContentNameRegistry {
pub fn register(&mut self, name: &str, sid: StableId) {
self.names.insert(name.to_string(), sid);
}
pub fn resolve(&self, name: &str) -> Option<StableId> {
self.names.get(name).copied()
}
}
```
This is a `BTreeMap<String, StableId>` (BTreeMap for D-010 determinism). Populated during NPC spawn: when the content system spawns "kael" from YAML, it calls `content_name_registry.register("kael", kael_sid)`. When the dialogue content indexer processes `entity_ref: "kael"`, it calls `content_name_registry.resolve("kael")` to get the StableId.
The loading order is already correct: NPC entities must be spawned before dialogue lines reference them. This is the same constraint that applies to `DialogueProfile.role` referencing valid NPC roles -- it's not a new ordering problem.
**Cost: ~30 lines for the registry + ~10 lines at NPC spawn to populate it + ~15 lines in the content indexer to resolve entity_refs. Total: ~55 lines. Half a day.**
### YAML schema extension
The extended `KnowledgeGrant` in YAML:
```yaml
# Fact-only grant (most common, existing pattern)
knowledge_grant:
fact_id: "poi.dock_7_restricted"
confidence: "knows_of"
# Entity grant (for testimony about people)
knowledge_grant:
entity_ref: "kael"
position_hint: "dock-7" # optional, maps to a TilePosition lookup
attributes:
role: "dock-worker"
confidence: "knows_of"
# Compound grant (rare, for dense narrative moments)
knowledge_grant:
grants:
- fact_id: "contraband.schedule_discrepancy"
confidence: "suspects"
- entity_ref: "kael"
attributes:
role: "dock-worker"
confidence: "knows_of"
```
The Rust type becomes:
```rust
// In content/types.rs, replacing the existing KnowledgeGrant
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum KnowledgeGrant {
Fact {
fact_id: String,
confidence: String,
},
Entity {
entity_ref: String,
#[serde(default)]
position_hint: Option<String>,
#[serde(default)]
attributes: BTreeMap<String, String>,
confidence: String,
},
Compound {
grants: Vec<KnowledgeGrant>,
},
}
```
Serde `untagged` enum handles the YAML dispatch based on which fields are present. `fact_id` key -> Fact variant. `entity_ref` key -> Entity variant. `grants` key -> Compound variant. Zero ambiguity, clean deserialization.
### Verdict on Tension A
**Sprint 17. Entity grants ship.** The infrastructure cost is ~0.5 days, not the multi-day effort Dudley estimated. The `ContentNameRegistry` is 30 lines. The YAML schema extension uses serde's existing untagged enum support. Paula's narrative requirement is correct: without entity grants, THE FRIEND arc's contradiction chain breaks at the first link.
Dudley: I'm not dismissing your caution. The registry is real new infrastructure. But it's small infrastructure, and the alternative (structured FactIds) creates a data-path split that makes contradiction detection fundamentally harder. The clean path is cheaper than the workaround.
---
## Tension B Resolution: Typed struct, not string encoding
Three proposals were on the table:
1. **Tyre (Round 1):** `contradicted_claim: Option<ContradictionClaim>` on `EntityKnowledge`
2. **Gestalt (Round 1):** `contradiction_basis: Option<ContradictionBasis>` -- structurally identical, different name
3. **Dudley (Round 1):** Option B -- structured attribute strings in `known_attributes` (`"claim.{tick}.position" = "{x},{y},{z},{source_sid}"`)
### Why the typed struct wins over string encoding
Dudley's Option B has "zero schema change" as its selling point. But this is misleading. You ARE changing the schema -- you're encoding structured data into strings within an existing BTreeMap. The "change" just moves from the type system to the runtime parser.
Concrete problems with Option B:
1. **String parsing in the hot path.** Contradiction detection runs at KG write time (event-driven, consensus from Round 1). Parsing `"claim.{tick}.position" = "{x},{y},{z},{source_sid}"` on every `observe_entity` call means regex or split-based parsing in a performance-sensitive path. The typed struct is a direct field access -- zero parsing.
2. **No type safety.** A typo in the key format (`"claim.1234.positon"`) silently breaks detection. The typed struct is checked at compile time.
3. **Pollutes `known_attributes`.** This BTreeMap is meant for semantically meaningful NPC attributes ("role", "faction", "name"). Filling it with internal bookkeeping strings (`"claim.1234.position"`) makes it harder to iterate for actual attribute queries (telling someone about Kael's role requires filtering out the claim keys).
4. **Creates a Sprint 18 refactor obligation.** Dudley acknowledges this. The refactor touches every system that reads/writes `known_attributes`, every test that constructs `EntityKnowledge`, and the serialization format. The clean struct approach has zero refactor debt.
The typed struct approach:
1. **One new optional field.** `Option<ContradictionClaim>` is `None` for 99%+ of entries. Serde with `#[serde(skip_serializing_if = "Option::is_none")]` means zero wire overhead for non-contradicted entries.
2. **Direct field access.** `entry.contradicted_claim.as_ref().map(|c| c.position)` -- no parsing, no string splitting.
3. **Self-documenting.** The struct fields tell you exactly what a contradiction contains.
4. **No refactor debt.** This is the correct shape for the long term.
### Naming: `ContradictionClaim`
Gestalt and I proposed the same struct with different names. I prefer `ContradictionClaim` over `ContradictionBasis`:
- `Claim` describes what the struct holds: the prior claim that was contradicted. "Sera claimed Kael was at the dock."
- `Basis` is ambiguous: it could mean "the basis for concluding there's a contradiction" (both sources) or "the basis of the original claim" (just the ToldBy).
The struct:
```rust
/// The prior claim that conflicts with the current observation.
/// Preserved when contradiction detection fires, enabling downstream
/// systems (monologue, relationship) to reference the conflicting source.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContradictionClaim {
/// Who made the contradicted claim (ToldBy source entity).
pub source: KnowledgeSource,
/// Position claimed by the source (if location contradiction).
pub claimed_position: Option<TilePosition>,
/// Tick when the contradiction was detected.
pub detected_tick: u64,
}
```
Added to `EntityKnowledge`:
```rust
pub struct EntityKnowledge {
// ... existing fields ...
/// When state == Contradicted: the prior claim that conflicts with current observation.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub contradicted_claim: Option<ContradictionClaim>,
}
```
### Verdict on Tension B
**Typed `ContradictionClaim` struct, Sprint 17.** Not the string-encoding workaround. The struct is ~15 lines, adds one Optional field, has zero breaking changes (all existing entries default to `None`), and eliminates the Sprint 18 refactor debt.
Dudley: I understand the instinct to minimize schema changes. But `Option<T>` with serde defaults is not a breaking change -- it's additive. Existing serialized data deserializes with `None`. New data with a contradiction stores the struct. No migration needed.
---
## Secondary Items Resolution
### Global disclosure rate limit
Round 1 split: Tyre/Dudley/Paula for, Gestalt against.
Gestalt's argument: the limit is invisible to the player and creates unintelligible competition between NPCs. Valid concern. A global limit that silently suppresses NPC A's disclosure because NPC B disclosed 3 ticks ago is bad UX.
**Revised position: Per-game-minute global cap (1 disclosure per 10 ticks), not per-tick.** At 10 ticks per game-minute, this means the player can receive at most 1 unprompted disclosure per game-minute from any NPC. The per-NPC cooldown (300 ticks / 30 game-minutes) is the primary rate limiter. The global cap is a degenerate-case safeguard that only fires when 3+ NPCs all want to disclose in the same game-minute -- unlikely in normal gameplay, but possible when entering a crowded area after a long absence.
The cap is a `Resource`:
```rust
#[derive(Resource, Debug, Default)]
pub struct GlobalDisclosureLimiter {
pub last_disclosure_tick: u64,
}
```
Check: `if time.tick - limiter.last_disclosure_tick < 10 { return; }`. One comparison per disclosure attempt. Zero overhead when no disclosure fires.
**Gestalt:** I hear your concern. The per-game-minute granularity means the player would have to encounter 2 NPCs both wanting to disclose within 6 real-time seconds (at 10 tps) for the limit to bite. In practice this safeguard almost never fires. But when it does fire (player enters bar with 8 NPCs after a long investigation), it prevents a disclosure avalanche. Can you live with this?
### Contradiction time window (`CONTRADICTION_WINDOW_TICKS`)
Gestalt proposed 600 ticks (1 game-hour). Paula asked what makes narrative sense.
The window defines "how recent must a claim be for an observation to contradict it, rather than just updating stale info?" The answer depends on the temporal granularity of claims.
Sera says "Kael was at the dock during second shift." This is a claim about a specific time period. If the player sees Kael in B-7 during second shift, it's a contradiction. If they see Kael in B-7 two game-days later, it's not -- people move around.
**Position: 1800 ticks (3 game-hours) as the default.** This covers a full shift with buffer. Rationale:
- D-031: 10 ticks = 1 game-minute. 600 ticks = 1 game-hour. 1800 ticks = 3 game-hours.
- A claim like "Kael was at the dock during second shift" implies a multi-hour window, not a single moment.
- 3 game-hours gives the player time to hear the claim, travel across the station, and observe the contradiction. At normal movement speed, crossing the v0.1 station map takes ~5-10 game-minutes. 3 hours is generous.
- The decay system marks entries as stale at 3600 ticks (6 game-hours). The contradiction window (1800) is well within the "still fresh" range.
This is a `const` or a `Resource`, easily tunable during playtest:
```rust
/// Ticks within which a ToldBy claim can be contradicted by a new observation.
/// Default: 1800 ticks = 3 game-hours (at 10 tps per D-031).
pub const CONTRADICTION_WINDOW_TICKS: u64 = 1800;
```
### `DisclosureCandidates` compute trigger
Gestalt asks: is there a "player entered dialogue range" event?
No, and we don't need one. **Lazy evaluation in the disclosure system itself.**
The `process_unprompted_disclosure` system runs every tick for Active-tier NPCs. Step 1: check proximity to player (Manhattan distance <= 3, same as `CONVERSATION_PROXIMITY`). If not proximate, skip. Step 2: check if `DisclosureCandidates` component exists and is fresh (within 60 ticks / 6 game-minutes). If stale or missing, recompute from KG. Step 3: apply trigger gates and attempt disclosure.
The proximity check is O(1) per NPC (position comparison). The candidate recomputation is O(N) per NPC KG where N = fact count (~20). With 30-80 Active NPCs, most of which are NOT proximate to the player, the per-tick cost is dominated by the proximity check -- sub-microsecond for distant NPCs.
No event infrastructure needed. The system self-manages freshness via the `last_derived_tick` field on `DisclosureCandidates`.
### Witness inhibition + location privacy gates
Gestalt proposes witness inhibition (fewer disclosures when other NPCs are nearby). Paula proposes location privacy (disclosure candidates tagged `private`/`semi-private`/`any`).
**Include both in the v0.1 trigger gate list.** They're cheap and complementary:
- **Witness inhibition:** Count `ActiveSim` NPCs within 3 tiles of the disclosing NPC (reuse the same proximity scan from the conversation system). If count > 0 and disclosure is trust-tier `real` or above, suppress unless NPC-player trust is at `secret` tier (override). Cost: one pass over the NPC position query, which the disclosure system already needs for proximity.
- **Location privacy:** Tag on disclosure candidates. The NPC's current tile has a `ZonePrivacy` component (or tagged via the location content data). Candidates tagged `private` only fire in private zones. Cost: one component read per disclosure attempt.
Both produce meaningful spatial behavior: the player learns "Sera talks freely at the bar but clams up at the Terminal." That's not just realism -- it's an investigative tool. The player can manipulate disclosure by choosing where to encounter NPCs.
### Runtime NPC KG guardrail
Round 1 split: Tyre (3-line check, Sprint 17), Dudley (Tier 3, defer), Gestalt (runtime + build pipeline), Paula (authoring-time mandatory).
**Revised position: Content-load validation in Sprint 17. Runtime check deferred.**
Dudley is right that the runtime check adds query complexity to `process_knowledge_events` (cross-entity KG lookup to verify the granting NPC holds the fact). This is a `Query<&KnowledgeGraph>` fetch inside the event processing loop, which currently only uses `knowledge_query.get_mut(event.observer)`. Adding a second lookup for the granting NPC's KG changes the borrow patterns.
Content-load validation catches the common case (author wrote a grant for a fact the NPC doesn't know in their initial KG). This is a ~20-line check in the content indexer. Runtime enforcement handles the dynamic case (NPC learned and then forgot a fact) but is architecturally heavier.
**Sprint 17: content-load validation. Sprint 18: runtime enforcement if playtest shows it matters.**
### Paula's Major secret `disclosure_threshold_override`
Paula proposes a per-KG-entry flag that prevents sharing regardless of trust tier. This models "Kael will never tell anyone about the ring, even at maximum trust, because sharing is dangerous."
**Architecturally sound. Add to the gossip transfer filter.**
Implementation: a new field on `FactKnowledge`:
```rust
pub struct FactKnowledge {
// ... existing fields ...
/// If true, this fact is never transferred via gossip or unprompted disclosure,
/// regardless of trust tier. Used for Major secrets whose sharing is dangerous.
#[serde(default)]
pub never_disclose: bool,
}
```
The gossip transfer system and the disclosure candidate system both check this flag before including a fact in the transfer/candidate set. One boolean check per entry. Trivial cost.
Content authors set `never_disclose: true` on facts that represent existential secrets. This is cleaner than encoding the behavior in the trust-tier thresholds, because it's per-fact rather than per-tier.
---
## D-Record Draft: Architecture Sections
The following are draft sections for the workshop's D-record, covering architecture, ECS patterns, and performance budgets.
### Section: Knowledge Grant Architecture
**Grant event type.** All knowledge input flows through a single `KnowledgeEventType::KnowledgeGranted` variant. No separate event types for dialogue grants, evidence discovery, POI discovery, or NPC-to-NPC gossip transfer. The source field (`KnowledgeSource`) distinguishes provenance: `ToldBy` for NPC testimony, `DirectObservation` for physical evidence, `Heard` for overheard conversations, `Background` for initial character knowledge.
**Grant payload.** The YAML `KnowledgeGrant` schema supports three variants via serde untagged enum:
- `Fact { fact_id, confidence }` -- creates/updates a `FactKnowledge` entry
- `Entity { entity_ref, position_hint, attributes, confidence }` -- creates/updates an `EntityKnowledge` entry with `ToldBy` source
- `Compound { grants: Vec<KnowledgeGrant> }` -- multiple grants from a single line
The `entity_ref` string resolves to `StableId` at content index time via the `ContentNameRegistry` resource, populated during NPC spawn.
**Grant timing.** Grants fire at line selection time in the dialogue system, server-side. The event is pushed to `KnowledgeEventQueue` and processed on the same tick. This is D-010 compliant: deterministic, tick-stamped, server-authoritative.
**Content validation.** Content-load validation checks that grant confidence strings parse to valid `KnowledgeConfidence` variants, fact_ids conform to `"category.topic"` format, and entity_refs resolve to known StableIds. Runtime NPC KG validation deferred to Sprint 18.
### Section: NPC-to-NPC Knowledge Propagation
**Hook.** Knowledge transfer piggybacked on the existing `run_npc_conversations` system (`server/src/simulation/conversation.rs`). A separate `transfer_npc_knowledge` system runs immediately after conversations (bevy ECS system ordering), using `kg_query.get_many_mut([entity_a, entity_b])` for concurrent mutable access to both participants' KGs.
**Trust-gated filtering.** Transfer eligibility determined by `RelationshipEdge.trust` value between the two NPCs:
| Trust | Tier | Eligible entries |
|-------|------|-----------------|
| < 0 | None | No transfer |
| 0..3 | Surface | Active facts with confidence >= KnowsOf |
| 3..7 | Real | Active facts at any confidence + entity observations |
| 7..10 | Secret | All Active entries except `never_disclose` flagged |
**Confidence cap.** `transferred_confidence = min(source_confidence, KnowledgeConfidence::KnowsOf)`. Gossip chains never produce `KnowsDetails` or `Direct`. `Suspects` stays `Suspects`.
**Rate limit.** 1-3 facts per conversation (`rng.random_range(1..=3)`), selected from eligible entries sorted by `last_updated_tick` descending (most recent first).
**Source construction.** `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: time.tick }`. Both values available at conversation termination.
**Player overhearing.** When the player entity is within `VOICE_RANGE_TILES` (8) of an NPC-NPC conversation that transferred knowledge, the player's KG gains entity-level entries (the entities discussed) at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: SoundRange::Medium }`. Specific fact transfer to the player deferred to Sprint 18 when content-authored NPC conversation lines replace placeholders.
### Section: Unprompted Disclosure
**Architecture.** Disclosure is a filtered KG query producing a `DisclosureCandidates` component, consumed by Layer 4 of the dialogue pipeline. Separate from `DerivedTellState` (different update frequency, different data shape).
**Candidate derivation.** `derive_disclosure_candidates` runs lazily: only for Active-tier NPCs within proximity of the player, recomputed every 60 ticks (6 game-minutes). Queries the NPC's `KnowledgeGraph` for Active-state facts at confidence >= `KnowsOf`, filters by trait-based predicates and `never_disclose` flag.
**NPC checks own KG only.** D-010 principle 2 (information boundaries) prohibits cross-entity KG queries for disclosure decisions. NPCs do not know what the player knows. Repeated disclosure of known information is acceptable and narratively meaningful.
**Trait two-stage filter.** Stage 1: traits filter the candidate pool (WHAT the NPC is willing to disclose). Stage 2: traits bias Line Pool scoring (HOW the NPC says it). Both stages are content-authorable via trait-to-predicate mappings in configuration YAML.
**Trigger gates** (all must pass):
1. Trust tier >= `surface` (NPC willing to speak to player)
2. Mood != Hostile
3. Contentment >= -10
4. Witness inhibition: if nearby NPCs present, suppress `real`/`secret`-tier disclosures unless NPC-player trust is `secret` tier
5. Location privacy: disclosure candidates tagged `private` only fire in private zones
6. Per-NPC disclosure cooldown (300 ticks / 30 game-minutes)
7. Global disclosure limiter (1 per 10 ticks / 1 per game-minute)
### Section: NPC Information Boundaries
**MVP scope.** Sprint 17: `tell_state.rs` relationship reads from KG + unprompted disclosure from KG. No other system retrofits.
**Self-knowledge.** NPCs read their own axis components directly for self-state (Secret, Contentment, Tolerance, Mood). The KG applies to knowledge of OTHER entities only. An NPC always knows its own internal state -- the information boundary applies to external knowledge.
**Simulation tiers.** KG-driven behavior scoped to `With<ActiveSim>`. Background-tier NPCs (D-026, 500-2000) receive no KG-based boundary changes. Tier transition from Background -> Active hydrates the NPC's KG from Background state and accumulated gossip.
**Fallback.** When an NPC's KG has no relevant entry for a decision, fall through to ground truth with `tracing::debug!` structured logging. No "ask around" behavior in v0.1.
**Deferred.** Conversation partner KG check (Sprint 18 candidate). Routine KG awareness (v0.2+). Pathfinding from KG (not planned -- failure mode has no safe recovery).
### Section: Contradiction Detection
**Detection timing.** Event-driven at KG write time. In `observe_entity()` (`server/src/knowledge/graph.rs:111`), before overwriting the entry, compare incoming observation against current entry. If `current.source` is `ToldBy`, `current.last_known_position` differs from incoming position, and `|current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS`, the contradiction fires.
**`ContradictionClaim` struct.** New optional field on `EntityKnowledge`:
```rust
pub struct ContradictionClaim {
pub source: KnowledgeSource,
pub claimed_position: Option<TilePosition>,
pub detected_tick: u64,
}
```
On contradiction detection: `entry.state = KnowledgeState::Contradicted`, `entry.contradicted_claim = Some(...)`, then overwrite with the new observation. The prior claim is preserved for monologue and relationship downstream consumers.
**Contradiction window.** `CONTRADICTION_WINDOW_TICKS = 1800` (3 game-hours). Covers a full work shift with travel buffer. Configurable per-playtest.
**Location contradiction:** Automatic. Position comparison + time window. Tier 1 difficulty.
**Attribute contradiction:** Content-authored pairs in YAML. Author specifies which attribute key+value combinations are mutually exclusive. Detection checks the authored lookup table when attributes are updated. Tier 2 difficulty.
**Fact contradiction:** Content-authored `contradicts` field on KnowledgeGrant YAML entries. Detection fires when both contradicting facts exist in the same KG at Active state.
**Event chain:**
1. `observe_entity()` detects contradiction, sets `Contradicted`, stores `ContradictionClaim`
2. `detect_anomalies` (`server/src/perception/anomaly.rs:44`) marks entity with `AnomalyMarker` (fires on `Contradicted` state, already tested)
3. Monologue system selects contradiction line, references `contradicted_claim.source` to name the source entity
4. Relationship system shifts `ToldBy` source entity to `PersonOfInterest` (D-033 amber color)
Steps 2-4 are already implemented and passing tests. Step 1 is the new work.
**Monologue content.** Contradiction monologue lines for FRIEND-pattern NPCs are hand-authored with explicit source naming ("Sera said Kael was at the dock"). Generic fallback template for auto-generated NPCs (Sprint 18). v0.1: hand-authored lines are sufficient given 2 FRIEND NPCs.
### Section: Performance Budgets
| Operation | Cost | Budget | Margin |
|-----------|------|--------|--------|
| Knowledge grant processing | ~1us per grant | 3ms/tick knowledge budget | 3000x |
| NPC-to-NPC transfer (per conversation) | ~6us (trust lookup + scan + write) | 3ms/tick | 500x |
| Disclosure candidate derivation (per NPC) | ~5us (KG scan of ~20 facts) | 3ms/tick | 600x |
| Location contradiction detection (per KG write) | ~10ns (position comparison) | 3ms/tick | 300,000x |
| Attribute contradiction detection (per attribute write) | ~500ns (authored pair lookup) | 3ms/tick | 6000x |
| `ContradictionClaim` struct memory (per contradicted entry) | ~56 bytes | ~14 KB per NPC KG | 0.4% overhead |
All operations are well within the D-041 performance budget. The event-driven architecture (only process when KG is written) keeps everything off the per-tick hot path.
### Section: Q-025 Closure
**Decision: Close Q-025.** KG memory cap and eviction strategy is not needed for v0.1 or v0.2.
Current budget: ~14 KB per Active NPC KG (50 entities + 20 facts). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries = ~5 MB. Total: ~6 MB. Gossip propagation increases entries by ~30% (1-3 facts per conversation, ~2 conversations per game-hour per NPC). Even doubled: ~12 MB.
**Re-evaluation trigger:** Active NPC count exceeds 200, OR profiling shows KG memory exceeding 50 MB. Neither condition is expected before v0.3.
---
## Implementation Dependencies and Sprint 17 Ordering
```
Week 1:
ContentNameRegistry + KnowledgeGrant YAML extension [0.5 day]
KnowledgeEventType::KnowledgeGranted + processing [1.5 days]
ContradictionClaim struct on EntityKnowledge [0.5 day]
─── entity grants + contradiction struct land ───
Week 2:
transfer_npc_knowledge system (gossip) [2 days]
DisclosureCandidates + trigger system [2 days]
↑ can parallel ↑
tell_state.rs Option<&KnowledgeGraph> addition [0.5 day]
Week 3:
Location contradiction detection in observe_entity [1.5 days]
Attribute contradiction authored pairs + detection [1.5 days]
Monologue integration (FRIEND arc lines) [0.5 day]
─── contradiction chain complete ───
```
**Total: ~10.5 days.** Gossip and disclosure are parallelizable (Week 2). Contradiction detection depends on gossip (needs ToldBy entries to exist). The critical path is: grant mechanism -> gossip -> contradiction detection.
---
## Summary of Round 2 Positions
| Tension/Item | Resolution | Rationale |
|---|---|---|
| **Tension A: Entity grants** | Sprint 17. ContentNameRegistry ~30 lines. | FactId workaround breaks contradiction chain. Registry cost is 0.5 days, not multi-day. |
| **Tension B: Structural fix** | Typed `ContradictionClaim` struct, not string encoding. | Zero breaking changes (Option + serde default). No refactor debt. No string parsing in hot path. |
| Global disclosure limit | Per-game-minute (1 per 10 ticks). Resource counter. | Addresses Gestalt's concern (generous enough to rarely fire) while preventing degenerate case. |
| Contradiction window | 1800 ticks (3 game-hours). Configurable const. | Covers a shift with travel buffer. Well within decay freshness range (3600 stale). |
| Disclosure compute trigger | Lazy evaluation in the disclosure system. 60-tick freshness. | No event infrastructure needed. Proximity check is O(1) per NPC. |
| Witness + location gates | Both included in v0.1 trigger list. | Cheap, complementary, produce meaningful spatial behavior. |
| Runtime NPC KG guardrail | Content-load validation Sprint 17. Runtime Sprint 18. | Dudley is right that runtime adds borrow complexity. Content-load catches the common case. |
| Major secret override | `never_disclose: bool` on `FactKnowledge`. | Clean, per-fact, one boolean check per entry in gossip/disclosure systems. |
| Naming | `ContradictionClaim` (not `ContradictionBasis`). | "Claim" describes the payload: the prior claim that was contradicted. |
@@ -0,0 +1,200 @@
# Knowledge Flow & NPC Information Boundaries Workshop Brief
**Goal:** Design the knowledge grant mechanism, NPC-to-NPC knowledge propagation, unprompted disclosure mechanics, NPC information boundaries, and contradiction detection algorithm -- resolving the functional gap between the mature D-041 data model and the Sprint 17 tickets that depend on it.
**Sprint context:** Between Sprint 16 (closing) and Sprint 17 (planned)
**Priority:** CRITICAL -- Sprint 17 blocker (6 tickets depend on these answers)
**Participants:** Tyre (architecture lead), Gestalt (systems design), Dudley (server implementation), Paula (narrative), Qatux (documenter)
**Source:** Tyre architecture audit 2026-02-23, Sprint 17 planning dependency analysis
## Context
The Knowledge Graph data model (D-041) shipped in Sprint 2 and is mature. The Rust types are solid (`server/src/knowledge/types.rs`), the per-entity `KnowledgeGraph` component works (`server/src/knowledge/graph.rs`), the event queue drains correctly (`server/src/knowledge/events.rs`), and the `filter_by_access` system is implemented with 14 passing tests. Player-facing information boundaries function.
The NPC-facing side is functionally empty. NPCs run on ground truth, gossip does not transfer knowledge, contradiction detection does not fire, and dialogue cannot grant facts. Sprint 17 introduces tickets that build on this unfinished foundation.
### Audit Findings (Tyre, 2026-02-23)
**Q-024 (Gossip timing) -- Still open.** The `ToldBy` source variant (`KnowledgeSource::ToldBy { source_id: StableId, tick: u64 }`, `server/src/knowledge/types.rs` line 97) is defined but never constructed anywhere in the codebase. NPC conversations (`run_npc_conversations` in `server/src/simulation/conversation.rs`, line 278+) are cosmetic -- they emit voice `SoundEvent`s and `ConversationEvent`s for the player's snapshot, but no knowledge flows between the participating NPCs. The conversation system IS the natural "routine intersection" hook the original workshop preferred. It already has proximity detection, cooldown management, and deterministic pairing via `StableId` sorting (D-010 principle 4).
**Q-025 (KG cap/eviction) -- Close.** Memory analysis shows ~30KB total at current NPC count. Not needed for v0.1/v0.2. Formal closure recommended.
**Q-026 (Contradiction detection) -- Downstream consumers fully built and tested, detection algorithm not implemented.** Anomaly marking in `server/src/perception/anomaly.rs` fires on `KnowledgeState::Contradicted`. Snapshot flags in `server/src/perception/observer/mod.rs` propagate it. Sprint double-take monologue in `server/src/simulation/monologue.rs` handles it. All tests manually set `Contradicted` on KG entries. The detection algorithm that would SET `Contradicted` based on comparing sources does not exist. Prerequisite: `ToldBy` sources must exist, which requires Topics 1 and 2 to be resolved first.
**#141 (Player info gating) -- Mostly done.** `filter_by_access` in `server/src/knowledge/graph.rs` is implemented and tested (14 tests). The dialogue pipeline enforces KG gating (layers 1-3 in `server/src/simulation/dialogue.rs`). Gap: the `knowledge_grant` field on `IndexedDialogueLine` (`server/src/content/line_pool.rs` line 297) is stubbed -- `Option<KnowledgeGrant>`, always `None`. The YAML schema defines `KnowledgeGrant { fact_id: String, confidence: String }` (`server/src/content/types.rs` lines 495-498). Dialogue lines cannot currently grant facts to the player's KnowledgeGraph.
**#142 (NPC info boundaries) -- Still open.** No NPC system queries its own `KnowledgeGraph`. `server/src/npc/tell_state.rs` uses axis values directly (Secret, ToleranceThreshold, Contentment, MoodState, Relationships). `server/src/npc/routine.rs` uses `DailyRoutine` directly. `server/src/simulation/path_follow.rs` uses ground truth. `server/src/simulation/conversation.rs` uses proximity + probability, not knowledge.
### Sprint 17 Tickets That Depend on These Answers
| Ticket | Title | Dependency |
|--------|-------|------------|
| **#172** | Layer 4: Unprompted disclosure | Knowledge propagation model (what can NPCs share?), NPC KG awareness (what does the NPC know to share?) |
| **#173** | Trait modifier system | Do traits reshape WHAT NPCs disclose (filtering) or HOW they say it (delivery)? Or both? |
| **#148** | POI data model | How do POIs enter the knowledge graph? New FactId category? Or separate system? |
| **#149** | POI discovery system | This IS knowledge flow -- it needs the grant mechanism |
| **#141** | Knowledge-based information gating | Wire `knowledge_grant`, complete the loop |
| **#142** | NPC information boundaries | NPCs use own KG for decisions |
## Key Code to Reference
Participants should read these files before Round 1:
| File | What to read | Why |
|------|-------------|-----|
| `server/src/knowledge/types.rs` | Full file | Core types: `KnowledgeSource::ToldBy` (line 97), `KnowledgeConfidence` hierarchy, `KnowledgeState::Contradicted`, `EntityKnowledge` struct, `FactKnowledge` struct |
| `server/src/knowledge/graph.rs` | Full file | `KnowledgeGraph` component, `filter_by_access` (14 tests), entity/fact queries, `observe_entity`, `observe_entity_leaving_los` |
| `server/src/knowledge/events.rs` | Full file | `KnowledgeEventQueue`, `KnowledgeEventType` variants (currently: `DirectObservation`, `LeftLOS`, `IncompleteInteraction`), `process_knowledge_events` system |
| `server/src/knowledge/registry.rs` | Full file | `EntityRegistry` -- `StableId <-> Entity` bidirectional mapping |
| `server/src/simulation/conversation.rs` (line 278+) | `run_npc_conversations` | NPC proximity pairing, conversation lifecycle, `SoundEvent`/`ConversationEvent` emission -- the hook for knowledge transfer |
| `server/src/simulation/dialogue.rs` | Full file | D-028 four-layer pipeline: access tier (from KG), situations, trust tier (from KG), topic+mood scoring |
| `server/src/npc/tell_state.rs` | Full file | `TellCategory` derivation from axis values -- currently bypasses KG entirely |
| `server/src/npc/relationships.rs` | Full file | `TrustEvent` queue, relationship state transitions, trust derivation |
| `server/src/content/line_pool.rs` (line 297) | `IndexedDialogueLine` | `knowledge_grant: Option<KnowledgeGrant>` stub |
| `server/src/content/types.rs` (lines 495-498) | `KnowledgeGrant` | YAML-parsed schema: `{ fact_id: String, confidence: String }` |
| `server/src/perception/anomaly.rs` | Full file | `detect_anomalies` system -- marks entities with `AnomalyMarker` based on KG `Contradicted`/`PersonOfInterest` |
| `server/src/simulation/monologue.rs` | Lines 1-60, sprint double-take | Anomaly monologue, recognition lines -- downstream consumer of contradiction |
## Decisions to Reference
| Decision | Topic | Relevance |
|----------|-------|-----------|
| **D-010** | Multiplayer-ready info boundaries | Non-negotiable: every design must respect info boundaries, deterministic iteration (BTreeMap), no player-special-casing |
| **D-011** | Fog of perception | NPCs use the same LOS/perception system as player -- information boundaries are universal |
| **D-024** | NPC 10 axes | Tell system, Secret, Contentment, Tolerance -- the axes that should be KG-aware but currently are not |
| **D-028** | Dialogue architecture (four layers) | Layer 4 (unprompted disclosure) is the immediate Sprint 17 target. Knowledge grants must integrate with this pipeline |
| **D-033** | Entity color from relationship | Color shifts on `RelationshipState` change -- downstream of contradiction detection |
| **D-034** | THE FRIEND arc | The canonical contradiction sequence that drives the emotional centerpiece of v0.1 |
| **D-035** | Tag taxonomy with prerequisites | Monologue prerequisite tags gate on knowledge state -- must align with grant mechanism |
| **D-041** | Knowledge graph data model | The foundation -- data model is stable, this workshop fills the behavioral gaps |
| **D-071** | Eavesdropping / ListeningFocus | Overheard info enters KG at lower confidence -- informs NPC overhearing rules |
| **D-078** | Overheard NPC conversation | Server-authoritative occlusion filter, passive dialogue panel -- player-side of NPC conversations |
---
## Workshop Topics
### Topic 1: Knowledge Flow -- The Grant Mechanism
When an NPC tells the player something (dialogue) or the player discovers something (POI, evidence, overheard conversation), how does the knowledge graph get updated?
**Sub-questions:**
1. **Wire `knowledge_grant` on dialogue lines.** The YAML schema exists (`KnowledgeGrant { fact_id, confidence }`). The `IndexedDialogueLine` field exists but is always `None`. What triggers the grant -- line selection? Line display on client? Separate post-dialogue system? Who constructs the `KnowledgeEvent` and what `KnowledgeEventType` variant does it use?
2. **Entity knowledge vs fact knowledge in grants.** Current `KnowledgeGrant` only has `fact_id` (a string). Should grants also update `EntityKnowledge` entries? Example: Sera tells the detective "Kael handles cargo at Dock 7" -- this should create/update an `EntityKnowledge` entry for Kael with `source: ToldBy { source_id: sera_sid }`, not just a fact. Does `KnowledgeGrant` need an `entity_grant` variant?
3. **POI discovery as knowledge flow.** `PointOfInterest` (#148) needs to enter the KG. Options: (a) new `FactId` category "poi.*" (e.g., `FactId("poi.dock_7_restricted")`), (b) extend `EntityKnowledge` to cover locations, (c) separate POI knowledge type. Which integrates cleanest with existing `filter_by_access` and dialogue prerequisite checks?
4. **Physical evidence discovery** (terminals, documents, cargo manifests). Same grant mechanism as dialogue? Or a separate `KnowledgeEventType::EvidenceDiscovered`?
5. **Content author guardrails.** What knowledge should NPCs be ABLE to grant? A content validation rule that prevents authors from accidentally making NPCs omniscient. Example: NPC can only grant facts that exist in their own KG. Authoring-time validation or runtime enforcement?
**Feasibility note (Tyre):** The event queue architecture (`KnowledgeEventQueue`) already handles this pattern cleanly. Adding a new `KnowledgeEventType::KnowledgeGranted` variant and a system that fires it post-dialogue-selection is a ~2-day task. The harder question is the schema design for multi-type grants (entity + fact).
### Topic 2: NPC-to-NPC Knowledge Propagation (resolves Q-024)
When NPCs talk to each other, what knowledge transfers?
**Sub-questions:**
1. **Confirm queued approach or revise.** Q-024 preferred direction: queued at routine intersections. The conversation system (`run_npc_conversations`, `server/src/simulation/conversation.rs` line 278+) IS a routine intersection -- NPCs start conversations when proximate during their routines. It already has proximity detection, cooldown timers, and deterministic pairing. Does this satisfy "queued at routine intersections" or do we need a separate system?
2. **Trust-gated filtering.** NPC A has trust level T toward NPC B. What knowledge does A share at each trust level? Proposed mapping to D-028 trust tiers: `surface` trust = share publicly-known facts only, `real` trust = share observations and rumors, `secret` trust = share sensitive knowledge. How does this map to the `KnowledgeConfidence` hierarchy?
3. **Confidence downgrade on transfer.** When NPC A tells NPC B something, B's entry should be at a lower confidence than A's. Proposed: `ToldBy` confidence = `min(source_confidence, KnowsOf)`. Direct observation downgrades to KnowsOf when transferred. `Suspects` stays `Suspects`. This prevents gossip chains from producing `KnowsDetails` knowledge.
4. **Rate limiting.** How much knowledge per conversation? All eligible entries? Random subset? Fixed cap (e.g., 1-3 facts per conversation)? Cap prevents knowledge-explosion from a single NPC-NPC meeting.
5. **Observable by player.** If the player overhears an NPC-NPC conversation (D-078, occlusion filter), what do they learn? The player gets per-word-occluded text. Should the grant mechanism fire for the player based on what words survived occlusion? Or is overheard knowledge always at `Suspects` confidence regardless of fidelity?
6. **ToldBy source construction.** The `KnowledgeSource::ToldBy { source_id: StableId, tick: u64 }` variant is defined but never constructed. This topic must produce the system that creates it. The source entity's `StableId` is already available in the conversation system (line 321: `sid.map(|s| s.0.0).unwrap_or(u64::MAX)`).
**Feasibility note (Tyre):** Tier 1 difficulty -- the conversation system already does the hard part (pairing, lifecycle, events). Adding a knowledge transfer phase between conversation start and conversation end is architecturally clean. The trust-gated filtering adds a query against both NPCs' KGs, which is O(log N) per entry. At ~50 entries per NPC and 1-3 transfers per conversation, this is sub-microsecond per conversation tick.
### Topic 3: Unprompted Disclosure Design (#172)
NPCs volunteer information to the player without being asked. D-028 Layer 4.
**Sub-questions:**
1. **Connection to NPC KG.** `tell_state.rs` currently derives tell category from raw axis values (Secret, Tolerance, Contentment, Mood). #172 needs NPCs to volunteer INFORMATION, which requires knowing what they know. How does the tell derivation system connect to the NPC's `KnowledgeGraph`? Does `DerivedTellState` gain a `disclosure_candidates: Vec<FactId>` field?
2. **"Do I know something you don't?"** Does the NPC check whether the player already knows a fact before volunteering it? Option A: NPC only checks own KG (simpler, may repeat known info -- but the NPC does not know what the player knows). Option B: NPC checks own KG vs player KG (requires cross-entity KG query, but prevents redundant disclosure). Option A is more realistic (NPCs do not know what you know). Option B is better UX. Which do we choose, or is there a middle ground?
3. **Trait filtering (#173).** Do personality/culture traits affect WHAT is disclosed (filtering -- a cautious NPC withholds certain facts) or HOW it is delivered (delivery -- same facts, different phrasing)? Or both? If "what": traits become a KG filter. If "how": traits become a line-pool modifier. If both: traits filter candidate facts, then modify the delivery of surviving candidates.
4. **Trigger conditions.** When does unprompted disclosure fire? Proposed: trust threshold met + mood permits + location appropriate + rate limit not exceeded. Which axes contribute? Contentment, trust toward player, mood state, presence of other NPCs (witnesses)?
5. **Rate limiting.** How often can an NPC volunteer info? Per-NPC cooldown? Per-fact cooldown (do not repeat the same fact)? Global rate limit across all NPCs (prevent disclosure spam)? Interaction with D-028 Layer 4 line cooldown (`LINE_COOLDOWN_TICKS` = 600 ticks in `server/src/simulation/dialogue.rs` line 39)?
**Feasibility note (Tyre):** Tier 2 difficulty. The dialogue pipeline exists and handles line selection well. The challenge is the "what to disclose" derivation -- this is new logic that sits between the KG and the line pool, and it needs to be both mechanically sound and narratively satisfying. Paula and Gestalt need to co-design the disclosure candidate selection before Dudley can implement.
### Topic 4: NPC Information Boundaries (#142)
NPCs should use their own `KnowledgeGraph` for decisions, not ground truth.
**Sub-questions:**
1. **Which NPC systems should be retrofitted? Priority order.** Candidates:
- `npc/tell_state.rs` -- tell derivation (currently uses axes directly, not KG)
- `simulation/conversation.rs` -- conversation partner selection (currently uses proximity, not knowledge of who is nearby)
- `npc/routine.rs` -- routine execution (currently uses `DailyRoutine` directly)
- `simulation/path_follow.rs` -- pathfinding (currently uses ground truth walkability)
2. **Minimum viable boundary.** What is the smallest retrofit that makes a gameplay-visible difference? Proposed: just `tell_state.rs` + unprompted disclosure (#172). An NPC that only reveals what it knows (via KG) is a meaningful boundary even if its pathfinding still uses ground truth.
3. **Simulation tier interaction (D-026).** Background-tier NPCs (500-2000) have minimal KGs. Do they get simplified boundaries? Option: Background NPCs have no KG-based boundaries (they run state machines, not full AI). Only Active-tier NPCs (30-80) get KG-driven behavior. This is consistent with D-026's tiered simulation model.
4. **What breaks?** If `tell_state.rs` reads from KG instead of raw axes: nothing breaks -- KG reflects observed state, which for an NPC's own axes is always up-to-date. If `path_follow.rs` reads from KG: NPCs might "forget" where things are after decay, leading to stuck NPCs or nonsensical pathfinding. The fallback behavior matters.
5. **Fallback behavior.** When an NPC's KG has no relevant information for a decision, what happens? Options: (a) fall through to ground truth (safe but breaks immersion), (b) use last-known state from KG (realistic but may cause stuck behavior), (c) trigger "ask around" behavior (NPC seeks information, creates emergent scenes). v0.1 recommendation: option (a) with logging, option (c) as future enhancement.
**Feasibility note (Tyre):** Tier 1 for minimum viable (tell_state + disclosure). Tier 3 for full retrofit (pathfinding + routine). Recommend starting with the MVP and expanding per sprint. The conversation system retrofit is Tier 2 -- it needs to check whether NPC A knows NPC B exists before initiating conversation, which adds a KG query to the pairing loop.
### Topic 5: Contradiction Detection Pipeline (resolves Q-026)
The downstream consumers are built and tested. Design the detection algorithm.
**Prerequisite:** Topics 1 and 2 must produce `ToldBy` sources. Without `ToldBy` entries in KGs, there is nothing to contradict.
**Sub-questions:**
1. **Location contradiction (simplest case).** NPC says "X was at location A at time T" (`ToldBy` source). Player observes X at location B at time T (`DirectObservation` source). System detects: same entity, overlapping time window, different locations. Both entries receive `Contradicted` state.
2. **Attribute contradiction.** NPC says "X is trustworthy" (attribute in `known_attributes`). Player discovers X is a smuggler (different attribute value for same key). How are attribute keys structured to enable comparison? Current `known_attributes: BTreeMap<String, String>` is untyped -- does contradiction detection need typed attribute keys?
3. **Content-authored vs automatic detection.** Which contradictions are hand-authored ("fact A contradicts fact B" in content files) and which are algorithmic (same-subject different-value)? Proposed: location contradictions are automatic (algorithmic, based on position + time). Attribute contradictions are content-authored (explicit contradiction pairs in YAML). Fact contradictions are hybrid (some automatic categories, some authored).
4. **Detection timing.** When does contradiction detection run? Per-tick (expensive but immediate)? Per-game-minute (matches decay frequency)? On KG write (event-driven, only checks new entries against existing)? The event-driven approach is most efficient -- only fire detection when a `KnowledgeEvent` modifies a relevant entry.
5. **Event emission chain.** Detection fires `ContradictionDetected` event containing: observer entity, contradicting entries (A, B), contradiction type. Downstream: monologue system triggers "Wait -- that does not add up" line. Relationship system shifts to `PersonOfInterest`. Anomaly marker set. D-033 color transitions amber. What is the exact event flow?
6. **THE FRIEND arc mechanical sequence.** The canonical example from D-034: Sera tells detective "Kael was at the dock during second shift" -> detective observes Kael in corridor B-7 at that time -> location contradiction detected -> both entries Contradicted -> monologue: "Sera said Kael was at the dock. I just saw him in B-7." -> Sera shifts to PersonOfInterest -> amber color. Walk through this sequence and confirm every system fires correctly with the proposed design.
**Feasibility note (Tyre):** Location contradiction detection is Tier 1 -- BTreeMap lookup by `StableId`, compare positions within a time window. Attribute contradiction is Tier 2 -- needs typed attribute keys or content-authored pairs. The event-driven approach (check on KG write) keeps it off the per-tick hot path. Estimated: ~3 days for location detection + event chain, ~2 additional days for attribute detection.
---
## What This Workshop Is NOT
- **Not redesigning D-041.** The data model is stable and works. Types, BTreeMap mandate, event queue, decay -- all ship-tested. This workshop fills behavioral gaps, not structural ones.
- **Not full gossip implementation.** The propagation design is Sprint 17+ dev work. This workshop produces the specification, not the code.
- **Not resolving Q-017** (triangle pressure thresholds). Still deferred -- requires gameplay data.
- **Not designing the full FRIEND arc narrative.** That is Paula + Mellanie content work. This workshop defines the mechanical sequence that enables the narrative.
- **Not addressing Q-025 in depth.** Recommendation: close formally (not needed at current scale). If the workshop agrees, Qatux records the closure.
## Expected Outputs
1. **Decision: D-0XX -- Knowledge Flow & NPC Information Boundaries.** Comprehensive decision covering: grant mechanism schema, NPC-to-NPC propagation model, NPC boundary scope, contradiction detection algorithm. Resolves Q-024 and Q-026.
2. **Formal closure of Q-025** (not needed at current scale, re-evaluate at 500+ Active NPCs).
3. **Implementation scope for #141** -- remaining work list: wire `knowledge_grant` field through dialogue pipeline, construct `KnowledgeEvent` on line selection, verify `filter_by_access` integration.
4. **Implementation scope for #142** -- which systems to retrofit, priority order, minimum viable boundary, fallback behavior spec.
5. **Updated ticket descriptions for #172 and #173** -- mechanical specifications from the workshop: disclosure candidate selection algorithm, trait filter vs modifier decision, trigger conditions, rate limits.
6. **New tickets as needed** -- knowledge propagation system, contradiction detection system, POI-KG integration, `KnowledgeEventType` variants.
## Workshop Format
Two rounds, following project convention:
- **Round 1:** Each participant independently analyzes all 5 topics from their domain perspective. Reference specific code files and line numbers. Tyre: system architecture, ECS patterns, performance budgets. Gestalt: mechanical interactions, "is this fun?", knowledge as gameplay lever. Dudley: Rust implementation feasibility, integration with existing systems, edge cases. Paula: what NPCs should say/know, THE FRIEND arc sequence, narrative consequences of design choices.
- **Round 2:** Cross-review, debate, synthesis into concrete decisions. Produce D-record(s), close Q-024/Q-025/Q-026, and generate implementation tickets with mechanical specifications.
@@ -0,0 +1,345 @@
# Workshop Outcomes — Knowledge Flow & NPC Information Boundaries
**Workshop date:** 2026-02-23 — 2026-02-24
**Documented by:** Qatux
**Participants:** Tyre, Gestalt, Dudley, Paula
**Source files:** round-1-notes.md, round-2-notes.md, tyre-round1/2.md, gestalt-round1/2.md, dudley-round1/2.md, paula-round1/2.md
---
## D-Record Drafts
The following D-records are produced by this workshop. They are ready for registration in the appropriate decisions/ domain files. The D-numbers D-079 through D-083 are assigned sequentially after D-078 (current highest in perception.md).
---
### 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!`.
- **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:** 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. See round-2-notes.md §Tension A for full workshop split and rationale.
- **Dissent:** Paula (Round 2) accepted the FactId workaround with 3 binding conditions; team lead overrode in favour of the entity grants path championed by Tyre, Gestalt, and Dudley. Paula's conditions remain relevant where applicable: (1) `ToldBy` source must flow through `ContradictionDetected` event payload — satisfied by D-083 design; (2) Sprint 18 entity grants (full `EntityGrant { attributes: BTreeMap }` variant + `Compound` variant) are **committed scope**, not aspirational. Paula's Condition 3 is formally recorded here as a commitment.
- **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.
---
### 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 immediately after `run_npc_conversations` (system ordering: `transfer_npc_knowledge.after(run_npc_conversations)`). The system uses `kg_query.get_many_mut([entity_a, entity_b])` for dual-mutable KG access (required by Bevy ECS — two mutable borrows of the same component type cannot occur in a single query). 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:** The existing `run_npc_conversations` system already provides proximity detection, deterministic StableId-sorted pairing, and conversation lifecycle — reusing it avoids building a separate propagation system and keeps NPC conversations as the natural information-sharing hook. Confidence capping at `KnowsOf` prevents gossip chains from amplifying information; only direct observation produces `KnowsDetails` or `Direct`. `ToldBy` source construction is the prerequisite for contradiction detection (Topic 5). The `disclosure_blocked` flag models secrets whose sharing is existentially dangerous regardless of relationship trust (D-034 NPC design requirement). Closes Q-024.
- **Raised by:** Workshop — unanimous
- **Dissent:** None
---
### D-081: Unprompted Disclosure Design
- **Date:** 2026-02-24
- **Decision:** Unprompted disclosure (ticket #172) 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). 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 candidate's `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, prevents repeats; (2) per-NPC 300-tick cooldown — prevents spam; (3) global 1/10-tick cap — prevents degenerate multi-NPC simultaneous fire.
- **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 (Sera praising Kael's integrity after the player found discrepancies). Option B (NPC checks player's KG) would collapse this asymmetry — NPCs would go silent at exactly the moments their speech would be most dramatically charged. Separate `DisclosureCandidates` component (not added to `DerivedTellState`) prevents DerivedTellState serialization bloat and allows different update frequencies for different concerns. The location privacy gate creates learnable spatial behavior patterns: "Sera talks freely at Lera's; she clams up at The Terminal." The per-fact `per_fact_history` is the primary narrative quality gate — an NPC who repeats the same fact is a quest marker, not a person. Implements ticket #172.
- **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: Paula withdrew support, Gestalt conditionally accepted with deterministic StableId selection. Included.
---
### 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 (D-026, 500–2000) 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: NPCs whose tells reflect their actual relationship knowledge (not just raw axes) and NPCs who only volunteer what they know. 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 information boundary applies to external knowledge only. The `Option<&KnowledgeGraph>` addition is backward-compatible. Implements ticket #142 MVP.
- **Raised by:** Workshop — unanimous
- **Dissent:** None. All participants agreed MVP boundary is tell_state + disclosure. Paula adds that `tell_state` should eventually incorporate KG-derived secret-exposure intensity (NPC whose KG shows investigation proximity acts more stressed) — deferred to a future sprint as enhancement.
---
### 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 — the engine does not determine which is wrong). Location contradiction is automatic (position comparison + time window). 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. Closes Q-026.
- **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` (Dudley's Option B) because: (1) the struct is accessed with direct field reads — no string parsing in the detection hot path; (2) the monologue system needs a typed `KnowledgeSource` to resolve the source entity's name; (3) string encoding in `known_attributes` mixes semantic metadata with internal bookkeeping; (4) 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 to fire its canonical sequence (D-034).
- **Raised by:** Workshop — unanimous on architecture and downstream chain; Tyre and Gestalt on struct approach (converged independently); Dudley conceded Option B
- **Dissent:** Dudley (Round 1): Option B string encoding as Sprint 17 workaround. Conceded in Round 2. No standing dissent.
---
## Question Closures
### Q-024: NPC-to-NPC knowledge propagation timing
- **Status:** CLOSED
- **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. See D-080 for full specification.
- **Closed by:** Workshop — unanimous
---
### Q-025: KG memory pressure and eviction strategy
- **Status:** CLOSED
- **Resolution:** No memory cap or eviction strategy is needed for v0.1 or v0.2. 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 (1–3 facts per conversation): estimated ~12 MB peak at current NPC counts. **Re-evaluation trigger:** Active NPC count exceeds 200, OR profiling shows KG memory exceeding 50 MB. Neither condition expected before v0.3.
- **Closed by:** Workshop — Tyre, Gestalt, Dudley confirmed (Paula did not address; non-objection noted)
---
### Q-026: Contradiction detection — when and how to detect
- **Status:** CLOSED
- **Resolution:** Event-driven detection at KG write time in `observe_entity()`, using `ContradictionClaim` struct. Location contradiction is automatic (Sprint 17). Attribute and fact contradiction are content-authored (Sprint 18). See D-083 for full specification. Entity grants ship in Sprint 17 (team lead decision, 2026-02-24); the contradiction chain fires via `EntityKnowledge.ToldBy` as designed.
- **Closed by:** Workshop — unanimous on detection architecture. Unconditional.
---
## Implementation Scope
### Ticket #141 — Knowledge grant wiring (POI discovery)
**Updated scope based on workshop:**
- Grant mechanism: `KnowledgeEventType::KnowledgeGranted` (D-079) handles POI discovery via `FactId("poi.dock_7_restricted")` — no separate event type needed
- When player enters POI trigger zone, push `KnowledgeGranted { fact_id: FactId("poi.*"), confidence, source: DirectObservation { tick } }` to queue
- `filter_by_access` → `KnowledgeGated("poi.dock_7_restricted")` already gates content on this fact (graph.rs:299–302)
- No new access control code; no new types; no new systems beyond the shared `KnowledgeGranted` event
- **Depends on:** Dudley's Ticket A + B (grant infrastructure)
- **Effort:** ~20 lines on top of Ticket B infrastructure; primarily content authoring for POI definitions
### Ticket #142 — NPC information boundaries (MVP)
**Updated scope based on workshop (D-082):**
Sprint 17 deliverable:
1. `derive_tell_state` adds `Option<&KnowledgeGraph>` to query; uses KG for OTHER-entity relationship reads; falls back to axis components for self-state and when KG is absent
2. `DisclosureCandidates` component + `derive_disclosure_candidates` system (see #172 spec)
3. `process_unprompted_disclosure` system stub (trigger gates operational; content-authored lines sparse for Sprint 17)
**Explicitly out of Sprint 17 scope:**
- Conversation partner KG-awareness check (Sprint 18)
- Routine KG awareness (v0.2+)
- Pathfinding KG integration (not planned)
**Depends on:** Ticket B (KG populated with meaningful content); Ticket D (NPC KGs populated via gossip)
---
## Updated Specifications
### Ticket #172 — Unprompted Disclosure
**Full specification (supersedes any prior spec):**
**New components:**
```rust
#[derive(Component, Debug, Default)]
pub struct DisclosureCandidates {
pub candidates: Vec<FactId>,
pub computed_tick: u64,
}
#[derive(Component, Debug, Default)]
pub struct DisclosureCooldown {
pub per_fact_history: BTreeSet<FactId>, // facts disclosed this cooldown period
pub npc_cooldown_until: u64,
}
```
**`derive_disclosure_candidates` system:**
- Runs every tick for Active NPCs; recomputes candidates when `time.tick - computed_tick > 30`
- Filters NPC KG: Active state; confidence ≥ KnowsOf (trait-adjusted); not in `per_fact_history`; `disclosure_blocked != true`; trust rough-gate (categories requiring Real trust excluded at Surface)
- Trait Stage 1 predicates: Cautious = requires KnowsDetails+; Gossipy = includes Suspects; Loyal = suppresses entity-linked facts where entity is trusted by NPC
- Candidate list capped at 10; sorted by confidence descending, then `last_updated_tick` descending
**`process_unprompted_disclosure` system:**
- Trigger gates (all must pass): trust ≥ Surface; mood ≠ Hostile; contentment ≥ −10; candidates not empty; per-NPC cooldown clear; global rate limit (1/10 ticks, StableId-ordered); witness inhibition check; location privacy check
- On success: push `KnowledgeGranted` event for player + push selected fact to `per_fact_history`; reset `npc_cooldown_until`
**Trait Stage 2 (line pool):** Trait modifier tags in `IndexedDialogueLine.tags` bias Layer 4 selection weights for delivery style.
**`disclosure_context` YAML field on disclosure candidates:** `private` / `semi_private` / `any`. Checked at Layer 4 line selection against current location zone type.
**Estimated effort:** ~220 lines (Dudley Ticket G); requires Gestalt + Paula algorithm spec (now provided in round-2-notes.md).
---
### Ticket #173 — Trait filtering for unprompted disclosure
**Full specification (supersedes any prior spec):**
Traits operate as two-stage filters, both implemented:
**Stage 1 — candidate pool filter (WHAT):**
| Trait | Filter applied |
|-------|---------------|
| Cautious | Remove candidates with confidence < KnowsDetails; remove ToldBy-source facts (won't pass on rumors) |
| Gossipy | Include Suspects-confidence candidates (normally excluded) |
| Loyal | Remove facts linked to entities with trust_level ≥ Real in NPC Relationships |
| Talkative | Override witness inhibition gate; include candidates at all confidence levels |
Traits map to filter predicates via a content-authorable YAML configuration (not hard-coded enum dispatch).
**Stage 2 — line pool scoring (HOW):**
- Trait tags in `IndexedDialogueLine.tags` are matched by Layer 4 line selection
- Tag examples: `"cautious_delivery"`, `"gossip_delivery"`, `"professional_delivery"`
- Same underlying candidate fact can have multiple authored line variants with different trait delivery tags
**`disclosure_eligible: bool` in authored NPC KG YAML:**
- Set to `false` for facts that should never enter the candidate pool regardless of trust tier or trait
- Implemented as `disclosure_blocked: bool` field on `FactKnowledge` (default `false`)
- Used for Major secrets with survival stakes (e.g., Kael's ring membership)
**Estimated effort:** ~40 lines for trait filter predicates + content configuration schema
---
## New Ticket Recommendations
The following new tickets are recommended based on workshop outputs. These are recommendations for SI to formalize:
---
### Recommended Ticket A: KnowledgeGrant schema + ContentEntityRegistry
**Scope:** `server/src/content/types.rs` — replace `KnowledgeGrant` struct with untagged enum (`Fact` + `Entity` variants); add `disclosure_blocked: bool` to `FactKnowledge`. New file `server/src/knowledge/content_registry.rs` — `ContentEntityRegistry` resource. NPC spawn sites: add `content_registry.register(content_id, sid)`. Content index validation: parse confidence strings, validate entity_refs.
**Blocks:** Tickets B, C, D, E, F, G (all downstream work)
**Effort estimate:** ~120 lines
**Sprint:** 17
---
### Recommended Ticket B: KnowledgeGranted event + `process_knowledge_events` handler
**Scope:** `server/src/knowledge/events.rs` — add `KnowledgeEventType::KnowledgeGranted` variant with `ProcessedKnowledgeGrant` enum (`Fact` and `Entity` subtypes); add match arm handler. `server/src/knowledge/types.rs` — add `TryFrom<&str> for KnowledgeConfidence`. `server/src/simulation/dialogue.rs` — wire `knowledge_grant` field in `process_talk_interaction` after line selection. Runtime NPC KG guardrail: 3-line check in handler.
**Blocks:** Tickets D, F, G; ticket #141 POI discovery wiring
**Effort estimate:** ~150 lines
**Sprint:** 17
---
### Recommended Ticket C: ContradictionClaim struct + detection in `observe_entity`
**Scope:** `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` — add pre-overwrite contradiction check in `observe_entity()`. `server/src/knowledge/events.rs` — add `ContradictionDetected` event variant; add processing (relationship shift for ToldBy source entity). Tests: location contradiction, time window boundary, no false positive for non-ToldBy sources.
**Blocks:** Ticket F (requires ContradictionClaim + event for monologue integration)
**Effort estimate:** ~120 lines + ~40 lines tests
**Sprint:** 17
---
### Recommended Ticket D: NPC-to-NPC knowledge transfer system
**Scope:** New file `server/src/simulation/npc_knowledge_transfer.rs` — `transfer_npc_knowledge` system. Trust-tier mapping; confidence downgrade via `min(source_confidence, KnowsOf)`; `disclosure_blocked` check; ToldBy source construction; 1–3 random fact selection (most-recent-first); player overheard grant at Suspects confidence; system ordering: `after(run_npc_conversations)`.
**Blocks:** Ticket F (ToldBy entries needed for contradiction to fire); Ticket G (NPC KGs need meaningful content before disclosure is useful)
**Effort estimate:** ~160 lines
**Sprint:** 17
---
### Recommended Ticket E: `tell_state.rs` KG awareness (MVP boundary #142)
**Scope:** `server/src/npc/tell_state.rs` — add `Option<&KnowledgeGraph>` to `derive_tell_state` query; replace Friendly-tell `relationships.entries` direct read with `kg.relationship_with(&entity_sid)` for OTHER-entity state; self-axes remain ground-truth reads.
**Blocks:** Nothing (standalone improvement, existing tests continue passing)
**Effort estimate:** ~35 lines
**Sprint:** 17
---
### Recommended Ticket F: Contradiction monologue + event chain completion
**Scope:** `server/src/simulation/monologue.rs` — add `EntityRegistry` + `Query<&NpcName>` to system signature (or read pre-resolved names from event payload); add `resolve_name` helper; add match arm for `ContradictionDetected`. `server/src/knowledge/events.rs` — in `ContradictionDetected` processing, call relationship shift for ToldBy source entity; pre-resolve display names into event struct. Monologue pool (content — Mellanie): generic fallback template line + hand-authored FRIEND-specific lines for Sera/Kael contradiction phases. Integration test: full THE FRIEND arc sequence (Tick T1 grant creates ToldBy → Tick T2 observation triggers ContradictionClaim → monologue fires with correct names → Sera/Kael shift to PersonOfInterest → AnomalyMarker set).
**Blocks:** Nothing (downstream consumers — anomaly.rs, D-033 pipeline — already built and tested)
**Effort estimate:** ~90 lines + content (Mellanie)
**Sprint:** 17
---
### Recommended Ticket G: DisclosureCandidates + unprompted disclosure (#172 + #173)
**Scope:** New file `server/src/npc/disclosure.rs` — `DisclosureCandidates` component; `DisclosureCooldown` component; `derive_disclosure_candidates` system; `process_unprompted_disclosure` system; trait filter predicates (Cautious, Gossipy, Loyal, Talkative); global rate limit resource. `server/src/simulation/dialogue.rs` — Layer 4 reads `DisclosureCandidates` for NPC-initiated line selection. System ordering: `derive_disclosure_candidates.after(process_knowledge_events)`, `process_unprompted_disclosure.after(derive_disclosure_candidates)`.
**Blocks:** Nothing
**Depends on:** Tickets B + D (KG needs meaningful NPC content before disclosure is useful)
**Effort estimate:** ~220 lines
**Sprint:** 17 (implement after Tickets B + D)
---
## THE FRIEND Arc Validation Sequence
**Status:** All systems in steps 3–6 are already implemented and passing tests. Steps 1–2 are new work (Tickets A+B for grant; Ticket C for ContradictionClaim; Ticket D for ToldBy population in NPC KGs).
**Canonical sequence (D-079/D-083 integration test, using entity grant path):**
1. **Tick T1 — Testimony:** Detective talks to Sera → `process_talk_interaction` selects line with `knowledge_grant: { entity_ref: "kael", attributes: { location: "dock-7", shift: "second" }, confidence: "knows_of" }` → `KnowledgeGranted` event pushed → `process_knowledge_events` creates `EntityKnowledge(kael_sid)` in player KG: `source: ToldBy { source_id: sera_sid, tick: T1 }`, `last_known_position: dock_7_pos`, `confidence: KnowsOf`, `state: Active`.
2. **Tick T2 — Observation:** Player LOS includes Kael in corridor B-7 → Perception fires `DirectObservation` → `observe_entity(kael_sid, b7_pos, T2)` runs → pre-overwrite contradiction check: existing entry has `ToldBy` source; `b7_pos ≠ dock_7_pos`; `|T2 - T1| < 600` → `entry.state = Contradicted`; `entry.contradicted_claim = Some(ContradictionClaim { source: ToldBy { sera_sid, T1 }, claimed_position: dock_7_pos, detected_at_tick: T2 })` → entry overwritten with `DirectObservation`.
3. **Tick T2 — ContradictionDetected event pushed:** `{ observer: detective_entity, entity_sid: kael_sid, source_display_name: Some("Sera"), subject_display_name: Some("Kael") }`.
4. **Tick T2/T3 — Downstream cascade (existing systems, already tested):**
- `detect_anomalies` finds `Contradicted` state → `AnomalyMarker` on Kael
- Relationship system: `EntityKnowledge(kael_sid).relationship → PersonOfInterest`; `EntityKnowledge(sera_sid).relationship → PersonOfInterest`
- D-033 pipeline: both Kael and Sera → amber (#e8c547)
- Monologue system: fires contradiction line: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."*
5. **Next player Talk with Sera:** Confrontation option unlocked for Sera and Kael. Sera behaves normally (she does not know the detective has seen Kael in B-7 — her KG has no such entry). The detective holds the contradiction alone until confrontation.
---
## Sprint 17 vs Sprint 18 Scope Summary
### Sprint 17 (this workshop produces)
| Component | Ticket | Lines est. |
|-----------|--------|-----------|
| `KnowledgeGrant` schema + `ContentEntityRegistry` | A | ~120 |
| `KnowledgeGranted` event + handler + dialogue wire | B | ~150 |
| `ContradictionClaim` struct + detection in `observe_entity` | C | ~160 |
| NPC-to-NPC `transfer_npc_knowledge` system | D | ~160 |
| `tell_state.rs` KG awareness | E | ~35 |
| Contradiction monologue + event chain | F | ~90 |
| `DisclosureCandidates` + unprompted disclosure | G | ~220 |
| **Total** | | **~935 lines** |
Q-024, Q-025, Q-026 closures in decisions/questions.md.
### Sprint 18 (committed scope from this workshop)
- Full `EntityGrant { attributes: BTreeMap }` variant (currently only `entity_ref` + position in Sprint 17)
- `Compound` grant variant (facts + entity in one line)
- Conversation partner KG-awareness check (`kg.knows_entity(&partner_sid)` in pairing loop)
- Attribute contradiction via content-authored YAML pairs (typed attribute key conventions)
- Fact contradiction via `contradicts` field on KnowledgeGrant entries
- Template monologue system for named contradictions (generic entity substitution for auto-generated NPCs)
- Specific overheard fact transfer to player (currently: entity-level Suspects grant only)
- Runtime NPC KG decay → disclosure candidate staleness handling
### Deferred (v0.2+)
- Routine KG awareness
- Pathfinding KG integration (not planned — no safe failure recovery)
- "Ask around" emergent information-seeking behavior
- NPC disclosing own KG-contradicted knowledge ("I could have sworn Kael was at the dock...")
---
## Team Lead Decisions
| Decision | Date | Override / Note |
|----------|------|----------------|
| **Entity grants ship in Sprint 17** | 2026-02-24 | Team lead call. Overrides Paula's Round 2 acceptance of Dudley's FactId workaround. Adopts Tyre/Gestalt/Dudley position: compound `KnowledgeGrant` enum with `Entity` variant, `ContentEntityRegistry` at NPC spawn (~0.5 day infrastructure). Paula's conditions (ToldBy in event, Sprint 18 formal commitment) honoured where applicable. See round-2-notes.md §Tension A for full workshop record. |
---
## Open Questions Not Resolved by This Workshop
| ID | Question | Required for | Decision owner |
|----|----------|-------------|---------------|
| — | `CONTRADICTION_WINDOW_TICKS` final value: 600 (majority) vs 1800 (Tyre) | Contradiction detection sensitivity | Can ship 600; playtest adjusts |
---
*Document produced from workshop rounds 1–2. D-records pending registration in decisions/ domain files by Qatux. New tickets A–G pending SI formalization.*
@@ -188,7 +188,7 @@ XREF ERROR: dialogue location "the-warehouse" not in district
```
XREF ERROR: unknown fact_id "investigation.unknown_fact"
In: campaigns/main/.../dialogue/the-terminal/kael-davan.yaml
Line: the-terminal_d_099
Line: kael-davan_d_099
Canonical fact_ids: 42 defined in content/global/knowledge/
```
@@ -228,7 +228,7 @@ XREF WARNING: npc_count mismatch in transit district
#### Check 8: Dialogue line ID uniqueness within pool
**What:** Each dialogue line has an `id` field (e.g., `the-terminal_d_001`). IDs MUST be unique within each dialogue file.
**What:** Each dialogue line has an `id` field (e.g., `kael-davan_d_001`). IDs MUST be unique within each dialogue file.
**Where:** `content/campaigns/**/dialogue/**/*.yaml` → `lines[].id`
@@ -236,7 +236,7 @@ XREF WARNING: npc_count mismatch in transit district
**Error format:**
```
XREF ERROR: duplicate dialogue line id "the-terminal_d_015"
XREF ERROR: duplicate dialogue line id "kael-davan_d_015"
In: campaigns/main/.../dialogue/the-terminal/kael-davan.yaml
First occurrence: line 167
Duplicate: line 215
+111
View File
@@ -0,0 +1,111 @@
# Settled Reach — Sprite Render Pipeline
Offline Godot 4 renderer for the 3D-to-2D sprite pipeline. Produces entity and structural sprites at three resolutions from a fixed camera angle, with outlines applied at working resolution.
## Camera Specification (D-019)
- **Angle**: -72.5° from horizontal (= 17.5° from vertical, the midpoint of the 15-20° from vertical range)
- **Projection**: Orthographic (`size = 1.4`)
- **Position**: `(0, 3, 1)` — above and slightly in front of the model origin
- **Godot Transform3D**: `Transform3D(1, 0, 0, 0, 0.30071, 0.95372, 0, -0.95372, 0.30071, 0, 3, 1)`
This is "the angle" per D-019 amendment (2026-02-12). All entity, object, and wall sprites for v0.1 are rendered at this angle. The Godot gameplay camera is purely orthographic — this tilt is an art convention expressed through the 3D render.
## Lighting Rig
Three-point studio rig. All lights have `shadow_enabled = false` — sprites are shape templates, no baked shadows or directional lighting. The Godot runtime PointLight2D pipeline provides all scene lighting at runtime (D-043).
| Light | Energy | Direction | Purpose |
|-------|--------|-----------|---------|
| KeyLight | 1.0 | Matches camera angle (-72.5° from horizontal) | Main illumination; ensures front face is lit as the camera sees it |
| FillLight | 0.4 | 45° from right side (-45° pitch, +90° yaw) | Fills shadow opposite the key; no deep-black patches on right face |
| RimLight | 0.3 | 45° from behind (-45° pitch, +180° yaw) | Back-edge accent; defines silhouette boundary for outline processing |
**Rationale**: Even 3-light coverage ensures no black patches on a convex mesh, keeping surface colors flat and readable for the outline processing step.
## Resolution Chain (D-043, D-044)
```
1024×1024 — source PNG, full fidelity for outline processing
↓ bilinear resize
256×256 — working PNG, outline applied at 4-8px, color #333340
↓ bilinear resize
64×64 — runtime PNG, deployed to client/assets/sprites/
```
Outline implementation: alpha-mask dilation + flat color fill. Outline width in `render_export.gd`: `outline_width_px = 4` (at 256px = 1px effective at 64px).
## Entity Footprint Spec (D-044, D-066)
- Entity sprites occupy a **24×32px footprint** within the 64×64 visual tile
- Entities render across a **2×2 sim tile sprite footprint** (D-066)
- Visual tile = 64×64px at runtime; sim tile = 32×32px (0.5m)
- **NPC model scale**: `CapsuleMesh(radius=0.25, height=0.62)` at camera size 1.4 produces ~24×32px apparent size in the 64px output tile
## Usage
### Command Line
```bash
# From the repository root
godot --path renderer/ --headless --quit-after 1200 res://render_scene.tscn -- <model_name>
```
The `/sprite-gen` skill wraps this command and handles output placement.
### Adding a New Model
1. Create `renderer/models/<model_name>.tscn` — root node is a `MeshInstance3D` (or `Node3D` with children)
2. Center the mesh at the origin
3. Use a flat `StandardMaterial3D` (no baked shadows — just albedo color + roughness)
4. Run: `godot --path renderer/ --headless --quit-after 1200 res://render_scene.tscn -- <model_name>`
5. Output: 12 PNGs in `renderer/output/` (4 directions × 3 resolutions)
6. Runtime sprites: copy 64px variants to `client/assets/sprites/`
### Material Guidelines
- **Entity sprites**: neutral flat material (albedo Color(0.5, 0.5, 0.5)). D-033 relationship color tinting applied at runtime by the client's entity renderer. Outline color (#333340 per D-043) is compatible with all D-033 tint colors — the dark blue-grey outline remains visible against teal, green, amber, and red entity tints.
- **Structural sprites**: use zone-appropriate texture (`textures/wall_institutional_era1.png`, etc.)
- No specularity: `metallic = 0.0`, `roughness = 0.9`
- No emission, no normal maps — shape is the signal
## Models
| Model | File | Type | Notes |
|-------|------|------|-------|
| `wall_structural` | `models/wall_structural.tscn` | Structure | 1.0×0.8×0.2 box, institutional era-1 texture |
| `wall_bar_green` | `models/wall_bar_green.tscn` | Structure | 1.0×0.8×0.2 box, green panel texture (model only — no sprites rendered yet) |
| `npc_generic` | `models/npc_generic.tscn` | Entity | Capsule silhouette, neutral grey, 24×32px footprint. Rotationally symmetric — north/south and east/west pairs are near-identical by design (asymmetric silhouettes come from named NPC models with identifying features per D-044). |
## Output Naming Convention
```
<model_name>_<direction>_<resolution>.png
wall_structural_north_64.png
npc_generic_south_256.png
```
Directions: `north`, `east`, `south`, `west` (model rotated 0°, 90°, 180°, 270° around Y axis).
## Runtime Sprite Path
64px sprites are deployed to: `client/assets/sprites/`
Naming convention at the client side matches the pipeline output: `<model_name>_<direction>_64.png`. The 1024 and 256 variants stay in `renderer/output/` as pipeline intermediates (gitignored).
## Design Constraints
- **No baked lighting**: sprites are shape templates. Lighting is applied at runtime by Godot's PointLight2D per D-046.
- **No baked shadows**: shadow direction would conflict with runtime point lights at arbitrary positions.
- **No baked mood**: flat materials, three-point neutral rig. Zone atmosphere comes from runtime CanvasModulate.
- **Outline applied at 256px, not 64px**: ensures outline is crisp after bilinear downscale.
- **Transparent background**: `SubViewport.transparent_bg = true` — sprites are PNG with alpha, composited at runtime.
## Cross-References
- D-019: Camera angle spec and amendment
- D-043: Art direction — "functional warmth," resolution chain, outline color
- D-044: Visual hierarchy, entity footprint spec
- D-049: Z-level rendering stack (sprites land on layers 2-3)
- D-066: Dual-scale grid, 2×2 sim tile entity footprint
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=3 format=3 uid="uid://npcgeneric001"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh_npc"]
radius = 0.25
height = 0.62
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_npc"]
albedo_color = Color(0.5, 0.5, 0.5, 1.0)
metallic = 0.0
roughness = 0.9
[node name="NpcGeneric" type="MeshInstance3D"]
mesh = SubResource("CapsuleMesh_npc")
surface_material_override/0 = SubResource("StandardMaterial3D_npc")
+1 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=3 format=3]
[gd_scene load_steps=3 format=3 uid="uid://wallbargreen001"]
[ext_resource type="Texture2D" path="res://textures/wall_bar_green_panels.png" id="1_wall_texture"]
+1 -1
View File
@@ -10,7 +10,7 @@ const ROTATIONS: PackedFloat64Array = [0.0, 90.0, 180.0, 270.0]
@export var model_scene: PackedScene
@export var output_name: String = "wall_structural"
@export var outline_width_px: int = 4 # at 256 = 1px at 64
@export var outline_color: Color = Color(0.2, 0.2, 0.25, 1.0)
@export var outline_color: Color = Color(0.2, 0.2, 0.25, 1.0) # #333340 per D-043
@export var render_now: bool = false:
set(value):
if value and model_scene:
+15 -3
View File
@@ -12,16 +12,28 @@ render_target_update_mode = 1
own_world_3d = true
[node name="Camera3D" type="Camera3D" parent="SubViewport"]
transform = Transform3D(1, 0, 0, 0, 0.309017, 0.951057, 0, -0.951057, 0.309017, 0, 3, 1)
transform = Transform3D(1, 0, 0, 0, 0.30071, 0.95372, 0, -0.95372, 0.30071, 0, 3, 1)
projection = 1
size = 1.4
near = 0.01
far = 10.0
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="SubViewport"]
transform = Transform3D(1, 0, 0, 0, 0.309017, 0.951057, 0, -0.951057, 0.309017, 0, 3, 1)
[node name="KeyLight" type="DirectionalLight3D" parent="SubViewport"]
transform = Transform3D(1, 0, 0, 0, 0.30071, 0.95372, 0, -0.95372, 0.30071, 0, 0, 0)
light_color = Color(1, 1, 1, 1)
light_energy = 1.0
shadow_enabled = false
[node name="FillLight" type="DirectionalLight3D" parent="SubViewport"]
transform = Transform3D(0, -0.70711, 0.70711, 0, 0.70711, 0.70711, -1, 0, 0, 0, 0, 0)
light_color = Color(1, 1, 1, 1)
light_energy = 0.4
shadow_enabled = false
[node name="RimLight" type="DirectionalLight3D" parent="SubViewport"]
transform = Transform3D(-1, 0, 0, 0, 0.70711, 0.70711, 0, 0.70711, -0.70711, 0, 0, 0)
light_color = Color(1, 1, 1, 1)
light_energy = 0.3
shadow_enabled = false
[node name="ModelRoot" type="Node3D" parent="SubViewport"]
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.14"
version = "0.1.15"
dependencies = [
"bevy_app",
"bevy_ecs",
+3 -8
View File
@@ -178,13 +178,6 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::sound::collect_sound_events)
.after(crate::simulation::conversation::run_npc_conversations)
.after(crate::simulation::dialogue::process_walk_away),
crate::simulation::dialogue::process_talk_interaction
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_walk_away
.after(crate::simulation::input::process_player_input)
.after(crate::simulation::dialogue::process_talk_interaction),
crate::simulation::dialogue::process_confrontation_response
.after(crate::simulation::input::process_player_input),
crate::simulation::follow::update_follow_state
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::movement::validate_movement)
@@ -195,9 +188,11 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::monologue::trigger_event_monologue)
.after(crate::simulation::dialogue::process_talk_interaction)
.after(crate::simulation::dialogue::process_confrontation_response)
.after(crate::simulation::dialogue::process_dialogue_response)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
.after(crate::perception::observer::compute_observer_snapshot)
.before(crate::simulation::time::advance_tick),
send_bridge_snapshot
.after(crate::perception::observer::compute_observer_snapshot),
),
+7
View File
@@ -377,6 +377,13 @@ pub enum PlayerAction {
/// Clears dialogue, monologue, and interaction buffers.
/// Rejected with a log warning on non-Gauntlet maps.
TeleportToHub,
/// Player chose a dialogue option (#539, D-028 follow-up).
/// response_id is the line_id that was displayed; target_entity_id is the NPC's wire ID.
/// Server runs the same 4-layer pipeline to select a follow-up line.
DialogueResponse {
target_entity_id: u64,
response_id: String,
},
}
impl PlayerAction {
+12
View File
@@ -27,6 +27,7 @@ impl Plugin for NpcPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<relationships::RelationshipGraph>()
.init_resource::<relationships::TrustEventQueue>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.init_resource::<routine::PreviousDayPhase>()
.init_resource::<tolerance::ToleranceBreachEventQueue>()
.init_resource::<routine::RoutineDeviationEventQueue>()
@@ -42,6 +43,7 @@ impl Plugin for NpcPlugin {
.after(crate::simulation::dialogue::process_talk_interaction)
.after(crate::simulation::dialogue::process_walk_away)
.after(crate::simulation::dialogue::process_confrontation_response)
.after(crate::simulation::dialogue::process_dialogue_response)
.before(crate::simulation::time::advance_tick),
relationships::update_relationship_dynamics
.after(relationships::update_trust)
@@ -59,6 +61,16 @@ impl Plugin for NpcPlugin {
.after(mood::update_mood)
.after(routine::detect_routine_deviation)
.before(crate::perception::observer::compute_observer_snapshot),
crate::simulation::dialogue::process_talk_interaction
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_walk_away
.after(crate::simulation::input::process_player_input)
.after(crate::simulation::dialogue::process_talk_interaction),
crate::simulation::dialogue::process_confrontation_response
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_dialogue_response
.after(crate::simulation::input::process_player_input)
.after(crate::simulation::dialogue::process_talk_interaction),
),
);
+740 -62
View File
@@ -79,6 +79,11 @@ impl Default for CurrentMood {
///
/// Prevents the same line from being selected within LINE_COOLDOWN_TICKS.
/// Entries older than the cooldown window are pruned each query.
///
/// Design note: this is player-global, not per-NPC. Line IDs are NPC-scoped
/// per D-035 (`{template}_{d|m|e}_{###}`), so cross-NPC collisions don't occur
/// in practice. If a future sprint introduces shared line IDs across roles,
/// the key should become `(StableId, line_id)` instead.
#[derive(Component, Debug, Default)]
pub struct DialogueCooldownTracker {
used: std::collections::BTreeMap<String, u64>, // line_id → tick_used (D-041)
@@ -116,6 +121,16 @@ pub struct ActiveDialogue {
pub started_tick: u64,
}
/// Marker: player submitted a dialogue response this tick (#539).
///
/// Set by process_player_input when PlayerAction::DialogueResponse is received.
/// Consumed and removed by process_dialogue_response each tick.
#[derive(Component, Debug)]
pub struct DialogueResponseRequest {
pub target: Entity,
pub response_id: String,
}
/// Marker: player walked away during active dialogue this tick (D-064).
///
/// Set by process_player_input when PlayerAction::WalkAway is received.
@@ -311,11 +326,9 @@ pub fn select_dialogue_line<'a>(
return None;
}
// Weighted random selection
// Weighted random selection — score_line always returns >= 1 (base score),
// so total_weight > 0 is guaranteed when scored is non-empty.
let total_weight: u32 = scored.iter().map(|(_, s)| s).sum();
if total_weight == 0 {
return None;
}
let mut roll = rng.random_range(0..total_weight);
for (line, weight) in &scored {
@@ -325,8 +338,70 @@ pub fn select_dialogue_line<'a>(
roll -= weight;
}
// Fallback (shouldn't reach here with valid weights)
Some(scored.last().unwrap().0)
unreachable!("weighted selection with total_weight > 0 must select a line")
}
// ---------------------------------------------------------------------------
// Shared pipeline: Layers 1-4
// ---------------------------------------------------------------------------
/// Run the full D-028 four-layer dialogue pipeline and return a selected line.
///
/// Shared by `process_talk_interaction` and `process_dialogue_response` to
/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the
/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it).
fn run_dialogue_pipeline<'a>(
line_pool: &'a crate::content::line_pool::LinePoolIndex,
location: &str,
role: &str,
relationship: RelationshipState,
confidence: crate::knowledge::types::KnowledgeConfidence,
day_phase: crate::simulation::time::DayPhase,
interaction_mem: Option<&InteractionMemory>,
npc_mood: Option<Mood>,
cooldown: &DialogueCooldownTracker,
tick: u64,
rng: &mut impl Rng,
) -> Option<&'a IndexedDialogueLine> {
// Layer 1: Access tiers from relationship
let access_tiers = available_access_tiers(relationship);
// Layer 2: Derive active situations from game state
let mut situations = derive_situations(day_phase, relationship);
// Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028)
if let Some(mem) = interaction_mem {
if mem.is_first_meeting() {
situations.push(Situation::FirstMeeting);
} else if mem.is_repeated_visit() {
situations.push(Situation::RepeatedVisit);
}
}
// Layer 3: Trust tier from relationship + confidence (D-075)
let trust = relationship_to_trust(relationship, confidence);
// Query Layers 1-3: collect candidates across all available access tiers
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
for access in &access_tiers {
let results = line_pool.query_dialogue(location, role, *access, &situations, trust);
for line in results {
if seen_ids.insert(&line.id) {
candidates.push(line);
}
}
}
if candidates.is_empty() {
return None;
}
// Layer 4: Topic + mood weighted selection
let active_topics: Vec<Topic> = Vec::new(); // v0.1: no topic context yet
select_dialogue_line(&candidates, npc_mood, &active_topics, cooldown, tick, rng)
}
// ---------------------------------------------------------------------------
@@ -405,73 +480,26 @@ pub fn process_talk_interaction(
.map(|sid| observer_kg.relationship_with(&sid))
.unwrap_or(RelationshipState::Unknown);
// Layer 1: Access tiers from relationship
let access_tiers = available_access_tiers(relationship);
// Layer 2: Derive active situations from game state
let mut situations = derive_situations(time.day_phase(), relationship);
// Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028)
if let Some(ref mem) = interaction_mem_opt {
if mem.is_first_meeting() {
situations.push(Situation::FirstMeeting);
} else if mem.is_repeated_visit() {
situations.push(Situation::RepeatedVisit);
}
}
// Layer 3: Trust tier from relationship + confidence (D-075)
// Default to Suspects for unknown NPCs — no KG entry means no basis for
// deeper dialogue, which correctly yields Surface trust tier.
let confidence = target_stable
.and_then(|sid| observer_kg.confidence_of(&sid))
.unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects);
let trust = relationship_to_trust(relationship, confidence);
// Query Layers 1-3: collect candidates across all available access tiers
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
for access in &access_tiers {
let results = line_pool.0.query_dialogue(
&profile.location,
&profile.role,
*access,
&situations,
trust,
);
for line in results {
// Deduplicate across access tiers (BTreeSet for deterministic iteration)
if seen_ids.insert(&line.id) {
candidates.push(line);
}
}
}
if candidates.is_empty() {
tracing::debug!(
"No dialogue lines available for {}/{} (access={:?}, situations={:?}, trust={:?})",
profile.location,
profile.role,
access_tiers,
situations,
trust,
);
commands.entity(player_entity).remove::<TalkRequest>();
return;
}
// Layer 4: Topic + mood weighted selection
let npc_mood = mood_opt.map(|m| m.0);
let active_topics: Vec<Topic> = Vec::new(); // v0.1: no topic context yet
// Prune old cooldown entries
cooldown.prune(time.tick);
let selected = select_dialogue_line(
&candidates,
let selected = run_dialogue_pipeline(
&line_pool.0,
&profile.location,
&profile.role,
relationship,
confidence,
time.day_phase(),
interaction_mem_opt.as_deref(),
npc_mood,
&active_topics,
&cooldown,
time.tick,
&mut rng.rng,
@@ -658,7 +686,7 @@ pub fn process_walk_away(
/// Hardcoded confrontation monologue lines (D-063).
/// Fired as a monologue spike when the player delivers a confrontation.
/// Future: move to content pools with trigger="confrontation_delivered".
/// TODO: move to content pools with trigger="confrontation_delivered" (D-028/D-035).
const CONFRONTATION_LINES: &[(&str, &str)] = &[
(
"confront_01",
@@ -767,6 +795,185 @@ pub fn process_confrontation_response(
.remove::<ConfrontationDelivered>();
}
// ---------------------------------------------------------------------------
// System: process_dialogue_response (#539)
// ---------------------------------------------------------------------------
/// Process DialogueResponse actions through the full D-028 four-layer pipeline (#539).
///
/// Called when the player picks a dialogue option. Re-runs the same pipeline as
/// process_talk_interaction to select a follow-up line. Clears ActiveDialogue if
/// no candidates remain after cooldown filtering (conversation ends naturally).
///
/// The response_id is the line_id that was shown; it's already on cooldown from
/// process_talk_interaction, ensuring the follow-up is a different line.
///
/// System ordering: after process_player_input, after process_talk_interaction,
/// before compute_observer_snapshot.
#[tracing::instrument(level = "debug", skip_all)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn process_dialogue_response(
mut commands: Commands,
time: Res<SimulationTime>,
line_pool: Option<Res<LinePoolIndexResource>>,
registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut trust_queue: ResMut<TrustEventQueue>,
mut player_query: Query<
(
Entity,
&KnowledgeGraph,
&DialogueResponseRequest,
&mut DialogueResponseBuffer,
&mut DialogueCooldownTracker,
Option<&ActiveDialogue>,
),
With<PlayerCharacter>,
>,
mut npc_query: Query<(
&DialogueProfile,
Option<&CurrentMood>,
Option<&mut InteractionMemory>,
Option<&NpcName>,
Option<&NpcColorIndex>,
)>,
) {
let Ok((
player_entity,
observer_kg,
response_req,
mut response_buffer,
mut cooldown,
active_dialogue_opt,
)) = player_query.single_mut()
else {
return;
};
let target = response_req.target;
let response_id = response_req.response_id.clone();
// Always remove the marker regardless of outcome — request is consumed this tick.
commands
.entity(player_entity)
.remove::<DialogueResponseRequest>();
let Some(line_pool) = line_pool else { return };
// Look up NPC dialogue profile, mood, interaction history, name, and color
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) =
npc_query.get_mut(target)
else {
tracing::debug!(
"DialogueResponse target {:?} has no DialogueProfile — cannot select follow-up",
target
);
return;
};
// Resolve target's StableId for KG lookup
let target_stable = registry.to_stable(target);
let relationship = target_stable
.map(|sid| observer_kg.relationship_with(&sid))
.unwrap_or(RelationshipState::Unknown);
let confidence = target_stable
.and_then(|sid| observer_kg.confidence_of(&sid))
.unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects);
let npc_mood = mood_opt.map(|m| m.0);
// Prune old cooldown entries
cooldown.prune(time.tick);
let selected = run_dialogue_pipeline(
&line_pool.0,
&profile.location,
&profile.role,
relationship,
confidence,
time.day_phase(),
interaction_mem_opt.as_deref(),
npc_mood,
&cooldown,
time.tick,
&mut rng.rng,
);
if let Some(line) = selected {
let Some(speaker_stable) = registry.to_stable(target) else {
tracing::warn!(
"DialogueResponse target {:?} not in EntityRegistry — skipping follow-up",
target
);
return;
};
let speaker_display_name = {
let known = observer_kg
.entity_knowledge(&speaker_stable)
.map(|e| e.known_attributes.contains_key("name"))
.unwrap_or(false);
if known {
npc_name_opt
.map(|n| n.0.clone())
.unwrap_or_else(|| "Unknown".to_string())
} else {
display_label_for_role(&profile.role)
}
};
let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8);
response_buffer.response = Some(DialogueResponseEvent {
line_id: line.id.clone(),
text: line.text.clone(),
speaker_entity_id: speaker_stable.0,
speaker_color_index: speaker_color,
speaker_name: speaker_display_name,
});
cooldown.record(&line.id, time.tick);
// Update ActiveDialogue with current tick — prevents stale started_tick
commands.entity(player_entity).insert(ActiveDialogue {
target,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: time.tick,
});
// Trust progression: follow-up dialogue warms the NPC
trust_queue.push(TrustEvent::TalkCompleted {
npc: target,
player: player_entity,
});
// Interaction tracking: record follow-up as a talk event
if let Some(ref mut mem) = interaction_mem_opt {
mem.record_talk(time.tick);
}
tracing::debug!(
"Follow-up selected: id={}, response_id={}, location={}, role={}",
line.id,
response_id,
profile.location,
profile.role,
);
} else {
// No follow-up lines — conversation ends naturally (D-062: invisible locks)
tracing::debug!(
"No follow-up lines for response_id={} at {}/{} — ending conversation",
response_id,
profile.location,
profile.role,
);
// Clear active dialogue state
if active_dialogue_opt.is_some() {
commands.entity(player_entity).remove::<ActiveDialogue>();
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1787,6 +1994,304 @@ mod tests {
use rand::SeedableRng;
// -- Trust-gated gossip tests (#171, D-075) ----------------------------------
/// Build a pool with a Surface-tier Public line and a Secret-tier Insider line.
/// Used to verify that KnowledgeConfidence gates Secret access correctly.
fn build_trust_tier_pool() -> LinePoolIndex {
let mut index = LinePoolIndex::default();
let pool = IndexedDialoguePool {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
lines: vec![
IndexedDialogueLine {
id: "trust_surface_001".to_string(),
text: "Just another day at the terminal.".to_string(),
role: "dock-worker".to_string(),
access: vec![AccessTier::Public],
trust: TrustTier::Surface,
situation: vec![Situation::Routine],
topic: vec![],
mood: vec![],
tags: vec![],
knowledge_grant: None,
},
IndexedDialogueLine {
id: "trust_secret_001".to_string(),
text: "The manifests don't match. You didn't hear that from me.".to_string(),
role: "dock-worker".to_string(),
access: vec![AccessTier::Insider], // requires Friendly relationship
trust: TrustTier::Secret, // requires Friendly + KnowsDetails+
situation: vec![Situation::Routine],
topic: vec![],
mood: vec![],
tags: vec![],
knowledge_grant: None,
},
],
};
index.dialogue.insert(
("the-terminal".to_string(), "dock-worker".to_string()),
pool,
);
index
}
#[test]
fn trust_gated_knows_details_can_get_secret_tier_line() {
// D-075: Friendly + KnowsDetails → Secret trust tier → secret lines available.
// Spec ref: #171, D-075 "Secret: Friendly + KnowsDetails+"
use crate::knowledge::types::KnowledgeConfidence;
let mut world = setup_dialogue_world();
world.insert_resource(LinePoolIndexResource(build_trust_tier_pool()));
let npc = world
.spawn((
Npc,
TilePosition::new(5, 5, 0),
DialogueProfile {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
// observe_entity → Direct; observe_entity_leaving_los → KnowsDetails
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0);
kg.observe_entity_leaving_los(&npc_sid, 1);
kg.set_relationship(&npc_sid, RelationshipState::Friendly);
assert_eq!(
kg.confidence_of(&npc_sid),
Some(KnowledgeConfidence::KnowsDetails),
"precondition: KG must have KnowsDetails confidence"
);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
kg,
TalkRequest { target: npc },
DialogueResponseBuffer::default(),
DialogueCooldownTracker::default(),
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
// Run with multiple seeds — Secret-tier line must appear at least once
let mut saw_secret_line = false;
for seed in 0u64..50 {
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
// Reset cooldown so the pool is not exhausted between iterations
world.entity_mut(player).insert(DialogueCooldownTracker::default());
world.insert_resource(SimRng::new(seed));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_talk_interaction);
schedule.run(&mut world);
world.flush();
if let Some(resp) = &world
.get::<DialogueResponseBuffer>(player)
.unwrap()
.response
{
if resp.line_id == "trust_secret_001" {
saw_secret_line = true;
break;
}
}
}
assert!(
saw_secret_line,
"Friendly + KnowsDetails player should be able to access Secret-tier lines (D-075 #171)"
);
}
#[test]
fn trust_gated_suspects_only_gets_surface_tier() {
// D-075: Friendly + Suspects → Surface trust tier → Secret lines invisible.
// Spec ref: #171, D-075 "Surface: any relationship + any confidence"
use crate::knowledge::types::KnowledgeConfidence;
let mut world = setup_dialogue_world();
world.insert_resource(LinePoolIndexResource(build_trust_tier_pool()));
let npc = world
.spawn((
Npc,
TilePosition::new(5, 5, 0),
DialogueProfile {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
// Friendly relationship but Suspects confidence → Surface trust only
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0);
kg.set_relationship(&npc_sid, RelationshipState::Friendly);
// Patch down to Suspects (observe_entity sets Direct — too high)
kg.entities.get_mut(&npc_sid).unwrap().confidence = KnowledgeConfidence::Suspects;
assert_eq!(
kg.confidence_of(&npc_sid),
Some(KnowledgeConfidence::Suspects),
"precondition: KG must have Suspects confidence"
);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
kg,
TalkRequest { target: npc },
DialogueResponseBuffer::default(),
DialogueCooldownTracker::default(),
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut saw_secret = false;
for seed in 0u64..50 {
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
world.entity_mut(player).insert(DialogueCooldownTracker::default());
world.insert_resource(SimRng::new(seed));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_talk_interaction);
schedule.run(&mut world);
world.flush();
if let Some(resp) = &world
.get::<DialogueResponseBuffer>(player)
.unwrap()
.response
{
if resp.line_id == "trust_secret_001" {
saw_secret = true;
break;
}
}
}
assert!(
!saw_secret,
"Friendly + Suspects player must NOT access Secret-tier lines (D-075 #171)"
);
}
// -- Line variety regression test (#338, D-028) ------------------------------
/// Pool with 12 distinct Public/Surface/Routine lines for variety testing.
fn build_variety_pool() -> LinePoolIndex {
let mut index = LinePoolIndex::default();
let lines: Vec<IndexedDialogueLine> = (1u32..=12)
.map(|n| IndexedDialogueLine {
id: format!("variety_{:03}", n),
text: format!("Line number {}.", n),
role: "dock-worker".to_string(),
access: vec![AccessTier::Public],
trust: TrustTier::Surface,
situation: vec![Situation::Routine],
topic: vec![],
mood: vec![],
tags: vec![],
knowledge_grant: None,
})
.collect();
let pool = IndexedDialoguePool {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
lines,
};
index.dialogue.insert(
("the-terminal".to_string(), "dock-worker".to_string()),
pool,
);
index
}
#[test]
fn line_variety_no_repeats_within_cooldown_window() {
// #338, D-028: No line_id should repeat within LINE_COOLDOWN_TICKS.
// Regression: Talk 10 times at tick 0 (well within the 600-tick window).
// Each selected line must be distinct — cooldown tracker enforces this.
let mut world = setup_dialogue_world();
world.insert_resource(LinePoolIndexResource(build_variety_pool()));
let npc = world
.spawn((
Npc,
TilePosition::new(5, 5, 0),
DialogueProfile {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
// Unknown player — Public access only; tick stays at 0 throughout
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
TalkRequest { target: npc },
DialogueResponseBuffer::default(),
DialogueCooldownTracker::default(),
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut seen_ids: Vec<String> = Vec::new();
for seed in 0u64..10 {
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
// NOTE: SimulationTime is NOT advanced — all 10 talks happen within tick 0
world.insert_resource(SimRng::new(seed));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_talk_interaction);
schedule.run(&mut world);
world.flush();
if let Some(resp) = &world
.get::<DialogueResponseBuffer>(player)
.unwrap()
.response
{
let id = resp.line_id.clone();
assert!(
!seen_ids.contains(&id),
"Line '{}' was repeated within the {}-tick cooldown window (iteration {}). \
Cooldown tracker must prevent repeats. (#338)",
id,
LINE_COOLDOWN_TICKS,
seed,
);
seen_ids.push(id);
}
}
assert_eq!(
seen_ids.len(),
10,
"Should have selected 10 distinct lines across 10 consecutive Talks (#338)"
);
}
// === Confrontation Response Tests (#520, D-063) ===
#[test]
@@ -1957,4 +2462,177 @@ mod tests {
"Marker should be removed after processing"
);
}
// === DialogueResponse Tests (#539) ===
#[test]
fn process_dialogue_response_selects_follow_up_line() {
let mut world = setup_dialogue_world();
let index = build_test_line_pool();
world.insert_resource(LinePoolIndexResource(index));
let npc = world
.spawn((
Npc,
TilePosition::new(5, 5, 0),
DialogueProfile {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
CurrentMood(Mood::Content),
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
// Simulate that first line was already selected (on cooldown)
DialogueResponseRequest {
target: npc,
response_id: "test_d_001".to_string(),
},
DialogueResponseBuffer::default(),
DialogueCooldownTracker::default(),
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_dialogue_response);
schedule.run(&mut world);
world.flush();
// Should have consumed the marker
assert!(
world.get::<DialogueResponseRequest>(player).is_none(),
"DialogueResponseRequest should be consumed"
);
}
#[test]
fn process_dialogue_response_clears_active_dialogue_when_no_lines() {
let mut world = setup_dialogue_world();
// Build pool with only one line — it will be on cooldown
let mut index = LinePoolIndex::default();
let pool = IndexedDialoguePool {
location: "test".to_string(),
role: "worker".to_string(),
lines: vec![IndexedDialogueLine {
id: "only_line".to_string(),
text: "Only thing I can say.".to_string(),
role: "worker".to_string(),
access: vec![AccessTier::Public],
trust: TrustTier::Surface,
situation: vec![Situation::Routine],
topic: vec![],
mood: vec![],
tags: vec![],
knowledge_grant: None,
}],
};
index
.dialogue
.insert(("test".to_string(), "worker".to_string()), pool);
world.insert_resource(LinePoolIndexResource(index));
let npc = world
.spawn((
Npc,
TilePosition::new(5, 5, 0),
DialogueProfile {
location: "test".to_string(),
role: "worker".to_string(),
},
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
// Set the only line on cooldown — so no follow-up can be selected
let mut cooldown = DialogueCooldownTracker::default();
cooldown.record("only_line", 0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
DialogueResponseRequest {
target: npc,
response_id: "only_line".to_string(),
},
DialogueResponseBuffer::default(),
cooldown,
ActiveDialogue {
target: npc,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: 0,
},
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_dialogue_response);
schedule.run(&mut world);
world.flush();
// No follow-up lines — ActiveDialogue should be cleared
assert!(
world.get::<ActiveDialogue>(player).is_none(),
"ActiveDialogue should be cleared when no follow-up lines available"
);
assert!(
world.get::<DialogueResponseRequest>(player).is_none(),
"DialogueResponseRequest should be consumed"
);
// Buffer should remain empty
let buffer = world.get::<DialogueResponseBuffer>(player).unwrap();
assert!(
buffer.response.is_none(),
"No response when all lines on cooldown"
);
}
#[test]
fn process_dialogue_response_no_profile_is_noop() {
let mut world = setup_dialogue_world();
// NPC without DialogueProfile
let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id();
world.resource_mut::<EntityRegistry>().register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
DialogueResponseRequest {
target: npc,
response_id: "some_line".to_string(),
},
DialogueResponseBuffer::default(),
DialogueCooldownTracker::default(),
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_dialogue_response);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<DialogueResponseRequest>(player).is_none(),
"DialogueResponseRequest consumed even with no profile"
);
let buffer = world.get::<DialogueResponseBuffer>(player).unwrap();
assert!(
buffer.response.is_none(),
"No response for NPC without DialogueProfile"
);
}
}
+74 -1
View File
@@ -280,6 +280,19 @@ pub fn process_player_input(
PlayerAction::TeleportToHub => {
handle_teleport_to_hub(&mut player_query, &mut commands);
}
PlayerAction::DialogueResponse {
target_entity_id,
ref response_id,
} => {
handle_dialogue_response(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
response_id,
);
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
@@ -458,6 +471,65 @@ fn handle_talk(
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
}
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
///
/// Range check: same CLOSE_RANGE as Talk/Confront (D-010 info boundary). The player
/// must still be near the NPC to continue a conversation — walking away mid-dialogue
/// should not allow remote follow-ups.
#[allow(clippy::type_complexity)]
fn handle_dialogue_response(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: u64,
response_id: &str,
) {
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_entity_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_entity_id, "DialogueResponse: target entity not in registry");
return;
};
// Server-side range check: reject DialogueResponse if target moved out of range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_entity_id,
distance,
"DialogueResponse: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::dialogue::DialogueResponseRequest {
target: target_entity,
response_id: response_id.to_string(),
});
tracing::debug!(target_entity_id, response_id, "DialogueResponse: marker set on player");
}
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
/// The confrontation response system runs in process_confrontation_response (dialogue.rs).
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
@@ -741,7 +813,8 @@ fn handle_teleport_to_hub(
.remove::<crate::simulation::dialogue::TalkRequest>()
.remove::<crate::simulation::dialogue::ActiveDialogue>()
.remove::<crate::simulation::dialogue::WalkAwayRequest>()
.remove::<crate::simulation::dialogue::ConfrontationDelivered>();
.remove::<crate::simulation::dialogue::ConfrontationDelivered>()
.remove::<crate::simulation::dialogue::DialogueResponseRequest>();
tracing::info!(
x = hub_spawn.x,

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