Merge remote-tracking branch 'origin/main' into client

# Conflicts:
#	client/data/dialogue-theme.yaml
#	client/scripts/main.gd
#	client/scripts/protocol/protocol.gd
#	client/scripts/ui/debug_overlay.gd
#	client/ui/dialogue_box.gd
This commit is contained in:
2026-02-21 13:48:08 +01:00
103 changed files with 10954 additions and 179 deletions
+231
View File
@@ -0,0 +1,231 @@
---
name: bug-report
description: >
Process in-game bug reports captured by the Godot client's bug reporter.
Use when the user says "bug reports", "check bug reports", "process bugs",
or invokes /bug-report. Scans the user:// bug-reports directory, summarizes
each report, and offers investigation, ticket creation, or dismissal.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Write
---
# Bug Report Skill
Process in-game bug reports exported by the Godot client to the user data
directory. Each report is a directory containing a snapshot of game state at the
moment the tester filed the report.
```
BUG_REPORT_DIR: /var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/
```
## Report structure
Each report lives in a directory named `gauntlet-t{tick}-{timestamp}/` and
contains these files:
| File | Purpose |
|------|---------|
| `description.txt` | Tester notes + metadata (tick, room, stance, facing, position) |
| `render.txt` | Simplified text render of the game snapshot |
| `snapshot.json` | Full JSON snapshot (entities, dialogue state, etc.) |
| `inputs.jsonl` | Last 60 ticks of player input (replay format) |
| `snapshots.jsonl` | Last 60 ticks of observer snapshots |
| `seed.txt` | RNG seed for deterministic replay |
## Invocation
- `/bug-report` — scan and process all unprocessed reports
- `/bug-report <directory-name>` — process a specific report by directory name
## Workflow
### 1. Scan for unprocessed reports
List all report directories in the bug reports directory:
```bash
ls -1d "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/"*/
```
If no directories are found, report "No bug reports found." and stop.
If the user provided a specific directory name as argument, filter to only that
directory. If it does not exist, report the error and list available reports.
### 2. Read and summarize each report
For each report directory, read the following files using the Read tool:
1. **`description.txt`** — extract:
- Tester description / notes (free text at top)
- Tick number
- Room name
- Player stance, facing, position
2. **`render.txt`** — extract:
- A brief description of what the text render shows (room layout, visible
entities, player position marker)
3. **`snapshot.json`** — extract:
- Total entity count
- Whether dialogue is active (look for `dialogue` or `conversation` keys
with non-null/non-empty values)
- Whether monologue is active (look for `monologue` keys with non-null/
non-empty values)
- NPC names and positions if present
- Any error or anomaly fields
4. **`seed.txt`** — note the seed value for reference
Do NOT read `inputs.jsonl` or `snapshots.jsonl` during the summary phase.
These are large files reserved for the investigation step.
### 3. Present the summary list
Present a numbered list of all reports with their summaries. Format:
```
## Bug Reports Found: N
### 1. gauntlet-t{tick}-{timestamp}
- **Tick:** {tick} | **Room:** {room} | **Position:** ({x}, {y})
- **Stance:** {stance} | **Facing:** {facing}
- **Entities:** {count} | **Dialogue active:** yes/no | **Monologue active:** yes/no
- **Seed:** {seed}
- **Description:** {tester notes, first 2-3 lines}
- **Render overview:** {brief description of what render.txt shows}
- **Observations:** {any anomalies spotted in the snapshot}
### 2. gauntlet-t{tick}-{timestamp}
...
```
### 4. Offer actions per report
After presenting the summary list, ask the user which action to take for each
report. The three actions are:
#### Investigate
Dig deeper into the report for root cause analysis:
1. Read `snapshot.json` in full — analyze entity states, component values,
relationships between entities, any inconsistencies
2. Read `inputs.jsonl` — reconstruct what the player was doing in the 60 ticks
leading up to the report. Look for:
- Rapid input changes (stuck keys, input spam)
- Movement into walls or invalid positions
- Interaction attempts that may have failed
- Timing patterns (actions on same tick as state changes)
3. Read `snapshots.jsonl` — compare entity states across recent ticks to find
when the bug manifested:
- Entity position jumps
- State machine transitions that look wrong
- Component values going out of expected range
- Entities appearing or disappearing unexpectedly
4. Cross-reference with `render.txt` to confirm visual manifestation
5. Read `seed.txt` and note it — the seed plus `inputs.jsonl` should allow
deterministic replay of the scenario
Present findings as a root cause analysis:
```
## Investigation: gauntlet-t{tick}-{timestamp}
### Timeline
- t{tick-N}: {what happened}
- t{tick-M}: {state change}
- t{tick}: {bug manifests}
### Root cause
{Analysis of what went wrong and why}
### Affected systems
- {system 1}: {how it's involved}
- {system 2}: {how it's involved}
### Reproduction
Seed: {seed}
Replay inputs.jsonl from tick {start} to reproduce.
### Suggested fix
{If identifiable from the snapshot data}
```
After investigation, return to the action prompt for this report (the user
may want to create a ticket or dismiss after investigating).
#### Create ticket
Create a bug ticket in the project database. Determine the team from the
nature of the bug:
- **server** — simulation bugs (entity state, movement, AI, ECS systems,
perception, knowledge graph)
- **client** — rendering bugs (display glitches, UI issues, input handling,
audio, visual artifacts)
- **server,client** — integration bugs (protocol mismatch, desync, bridge
issues)
Construct the ticket title and description from the report summary and any
investigation findings. Use the ticket CLI:
```bash
db/connectors/ticket create bug "{title}" --team {team} --description "{description}"
```
The description should include:
- Bug summary (from tester notes)
- Tick, room, position
- Key observations from snapshot analysis
- Seed for reproduction
- Report directory name for reference
After creating the ticket, report the ticket ID to the user.
#### Dismiss
Mark the report as not actionable. Remove the report directory:
```bash
rm -rf "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/{report-dir}/"
```
**Always confirm with the user before deleting.** State clearly which directory
will be removed and wait for confirmation.
### 5. Batch processing
When processing multiple reports, work through them one at a time in the
numbered order presented. For each report, complete the chosen action before
moving to the next.
If the user wants to batch-dismiss multiple reports, confirm the full list
of directories that will be deleted before proceeding.
### 6. Final summary
After all reports have been processed, present a summary:
```
## Bug Report Processing Complete
- **Investigated:** {count}
- **Tickets created:** {count} ({ticket IDs})
- **Dismissed:** {count}
- **Remaining unprocessed:** {count}
```
## Tips
- Large `snapshot.json` files may need to be read with offset/limit parameters.
Start with the first 200 lines to get the structure, then target specific
sections.
- `inputs.jsonl` and `snapshots.jsonl` are newline-delimited JSON. Each line
is one tick. Read the last 10-20 lines first to focus on the moments before
the report was filed.
- The `render.txt` is a text-art representation of the game view. Entity
positions in the render should match positions in the snapshot. Mismatches
are themselves a bug signal (rendering vs simulation desync).
- The seed in `seed.txt` combined with `inputs.jsonl` enables deterministic
replay on the server. Note this in any ticket you create.
- If the bug-reports directory does not exist, the tester has not yet run any
gauntlet sessions or has not filed any reports. This is not an error.
+37
View File
@@ -6,6 +6,43 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.14] — 2026-02-21
### Added
- Unified dialogue log — player-NPC and overheard NPC-NPC conversations in one chronological scrolling panel (#535, D-061/D-078)
- F3 debug overlay — real-time game state display with tick, FPS, position, entity counts, dialogue/monologue status (#511)
- Monologue display — multi-line priority queue with character colours, italic BBCode, stagger animation (#122)
- Protocol v9 — conversation_events, conversation_ended, dialogue_response fields with carry-forward logic
- Dialogue theme system — configurable NPC name colour palette, entry timing, passive opacity via dialogue-theme.yaml
- Monologue display system visual spec — typography, positioning, stacking, priority, fade animation, character color differentiation, 80-char line constraint (#315)
- Entity color system spec — D-033 relationship-to-player mapping, transition animations, color blindness assessment (#304)
- Text display hierarchy spec — 4 content pipelines (dialogue, monologue, observation, environmental) with z-layers and positioning (#316)
- Sound indicator visual design — fog-edge pulse for D-018 three-range sound model with direction encoding and range differentiation (#317)
- THE FRIEND visual treatment spec — 3-phase earned visual detail for Kael Davan and Sera Venn (#318)
- Environmental text visual standards — signage, terminal, and news ticker rendering with bilingual Concordat/Krenn treatment (#334)
- Tell visual/behavioral expression spec — 5 tell categories mapped to 6 Tier 2 behaviors (#251)
- Monologue line pool maxLength raised from 160 to 256 chars (soft guidance ≤160)
- NPC name masking infrastructure — entity-anchored dialogue log with server-side role labels, retroactive name update on learning, NpcColorIndex for stable color assignment
- Dialogue option keyboard selection (1/2/3 number keys) and numbered option labels
- Interaction list chrome — background panel, mouse hover highlighting, click-to-interact, pointing hand cursor
### Changed
- D-061 updated to document unified conversation log architecture from Sprint 14
- Dialogue options switched from RichTextLabel to Label for reliable VBoxContainer sizing
### Fixed
- Visual grammar dialogue max-width corrected from "~70% screen width" to 640px per D-076
- BBCode injection in dialogue log formatting — server-sourced strings now escaped with [lb]
- Per-frame dialogue log rebuild replaced with dirty flag (performance)
- dialogue_active lifecycle — now cleared after panel fade completes per D-064
- PAUSE/UNPAUSE routed through main.gd input recording for bug report replay (#507)
- WASD input freeze after filing bug report — LineEdit focus not released before queue_free() across CanvasLayers
- WASD not reactivating after Talk — dialogue_active held for entry_lifetime instead of cleared immediately
- Recognition chime spam — entity IDs now tracked permanently per room instead of expiring
- Audio path warning — res://audio/ corrected to res://assets/audio/ in AudioManager
- world_radial.tscn anchors_preset warning — changed from 15 to 0
- bug_report_dialog.gd push_warning changed to print for informational message
## [v0.1.13] — 2026-02-20
### Added
+3 -3
View File
@@ -94,7 +94,7 @@ build-client:
# --- Run ---
server:
cd server && cargo run
cd server && cargo run --bin settled-reach-server
client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@@ -103,7 +103,7 @@ client:
game: stop build
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Starting server..."
@cd server && cargo run &
@cd server && cargo run --bin settled-reach-server &
@sleep 2
@echo "Starting client..."
@SR_LIVE=1 $(GODOT) --path client
@@ -290,7 +290,7 @@ perf-baseline:
debug-schedule:
@echo "Dumping bevy_ecs schedule graph..."
@cd server && cargo run -- --dump-schedule
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
content-ron:
cd tooling/content-converter && cargo build --release
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dfy0ye3srawos"
path="res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_bar_layer.ogg"
dest_files=["res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://cne005dbmwt6d"
path="res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_corridor_layer.ogg"
dest_files=["res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://b6ycjdvxpphfa"
path="res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_station_base.ogg"
dest_files=["res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dw2pokvg5d7v2"
path="res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_workplace_layer.ogg"
dest_files=["res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://da81bx5y6iw87"
path="res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_footstep_metal_run.ogg"
dest_files=["res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://d14dx3q0qd483"
path="res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_footstep_metal_walk.ogg"
dest_files=["res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -2,7 +2,7 @@
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://caeuztw9ah5h5l"
uid="uid://cow7symyvpmal"
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
[deps]
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://qbg8rfnetlqn"
path="res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"
[deps]
source_file="res://assets/fonts/Michroma-Regular.ttf"
dest_files=["res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+2 -2
View File
@@ -43,5 +43,5 @@ passive_opacity: 0.9
# ENTRY TIMING
# All entry types share the same lifetime and fade duration.
# ============================================================
entry_lifetime_seconds: 15.0
entry_fade_seconds: 3.0
entry_lifetime_seconds: 45.0
entry_fade_seconds: 5.0
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="#1a1a2e"/></svg>

After

Width:  |  Height:  |  Size: 119 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://caxeq5xayr0iy"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+5 -5
View File
@@ -44,7 +44,7 @@ const FILTER_CUTOFF_DEFAULT := 20500.0
# --- D-073: Zone crossfade constants ---
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
# Maps server zone_id strings to ambient asset keys (filenames in res://assets/audio/).
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
# they are the same location type, so hub→workplace transition is a same-asset no-op
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
@@ -112,15 +112,15 @@ func _setup_buses() -> void:
# --- Asset registry (D-068 directory-scan pattern) ---
func _scan_registry() -> void:
_scan_dir("res://audio/")
_scan_dir("res://assets/audio/")
print("AudioManager: %d assets registered" % _registry.size())
func _scan_dir(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
if path == "res://audio/":
print("AudioManager: res://audio/ not found — all play methods no-op")
if path == "res://assets/audio/":
print("AudioManager: res://assets/audio/ not found — all play methods no-op")
return
dir.list_dir_begin()
var file_name := dir.get_next()
@@ -197,7 +197,7 @@ func stop_all_loops() -> void:
# --- Audio asset registry: event type → asset key (D-018, #125) ---
# Maps server-sent sound event_type strings to audio asset keys.
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
# Missing assets no-op gracefully (D-038 fallback pattern).
const SOUND_EVENT_ASSETS: Dictionary = {
@@ -0,0 +1 @@
uid://rqfw0ycyb4c6
+1 -1
View File
@@ -94,7 +94,7 @@ const FACING_INDICATOR_OFFSET: float = 14.0
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
# Tyre architecture review 2026-02-19: readability over max-width; fits
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 640
const DIALOGUE_MAX_WIDTH: int = 1200
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
+9 -9
View File
@@ -250,22 +250,16 @@ func _play_close_sound_events() -> void:
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
# enters the cognitive delay recognition queue for the first time.
# "The chime marks the character's attention shifting" (D-067).
# Entities that complete recognition (leave pending_recognitions) are removed
# from _known_recognition_ids so they can chime again if re-encountered.
# IDs persist for the session — one chime per entity, no re-trigger on
# fog oscillation or server re-send. Cleared on room change (teleport).
func _play_recognition_chimes() -> void:
var active_ids: Dictionary = {}
for rec in GameState.pending_recognitions:
if not rec is Dictionary or not rec.has("entity_id"):
continue
var eid: int = rec.entity_id
active_ids[eid] = true
if not _known_recognition_ids.has(eid):
_known_recognition_ids[eid] = true
AudioManager.play(AudioManager.CHIME_RECOGNITION)
# Expire IDs no longer in the recognition queue
for eid in _known_recognition_ids.keys():
if not active_ids.has(eid):
_known_recognition_ids.erase(eid)
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
@@ -358,11 +352,16 @@ func _consume_conversation_ended() -> void:
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
# Updates dialogue_box entity display registry with speaker identity from the wire.
func _consume_dialogue_response() -> void:
if GameState.dialogue_response == null or not dialogue_box:
return
var dr: Dictionary = GameState.dialogue_response
dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", ""))
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
var speaker_color_index: int = dr.get("speaker_color_index", -1)
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
GameState.dialogue_response = null
@@ -446,6 +445,7 @@ func _teleport_transition() -> void:
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
_known_recognition_ids.clear() # D-067: reset chimes for new room
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
+1 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 9
const PROTOCOL_VERSION: int = 12
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -0,0 +1 @@
uid://cb10ir0idsr0g
+1 -1
View File
@@ -88,7 +88,7 @@ func _draw() -> void:
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count := max(left_lines.size(), right_lines.size())
var line_count: int = maxi(left_lines.size(), right_lines.size())
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
# Background
+1
View File
@@ -0,0 +1 @@
uid://c55ow14m345vv
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
uid://duvqpvheku2j2
+1
View File
@@ -0,0 +1 @@
uid://d30by4d62g7ts
@@ -0,0 +1 @@
uid://b1tata3wjyb46
+1
View File
@@ -0,0 +1 @@
uid://dhgcekc2ywr26
+1
View File
@@ -0,0 +1 @@
uid://qv808kduvlbk
+1
View File
@@ -0,0 +1 @@
uid://dmalit26eiwlh
@@ -0,0 +1 @@
uid://6q82kiijx7ml
@@ -0,0 +1 @@
uid://c8ng0ubdfumht
@@ -0,0 +1 @@
uid://bn4a46egt2uol
@@ -0,0 +1 @@
uid://tp8wpp6t8tvx
+2 -1
View File
@@ -243,6 +243,7 @@ func _close() -> void:
_active = false
visible = false
if _line_edit:
_line_edit.release_focus()
_line_edit.queue_free()
_line_edit = null
@@ -344,7 +345,7 @@ func _save_report(description: String) -> void:
else:
push_error("BugReport: failed to write %s" % seed_path)
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
print("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
+1
View File
@@ -0,0 +1 @@
uid://c5xgtnkp0butl
+119 -30
View File
@@ -21,12 +21,18 @@ signal unpause_requested # D-061: auto-unpause
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
# -- Log state --
# Entry format: {speaker, target, text, is_passive, pinned, timestamp_msec}
# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec}
# Entry format (entity-anchored): {speaker_id: int, target_id: int, text, is_passive, pinned, timestamp_msec}
# pinned entries do not expire (active conversation lines, Araminta review).
var _log_entries: Array[Dictionary] = []
var _in_player_conversation: bool = false
var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1)
# Entity ID → {name: String, color_index: int}
# Populated from server events; drives retroactive re-render when NPC names resolve.
var _entity_display: Dictionary = {}
# -- Option state --
var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = []
@@ -45,8 +51,8 @@ var _npc_colors: Array[Color] = []
var _arrow_color: Color = Color("#8890a0")
var _speech_color: Color = Color("#c8d0e0")
var _passive_opacity: float = 0.9
var _entry_lifetime: float = 15.0
var _entry_fade: float = 3.0
var _entry_lifetime: float = 45.0
var _entry_fade: float = 5.0
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
@@ -93,6 +99,18 @@ func _unhandled_input(event: InputEvent) -> void:
if not _in_player_conversation:
return
# Number keys 1-3 select dialogue options
if event is InputEventKey and event.pressed:
var key_index := -1
if event.keycode == KEY_1: key_index = 0
elif event.keycode == KEY_2: key_index = 1
elif event.keycode == KEY_3: key_index = 2
if key_index >= 0 and key_index < _option_controls.size():
get_viewport().set_input_as_handled()
_on_option_pressed(key_index)
return
# D-064: WASD during active player dialogue → walk-away
if event is InputEventKey and event.pressed:
for action in _WALK_AWAY_ACTIONS:
@@ -153,7 +171,7 @@ func _load_theme() -> void:
func _update_layout() -> void:
var vp := get_viewport_rect().size
var max_h := vp.y * MAX_HEIGHT_RATIO
var w := minf(MAX_WIDTH_PX, vp.x * 0.65)
var w := minf(MAX_WIDTH_PX, vp.x * 0.85)
panel.offset_left = -w / 2.0
panel.offset_right = w / 2.0
panel.offset_top = -max_h
@@ -180,13 +198,59 @@ func append_line(speaker: String, target: String, text: String, is_passive: bool
## Append an overheard conversation event (D-078).
## Stores entity IDs for retroactive name resolution when NPC display names change.
func append_conversation_event(event: Dictionary) -> void:
var speaker: String = event.get("speaker_name", "?")
var target: String = event.get("target_name", "?")
var text: String = event.get("occluded_line", "")
if text.is_empty():
return
append_line(speaker, target, text, true)
var speaker_id: int = event.get("speaker_id", -1)
var target_id: int = event.get("target_id", -1)
var speaker_name: String = event.get("speaker_name", "?")
var target_name: String = event.get("target_name", "?")
var speaker_color_index: int = event.get("speaker_color_index", -1)
var target_color_index: int = event.get("target_color_index", -1)
# Update entity display registry — set dirty if a known name changed (retroactive update)
if speaker_id >= 0:
var prev: Dictionary = _entity_display.get(speaker_id, {})
_entity_display[speaker_id] = {"name": speaker_name, "color_index": speaker_color_index}
if prev.has("name") and prev.get("name", "") != speaker_name:
_log_dirty = true
if target_id >= 0:
var prev: Dictionary = _entity_display.get(target_id, {})
_entity_display[target_id] = {"name": target_name, "color_index": target_color_index}
if prev.has("name") and prev.get("name", "") != target_name:
_log_dirty = true
var entry: Dictionary = {
"text": text,
"is_passive": true,
"pinned": false,
"timestamp_msec": Time.get_ticks_msec(),
}
if speaker_id >= 0:
entry["speaker_id"] = speaker_id
entry["target_id"] = target_id
else:
# Fallback: no entity IDs on wire, store raw names for legacy rendering
entry["speaker"] = speaker_name
entry["target"] = target_name
_log_entries.append(entry)
_log_dirty = true
_ensure_visible()
## Update entity display name and color index — called when Talk responses arrive.
## Triggers retroactive log re-render if the entity's displayed name has changed.
func update_entity_display(entity_id: int, name: String, color_index: int) -> void:
if entity_id < 0:
return
var prev: Dictionary = _entity_display.get(entity_id, {})
_entity_display[entity_id] = {"name": name, "color_index": color_index}
if prev.has("name") and prev.get("name", "") != name:
_log_dirty = true
## Handle conversation_ended — no-op currently (entries expire via timeout).
@@ -234,7 +298,8 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
## End active player conversation — clears options but preserves log.
## D-064: dialogue_active held until fade completes.
## D-064: dialogue_active cleared immediately so WASD resumes.
## Log entries remain visible and expire via timeout (cosmetic only).
func _end_player_conversation() -> void:
_in_player_conversation = false
_clear_options()
@@ -254,9 +319,10 @@ func _end_player_conversation() -> void:
# D-061: unpause — signal to main.gd for input recording (#507)
unpause_requested.emit()
# D-064: dialogue_active stays true — cleared after fade completes in hide callback.
# If log entries still exist, panel stays visible and entries expire via timeout.
# If no entries, hide immediately with fade.
# D-064: unblock movement immediately — log entries stay visible but don't block input.
GameState.dialogue_active = false
# If no entries remain, hide the panel with fade.
if _log_entries.is_empty():
hide_dialogue()
@@ -302,18 +368,15 @@ func _show_options(options: Array) -> void:
var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false)
var label := RichTextLabel.new()
label.bbcode_enabled = true
label.fit_content = true
label.scroll_active = false
var label := Label.new()
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.mouse_filter = Control.MOUSE_FILTER_STOP
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
if is_confrontation:
label.text = "[i]%s[/i]" % raw_text
else:
label.text = raw_text
var numbered_text := "%d. %s" % [i + 1, raw_text]
label.text = numbered_text
var idx := i
label.gui_input.connect(func(event: InputEvent):
@@ -407,15 +470,42 @@ func _rebuild_log() -> void:
## Hoshe #2: escape BBCode brackets in server-sourced strings.
## Araminta: passive lines get ┃ prefix + desaturated colours.
## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You).
## Entity-anchored entries resolve display name and color from _entity_display.
func _format_entry(entry: Dictionary, alpha: float) -> String:
var speaker: String = _escape_bbcode(entry.speaker)
var target: String = _escape_bbcode(entry.target)
var speaker: String
var target: String
var speaker_color: Color
var target_color: Color
var involves_player: bool
if entry.has("speaker_id"):
# Entity-anchored entry: resolve from _entity_display registry
var sp_data: Dictionary = _entity_display.get(entry["speaker_id"], {})
var tg_data: Dictionary = _entity_display.get(entry.get("target_id", -1), {})
speaker = _escape_bbcode(sp_data.get("name", "?"))
target = _escape_bbcode(tg_data.get("name", "?"))
var sp_ci: int = sp_data.get("color_index", -1)
var tg_ci: int = tg_data.get("color_index", -1)
if sp_ci >= 0 and not _npc_colors.is_empty():
speaker_color = _enforce_contrast(_npc_colors[sp_ci % _npc_colors.size()])
else:
speaker_color = _color_for_name(sp_data.get("name", "?"))
if tg_ci >= 0 and not _npc_colors.is_empty():
target_color = _enforce_contrast(_npc_colors[tg_ci % _npc_colors.size()])
else:
target_color = _color_for_name(tg_data.get("name", "?"))
involves_player = false # Overheard entries never involve the player directly
else:
# Legacy string-keyed entry (player dialogue, backward compat)
speaker = _escape_bbcode(entry.get("speaker", "?"))
target = _escape_bbcode(entry.get("target", "?"))
speaker_color = _color_for_name(entry.get("speaker", "?"))
target_color = _color_for_name(entry.get("target", "?"))
involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
var text: String = _escape_bbcode(entry.text)
var is_passive: bool = entry.is_passive
var speaker_color := _color_for_name(entry.speaker)
var target_color := _color_for_name(entry.target)
# Desaturate passive name colours (Araminta review)
if is_passive:
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
@@ -429,7 +519,6 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
var prefix := PASSIVE_GLYPH if is_passive else ""
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
var involves_player := entry.speaker == PLAYER_NAME or entry.target == PLAYER_NAME
if involves_player and not is_passive:
# Just "Speaker: text" or "You: text"
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
@@ -555,11 +644,11 @@ func _clear_options() -> void:
# Hover callbacks
static func _make_hover_on(label: RichTextLabel) -> Callable:
static func _make_hover_on(label: Control) -> Callable:
return func():
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
static func _make_hover_off(label: RichTextLabel) -> Callable:
static func _make_hover_off(label: Control) -> Callable:
return func():
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
+74 -2
View File
@@ -19,10 +19,12 @@ const LABEL_GAP := 2
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
const ENTITY_OFFSET := Vector2(0, -12) # nudge above entity sprite center
var _showing: bool = false
var _insert_active: bool = true
var _current_target_id: int = -1
var _entity_world_pos: Vector2 = Vector2.ZERO # cached world tile position of target
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0
var _active_tween: Tween = null
@@ -37,6 +39,21 @@ func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _process(_delta: float) -> void:
if _showing:
_update_screen_position()
queue_redraw()
func _draw() -> void:
if not _showing or _verb_labels.is_empty():
return
var pad := 6.0
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
draw_rect(bg_rect, INSERT_BG)
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
func update_from_state() -> void:
# D-055: Sprint stance suppresses interaction list
if GameState.player_stance == "Sprint":
@@ -70,6 +87,7 @@ func update_from_state() -> void:
_current_target_id = entity_id
_verb_items = sorted
_selected_index = 0
_cache_entity_position()
_rebuild_labels()
_show()
@@ -84,15 +102,46 @@ func _rebuild_labels() -> void:
for i in range(_verb_items.size()):
var verb: Dictionary = _verb_items[i]
var lbl := Label.new()
lbl.text = verb.get("label", "")
lbl.text = " %s " % verb.get("label", "")
lbl.add_theme_font_size_override("font_size", 14)
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
lbl.mouse_filter = Control.MOUSE_FILTER_STOP
lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
var idx := i
lbl.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_select_and_interact(idx)
)
lbl.mouse_entered.connect(func(): _hover_index(idx))
lbl.mouse_exited.connect(func(): _unhover_index(idx))
_vbox.add_child(lbl)
_verb_labels.append(lbl)
## Cache the target entity's world tile position from GameState.visible_entities.
func _cache_entity_position() -> void:
for entity in GameState.visible_entities:
if entity.get("entity_id") == _current_target_id:
_entity_world_pos = Vector2(entity.x, entity.y)
return
## Convert entity world position to screen coords and reposition this Control.
## Runs every frame while showing so the list tracks the entity as the camera moves.
func _update_screen_position() -> void:
var camera := get_viewport().get_camera_2d()
if camera == null:
return
var viewport_size := get_viewport_rect().size
var cam_center := camera.get_screen_center_position()
var zoom: Vector2 = camera.zoom if camera.zoom.length_squared() > 0.01 else Constants.CAMERA_DEFAULT_ZOOM
var world_px := _entity_world_pos * Constants.TILE_SIZE
var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0
# Anchor above the entity, centered horizontally
position = screen_pos + ENTITY_OFFSET * zoom - Vector2(size.x / 2.0, size.y)
func _show() -> void:
if _showing:
return
@@ -161,6 +210,29 @@ func get_verb_labels() -> Array:
return labels
func _hover_index(idx: int) -> void:
_selected_index = idx
_update_label_colors()
func _unhover_index(_idx: int) -> void:
pass # keep last hover highlighted
func _select_and_interact(idx: int) -> void:
if idx < 0 or idx >= _verb_items.size():
return
_selected_index = idx
verb_selected.emit(_verb_items[idx].get("kind", ""), _current_target_id)
func _update_label_colors() -> void:
for i in range(_verb_labels.size()):
if is_instance_valid(_verb_labels[i]):
_verb_labels[i].add_theme_color_override(
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and _showing:
+1 -1
View File
@@ -94,7 +94,7 @@ func _show_line(text: String, duration: float, priority: int, is_urgent: bool, l
node = line_node,
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
priority = priority,
tween = null as Tween,
tween = null,
}
_visible.append(slot)
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
+1
View File
@@ -0,0 +1 @@
uid://cpjq8yfsnpr5m
+1 -1
View File
@@ -5,7 +5,7 @@
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
[node name="WorldRadial" type="Control"]
layout_mode = 3
anchors_preset = 15
anchors_preset = 0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
+288
View File
@@ -0,0 +1,288 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "dialogue-line.schema.json",
"title": "Dialogue Line — canonical D-035 tag taxonomy",
"description": "Canonical single-line schema for dialogue and monologue pools. Implements the converged tag taxonomy from D-035 (6 structural + 3 selection + 2 authoring-only tags). Monologue-specific additions (character, trigger, prerequisite) are defined in $defs/monologue_extension. Mood vocabulary renamed Sprint 14 to match voice guide (anxious/frustrated/content/suspicious/warm/hostile/relieved/focused).",
"type": "object",
"required": ["id", "text", "role", "access", "trust", "situation"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_(d|m)_[0-9]{3}$",
"description": "Stable machine-parseable line ID: {template}_{d|m}_{###}. d = dialogue, m = monologue."
},
"text": {
"type": "string",
"minLength": 1,
"description": "The authored line text."
},
"role": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Template-defined role slug (e.g. dock-worker, bar-owner, player_character). Not NPC name — NPC assignment is runtime."
},
"access": {
"type": "array",
"items": {
"type": "string",
"enum": ["public", "insider", "authority", "peer", "hostile"]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-028 Layer 1: access tiers this line is eligible for. List — a line can be eligible for multiple tiers. Hard filter."
},
"trust": {
"type": "string",
"enum": ["surface", "real", "secret"],
"description": "D-028 Layer 3: minimum trust tier required. Hard filter. Ordering: surface < real < secret."
},
"situation": {
"type": "array",
"items": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-028 Layer 2: situations in which this line can fire. 14 v0.1 values (13 original + greeting added Sprint 8 for PC dialogue initial contact lines). NOTE: 'greeting' is not yet in server/src/content/line_pool.rs — lines using it will be skipped until Rust is updated."
},
"topic": {
"type": "array",
"items": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
]
},
"uniqueItems": true,
"description": "D-028 Layer 4: topic tags for weighted selection. 9 v0.1 values. Optional — defaults to empty if omitted. Note: 'crime' deliberately excluded; NPCs think of it as 'cargo' or 'money'."
},
"mood": {
"type": "array",
"items": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
]
},
"uniqueItems": true,
"description": "D-028 Layer 4: mood tags for weighted selection. 8 v0.1 values. Neutral mood = omit tag (untagged lines are always eligible). Renamed Sprint 14 to match voice guide vocabulary."
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Freeform escape hatch for author intent not covered by the structured taxonomy. Not consumed by the engine selection pipeline."
},
"knowledge_grant": {
"type": "object",
"description": "Knowledge the player gains from hearing this line. Feeds into the knowledge graph (D-041).",
"required": ["fact_id", "confidence"],
"additionalProperties": false,
"properties": {
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368)."
},
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "D-041 confidence tier granted."
}
}
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: per-character notes for content with different resonance for smuggler vs detective. NOT consumed by the engine.",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes, context, or intent documentation. NOT consumed by the engine."
}
},
"$defs": {
"monologue_extension": {
"title": "Monologue-specific additions (D-035)",
"description": "Additional required fields for monologue lines. Applied ON TOP OF the base dialogue line schema. Pool-level character partitioning (D-032) is enforced at the pool root, not per-line.",
"type": "object",
"required": ["trigger"],
"properties": {
"character": {
"type": "string",
"enum": ["smuggler", "detective"],
"description": "D-032: hard partition tag. Which playable character this line belongs to. Must match the parent pool's character field."
},
"trigger": {
"type": "string",
"enum": [
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit"
],
"description": "What causes this monologue line to fire. 9 v0.1 trigger types."
},
"prerequisite": {
"description": "Knowledge state gate. null = unconditional (fires whenever triggered). Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
"oneOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
}
}
}
]
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 5,
"description": "Selection priority. Higher = more likely to fire when multiple lines are eligible. Default: 5."
},
"cooldown": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Minimum simulation ticks before this line can fire again. Default: 0 (no cooldown)."
}
}
},
"fact_prerequisite": {
"type": "object",
"required": ["fact_id", "min_confidence"],
"additionalProperties": false,
"properties": {
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368, D-041)."
},
"min_confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "Minimum D-041 confidence level required for this fact."
}
}
},
"attribute_prerequisite": {
"type": "object",
"required": ["entity", "key", "value"],
"additionalProperties": false,
"properties": {
"entity": { "type": "string" },
"key": { "type": "string" },
"value": { "type": "string" }
}
},
"situation_enum": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
],
"description": "14 v0.1 situation values (D-035 + Sprint 8 amendment)."
},
"topic_enum": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
],
"description": "9 v0.1 topic values (D-035)."
},
"mood_enum": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
],
"description": "8 v0.1 mood values. Renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
}
}
}
+19 -6
View File
@@ -90,13 +90,12 @@
"items": {
"type": "string",
"enum": [
"fond", "comfortable", "worried", "suspicious",
"analytical", "conflicted", "concerned", "relieved",
"focused"
"anxious", "frustrated", "content", "suspicious",
"warm", "hostile", "relieved", "focused"
]
},
"uniqueItems": true,
"description": "Mood tags for selection weighting (D-035: list<enum>, v0.1 8 moods + focused added Sprint 8)"
"description": "Mood tags for selection weighting (D-035: list<enum>). 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
},
"tags": {
"type": "array",
@@ -106,14 +105,28 @@
"knowledge_grant": {
"type": "object",
"description": "Knowledge the player gains from hearing this line",
"required": ["fact_id", "confidence"],
"additionalProperties": false,
"properties": {
"fact_id": { "type": "string" },
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
}
},
"required": ["fact_id", "confidence"]
}
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: per-character resonance notes (NOT consumed by engine)",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes (NOT consumed by engine)"
}
}
}
+144 -34
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "monologue-pool.schema.json",
"title": "Monologue Line Pool",
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035).",
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035). Monologue lines carry all 6 D-035 structural tags for schema compliance, but role/access/trust are fixed constants for player-character internal voice (role=player_character, access=[public], trust=surface). The engine does not gate monologue on access or trust — these tags exist for taxonomy uniformity only.",
"type": "object",
"required": ["character", "location", "lines"],
"additionalProperties": false,
@@ -10,12 +10,12 @@
"character": {
"type": "string",
"enum": ["smuggler", "detective"],
"description": "Playable character this pool belongs to (hard partition per D-032)"
"description": "Playable character this pool belongs to (hard partition per D-032). All lines in this pool belong to this character."
},
"location": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Location slug, or 'general' for location-independent lines"
"description": "Location slug, or 'general' for location-independent lines."
},
"lines": {
"type": "array",
@@ -26,70 +26,176 @@
"$defs": {
"monologue_line": {
"type": "object",
"required": ["id", "text", "trigger"],
"required": ["id", "text", "role", "access", "trust", "situation", "trigger"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}"
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "Line text — 160 char max to fit monologue display without scrolling"
"maxLength": 256,
"description": "Line text — 256 char hard cap, aim for ≤160 to avoid line wrap"
},
"role": {
"type": "string",
"const": "player_character",
"description": "D-035 structural tag — always player_character for monologue. Monologue is the player character's internal voice."
},
"access": {
"type": "array",
"items": { "const": "public" },
"minItems": 1,
"maxItems": 1,
"description": "D-035 structural tag — always [public] for monologue. No access gating applies to internal voice."
},
"trust": {
"type": "string",
"const": "surface",
"description": "D-035 structural tag — always surface for monologue. No trust gating applies to internal voice."
},
"situation": {
"type": "array",
"items": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-035 structural tag: situations in which this monologue line is contextually appropriate. 14 v0.1 values. The engine selects using trigger; situation provides additional authoring context for filtering by the caller. NOTE: 'greeting' is not yet in server/src/content/line_pool.rs Situation enum."
},
"trigger": {
"type": "string",
"enum": [
"enter_location", "observe_npc", "hear_sound",
"observe_anomaly", "post_conversation", "discover_evidence",
"witness_interaction", "time_idle", "return_visit"
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit"
],
"description": "What causes this line to fire"
"description": "What causes this line to fire. 9 v0.1 trigger types (D-035 monologue-specific tag)."
},
"prerequisites": {
"type": "object",
"description": "AND-only prerequisite conditions",
"properties": {
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"description": "Knowledge state gate (D-035 monologue-specific tag). null or omitted = unconditional. Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
}
}
}
}
]
},
"topic": {
"type": "array",
"items": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
]
},
"uniqueItems": true,
"description": "D-035 selection tag: topic tags for weighted selection. 9 v0.1 values. Optional."
},
"mood": {
"type": "array",
"items": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
]
},
"uniqueItems": true,
"description": "D-035 selection tag: mood tags for weighted selection. 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 5,
"description": "Selection priority (higher = more likely to fire)"
"description": "Selection priority (higher = more likely to fire when multiple lines are eligible). Default: 5."
},
"cooldown": {
"type": "integer",
"minimum": 0,
"description": "Minimum ticks before this line can fire again"
"default": 0,
"description": "Minimum simulation ticks before this line can fire again. Default: 0."
},
"tags": {
"type": "array",
"items": { "type": "string" }
"items": { "type": "string" },
"description": "D-035 selection tag: freeform tags. Not consumed by the selection pipeline."
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: notes on how this line reads differently for smuggler vs detective. NOT consumed by engine.",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes. NOT consumed by engine."
}
}
},
@@ -98,10 +204,14 @@
"required": ["fact_id", "min_confidence"],
"additionalProperties": false,
"properties": {
"fact_id": { "type": "string" },
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368, D-041)."
},
"min_confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "Minimum D-041 confidence level required."
}
}
},
@@ -0,0 +1,306 @@
# Monologue: Detective — Commission Kiosk
# Ticket: #120 | Author: Mellanie | Sprint: 14
# Decision refs: D-016, D-032, D-034, D-035
#
# The Commission kiosk is a small terminal alcove in the transit district —
# institutional grey, cool lighting, regulation spec. The detective has
# authorized access. This is where official investigation happens: manifest
# logs, personnel records, flagged container reports. It smells like
# commission-issue air filters. The detective is comfortable here and also
# slightly isolated — the kiosk is public but feels private.
#
# Voice note: the detective is in his element at the kiosk — data flows,
# patterns emerge, the lattice is doing its work. But the kiosk is also a
# reminder that the case is bigger than the data suggests, and that Sera works
# with Commission systems daily. These lines should feel analytically engaged
# with moments of personal cost showing through.
#
# Schema: D-035 compliant. role/access/trust/situation included on all lines.
character: detective
location: commission-kiosk
lines:
# --- Arrival ---
- id: commission-kiosk_m_d_001
text: "Commission terminal. Authorized access, full import logs. Let's see what the system knows."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, tutorial, orientation]
- id: commission-kiosk_m_d_002
text: "Standard regulation spec. Commission requisition 4-C. They didn't upgrade this terminal in three years."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric]
- id: commission-kiosk_m_d_003
text: "Filtered air. A Commission smell — sterile, precise, faintly antiseptic. Familiar."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric, sensory]
- id: commission-kiosk_m_d_004
text: "Back at the kiosk. The terminal logged my last access thirty-two minutes ago."
role: player_character
access: [public]
trust: surface
trigger: return_visit
situation: [arrival]
tags: [arrival, orientation]
- id: commission-kiosk_m_d_005
text: "Nobody uses this terminal except Commission staff. Which means whoever was here before me has credentials."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, investigation, tutorial]
# --- Perception / Sound ---
- id: commission-kiosk_m_d_006
text: "The terminal hum is different from the freight equipment. Cleaner. Higher frequency."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental]
- id: commission-kiosk_m_d_007
text: "Footsteps at the transit corridor entrance. Not Commission — wrong pace."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine, observation]
tags: [sensory, caution]
- id: commission-kiosk_m_d_008
text: "Someone's running queries at the main manifest board. Loud keystrokes — frustrated."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental]
# --- Tutorial / Evidence ---
- id: commission-kiosk_m_d_009
text: "Import logs go back forty days. Weight discrepancies flagged automatically — unless someone cleared the flag."
role: player_character
access: [public]
trust: surface
trigger: discover_evidence
situation: [routine, investigation]
tags: [tutorial, investigation, orientation]
- id: commission-kiosk_m_d_010
text: "Personnel movement log. Every authorized access to every restricted space, timestamped."
role: player_character
access: [public]
trust: surface
trigger: discover_evidence
situation: [routine, investigation]
tags: [tutorial, investigation]
# --- Time Idle / Ruminative ---
- id: commission-kiosk_m_d_011
text: "Pattern is forming. Three manifests, same routing anomaly, different filing dates. That's deliberate."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_012
text: "The kiosk shows what the system knows. The system doesn't know what it hasn't been told."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, investigation]
- id: commission-kiosk_m_d_013
text: "Commission protocols say: document everything, infer nothing. The gap between those two is where cases live."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, analytical]
- id: commission-kiosk_m_d_014
text: "Twelve access events in the manifest terminal in the last six days. Elevated for a district this size."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_015
text: "Evidence is what the system logged. Inference is what it means. Right now I have plenty of both."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
tags: [investigation, analytical]
# --- Anomaly ---
- id: commission-kiosk_m_d_016
text: "Access log shows a non-Commission credential used at 0340. That terminal should have rejected it."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine, investigation]
priority: 7
tags: [investigation, caution, analytical]
- id: commission-kiosk_m_d_017
text: "Container 4471 has three separate manifest entries with different timestamps. One of them is false."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_of
priority: 8
tags: [investigation, contraband, analytical]
- id: commission-kiosk_m_d_018
text: "Standard access log would show calibration events. This one shows something cleared three days ago. Manually."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [investigation]
tags: [investigation, caution]
# --- Knowledge-Gated: Sera Arc ---
- id: commission-kiosk_m_d_019
text: "Venn, S. — last calibration event logged here: 0615 this morning. Standard. Her schedule is precise."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
relationship:
target: npc:sera-venn
state: known
priority: 5
tags: [npc, sera, routine, friend-arc]
- id: commission-kiosk_m_d_020
text: "Venn's credentials are in the access log. Three times today. She's thorough, or she's looking for something."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
prerequisites:
facts:
- fact_id: behavioral.sera_kiosk_pattern
min_confidence: suspects
priority: 6
tags: [npc, sera, investigation, friend-arc, dual-lens]
- id: commission-kiosk_m_d_021
text: "She runs calibration checks on the Commission terminal. That's her job. But the access log shows she's also running manifest queries. That's not."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [investigation]
prerequisites:
facts:
- fact_id: behavioral.sera_kiosk_pattern
min_confidence: knows_of
priority: 7
tags: [npc, sera, tell, investigation, friend-arc]
- id: commission-kiosk_m_d_022
text: "Venn cleared a flag on container 4471 six days ago. Routine override — authorized. But 4471 is in my manifest discrepancy list."
role: player_character
access: [public]
trust: surface
trigger: discover_evidence
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_of
- fact_id: behavioral.sera_kiosk_pattern
min_confidence: suspects
priority: 9
tags: [npc, sera, investigation, contraband, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_023
text: "She knows. Either she found it and cleared it deliberately, or she was directed to. Both are worse than I want to believe."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [investigation]
prerequisites:
facts:
- fact_id: behavioral.sera_avoidance_pattern
min_confidence: knows_of
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_details
priority: 9
tags: [npc, sera, investigation, friend-arc, contaminated-trust]
# --- Post-Conversation ---
- id: commission-kiosk_m_d_024
text: "She said she hadn't touched the manifest terminal. The log says otherwise. Either she forgot or she lied."
role: player_character
access: [public]
trust: surface
trigger: post_conversation
situation: [investigation]
prerequisites:
relationship:
target: npc:sera-venn
state: person_of_interest
priority: 9
tags: [npc, sera, post-conversation, tell, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_025
text: "Davan said he runs a clean dock. The manifest data disagrees on three specific points. Filed."
role: player_character
access: [public]
trust: surface
trigger: post_conversation
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_of
tags: [npc, kael, investigation, contraband]
@@ -0,0 +1,303 @@
# Monologue: Smuggler — Smuggling Hold
# Ticket: #120 | Author: Mellanie | Sprint: 14
# Decision refs: D-016, D-032, D-035, D-037
#
# The smuggling hold is below the maintenance corridors — an unmarked
# sub-level cargo space that the ring uses for temp storage and transfers.
# Not on any official manifest. The smuggler knows every pipe and access
# point here. This is both her operational center and her greatest exposure.
#
# Voice note: the smuggler here is at maximum operational competence and
# maximum paranoia simultaneously. She's in control of the space but the
# space itself is evidence of everything she's done. Lines should carry
# that dual register — calm on the surface, tight underneath.
#
# Schema: D-035 compliant. role/access/trust/situation included on all lines.
# Existing Sprint 5 files (the-terminal, the-last-shift, maintenance-corridors)
# will be updated by #168 (schema compliance pass).
character: smuggler
location: smuggling-hold
lines:
# --- Arrival ---
- id: smuggling-hold_m_s_001
text: "Sub-level. The kind of space nobody finds unless you know where to look."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric, operational]
- id: smuggling-hold_m_s_002
text: "Coolant smell. No ventilation down here — just recycled air and waiting."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric, sensory]
- id: smuggling-hold_m_s_003
text: "Access hatch sealed from the inside. Good. Route's still ours."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, operational]
- id: smuggling-hold_m_s_004
text: "Three containers in temp. Right where they should be."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
facts:
- fact_id: knowledge.ring_routing_knowledge
min_confidence: knows_of
tags: [arrival, operational, contraband]
- id: smuggling-hold_m_s_005
text: "Back in the hold. Dust on the floor shows the last two paths in. Both mine."
role: player_character
access: [public]
trust: surface
trigger: return_visit
situation: [arrival, routine]
tags: [arrival, operational]
- id: smuggling-hold_m_s_006
text: "Nobody's been here. Good. That's how it should feel."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
tags: [arrival, atmospheric]
# --- Perception / Sound ---
- id: smuggling-hold_m_s_007
text: "The water recycler's on the other side of that wall. Loud enough to cover conversation."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental, operational]
- id: smuggling-hold_m_s_008
text: "Footsteps above. Maintenance crew — they stay up top. Always."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, environmental, caution]
- id: smuggling-hold_m_s_009
text: "That sound. Pressure shift. Someone opened the main hatch."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine, investigation]
priority: 7
tags: [sensory, caution]
- id: smuggling-hold_m_s_010
text: "Pipe drone changes pitch when cargo shifts weight in the upper tier. That's cargo moving."
role: player_character
access: [public]
trust: surface
trigger: hear_sound
situation: [routine]
tags: [sensory, operational]
# --- Time Idle / Ruminative ---
- id: smuggling-hold_m_s_011
text: "Fifteen minutes until the oversight window closes. Plenty of time. Probably."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [shift_transition]
tags: [operational, atmospheric]
- id: smuggling-hold_m_s_012
text: "Medical-grade lattice components. People need these. That's still the reason."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, contraband]
- id: smuggling-hold_m_s_013
text: "Twelve minutes of reduced oversight. Stop counting and do something useful."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [shift_transition]
tags: [operational]
- id: smuggling-hold_m_s_014
text: "How many times have I been in this room telling myself it's almost done?"
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, personal]
- id: smuggling-hold_m_s_015
text: "Quiet. The right kind. Not the wrong kind."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric]
# --- Anomaly / Investigation ---
- id: smuggling-hold_m_s_016
text: "Container 4471's been opened. Not by me. Not by anyone I authorized."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine, investigation]
priority: 8
tags: [operational, caution, contraband]
- id: smuggling-hold_m_s_017
text: "Dust disturbed at the secondary hatch. Recent. Someone's been using the back route."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine]
tags: [caution, operational]
- id: smuggling-hold_m_s_018
text: "Scratches on the access panel. New ones, over the old ones. Different tool."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine]
tags: [caution, environmental]
- id: smuggling-hold_m_s_019
text: "The temp unit's running warm. Someone moved cargo through here fast."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [routine]
tags: [operational, caution, contraband]
# --- Knowledge-Gated: Kael Arc ---
- id: smuggling-hold_m_s_020
text: "Kael used to wait for me here. The one place where we could actually talk."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
relationship:
target: npc:kael-davan
state: friendly
priority: 6
tags: [npc, kael, atmospheric, friend-arc]
- id: smuggling-hold_m_s_021
text: "Kael's not answering. Should be here by now. Should have been here ten minutes ago."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
prerequisites:
relationship:
target: npc:kael-davan
state: person_of_interest
priority: 7
tags: [npc, kael, concern, friend-arc]
- id: smuggling-hold_m_s_022
text: "If Kael talked to someone in that corridor — someone outside the ring — and then this container got touched... that's not coincidence."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine, investigation]
prerequisites:
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: knows_of
- fact_id: investigation.container_delay
min_confidence: suspects
priority: 9
tags: [npc, kael, investigation, contraband, friend-arc]
- id: smuggling-hold_m_s_023
text: "The manifest Kael helped me falsify is in that container. If he talked, they already know."
role: player_character
access: [public]
trust: surface
trigger: observe_anomaly
situation: [investigation]
prerequisites:
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: suspects
- fact_id: knowledge.ring_routing_knowledge
min_confidence: knows_details
priority: 9
tags: [npc, kael, operational, contraband, friend-arc, contaminated-trust]
- id: smuggling-hold_m_s_024
text: "Renn's mark is here. The route's still active. So Kael hasn't burned everything. Yet."
role: player_character
access: [public]
trust: surface
trigger: enter_location
situation: [arrival, routine]
prerequisites:
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: suspects
priority: 7
tags: [operational, kael, renn, friend-arc]
# --- Post-Conversation ---
- id: smuggling-hold_m_s_025
text: "Renn didn't ask questions. That's either loyalty or he already knows."
role: player_character
access: [public]
trust: surface
trigger: post_conversation
situation: [routine]
tags: [npc, renn, operational]
- id: smuggling-hold_m_s_026
text: "One more run. That's what I keep telling myself. Has been for eight months."
role: player_character
access: [public]
trust: surface
trigger: time_idle
situation: [routine]
tags: [atmospheric, personal, contraband]
+821
View File
@@ -0,0 +1,821 @@
# NPC-to-NPC Overheard Dialogue Pool
# Ticket: #536 | Author: Mellanie | Sprint: 14
# Decision refs: D-078, D-018, D-071, D-035
#
# These are conversations the player can overhear when stationary near two
# NPCs. The passive dialogue panel (D-078) displays them with per-word
# occlusion based on distance, ambient noise, and ListeningFocus stance.
#
# OCCLUSION-RESILIENT AUTHORING RULES (D-078):
# 1. Front-load key information — most important word in first third.
# 2. Short declarative sentences — one idea per turn.
# 3. No pronoun-first openers — first word has highest drop risk; a dropped
# pronoun without antecedent is unresolvable. Use names or nouns.
# 4. Each turn is self-contained — a player who hears only one side gets
# a complete thought.
#
# SCHEMA NOTE:
# `relationship_type` and `knowledge_payload` are custom extensions to the
# D-035 schema for this content type. NPC-sourced lines use `role: npc`,
# `access` and `trust` apply normally. Monologue-specific tags (`character`,
# `trigger`, `prerequisite`) are not used here.
# Schema confirmation with Gestalt (#168) before CI validation.
#
# REGISTERS:
# social — idle chat, personal news, relationship talk
# work — shift logistics, job gripes, operational notes
# gossip — third-party information with player-relevant knowledge payload
#
# RELATIONSHIP TYPES:
# colleague, friend, hostile, romantic
#
# KNOWLEDGE PAYLOAD FORMAT:
# Plain English description of what the player can infer from hearing this
# exchange clearly — or the key fragment they retain under partial occlusion.
# null if the exchange is social noise with no investigative value.
pairs:
# ===================================================================
# SOCIAL register — personal news, idle chat, relationships
# ===================================================================
- id: overheard_001
register: social
speaker_a:
role: dock-worker
text: "Kael brought food for the whole bay yesterday. Just showed up with it."
speaker_b:
role: dock-worker
text: "Naia must have made him feel guilty about something. Again."
relationship_type: colleague
topic: personal-gossip
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Naia have a relationship dynamic. Naia has leverage or emotional pull over Kael's behavior."
tags: [kael, naia, social, atmosphere]
- id: overheard_002
register: social
speaker_a:
role: bar-regular
text: "Lera's keeping the kitchen open late this week. Span gate delay — crews stuck here."
speaker_b:
role: bar-regular
text: "Good for Lera. Bad for everyone waiting on the gate."
relationship_type: friend
topic: station-life
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, span-gate, atmosphere, social]
- id: overheard_003
register: social
speaker_a:
role: dock-worker
text: "Drin's applying for the transfer. Again. Third time he's tried."
speaker_b:
role: dock-worker
text: "Drin's never getting off Sova. Some people just belong to a place."
relationship_type: colleague
topic: career-gossip
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Drin wants out of Sova Transit. Unhappy enough to pursue transfer requests."
tags: [drin, transfer, social, atmosphere]
- id: overheard_004
register: social
speaker_a:
role: bar-regular
text: "Naia and Kael had a fight. Loud enough that Lera had to step in."
speaker_b:
role: bar-regular
text: "Kael looked rough this morning. That tracks."
relationship_type: friend
topic: relationship-drama
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Naia are under relationship strain. Kael's mood is affected. Cross-reference: behavioral.kael_behavioral_change."
tags: [kael, naia, relationship, friend-arc-adjacent, dual-lens]
- id: overheard_005
register: social
speaker_a:
role: dock-worker
text: "Renn's finally getting his lattice service done. Been putting it off for years."
speaker_b:
role: dock-worker
text: "Commission clinic wait time is eight months. Renn found someone faster."
relationship_type: colleague
topic: lattice-access
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Renn found a non-Commission lattice service provider. Points toward unlicensed lattice components market."
tags: [renn, lattice, contraband-adjacent, atmosphere]
- id: overheard_006
register: social
speaker_a:
role: bar-regular
text: "Maret's promotion came through. Third level supervisor."
speaker_b:
role: bar-regular
text: "Good. Maret actually knows what she's doing. Unlike some."
relationship_type: colleague
topic: career-news
location_hints: [the-last-shift, the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [maret, promotion, atmosphere, social]
- id: overheard_007
register: social
speaker_a:
role: dock-worker
text: "Voss has been in a mood all week. Something from upstairs."
speaker_b:
role: dock-worker
text: "Voss is always in a mood. Week ends in -day, Voss is in a mood."
relationship_type: colleague
topic: supervisor-gossip
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss is under external pressure from 'upstairs.' Something is stressing management."
tags: [voss, management, atmosphere, social]
- id: overheard_008
register: social
speaker_a:
role: bar-regular
text: "Sera's been coming here every evening this week. Thought Commission people didn't drink."
speaker_b:
role: bar-regular
text: "Sera's different. She fits here better than she fits over there."
relationship_type: colleague
topic: social-observation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera Venn is a regular at The Last Shift, unusual for a Commission employee. She has social roots in the district."
tags: [sera, commission, atmosphere, social, dual-lens]
- id: overheard_009
register: social
speaker_a:
role: dock-worker
text: "Nils covered Kael's morning slot yesterday. No explanation, just a roster note."
speaker_b:
role: dock-worker
text: "Kael's been doing that a lot lately. Taking sick leave, then showing up mid-shift."
relationship_type: colleague
topic: roster-anomaly
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Kael's schedule is irregular. Unexplained absences and late arrivals. Cross-reference: observe_anomaly triggers for Kael."
tags: [kael, nils, roster, friend-arc-adjacent]
- id: overheard_010
register: social
speaker_a:
role: maintenance-tech
text: "Olin's kid got into the Commission cadet program. Starts next cycle."
speaker_b:
role: maintenance-tech
text: "Olin must be pleased. Cost of living here, Commission pay makes sense."
relationship_type: colleague
topic: family-news
location_hints: [maintenance-corridors, the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [olin, commission, atmosphere, social]
# ===================================================================
# WORK register — shift logistics, operational gripes, job talk
# ===================================================================
- id: overheard_011
register: work
speaker_a:
role: dock-worker
text: "Maret shifted the roster again. Bay four to bay seven, no reason given."
speaker_b:
role: dock-worker
text: "Bay seven's the low-traffic slot. Somebody wanted less oversight over there."
relationship_type: colleague
topic: roster-change
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Bay seven has been given a low-oversight crew deliberately. This is operationally significant."
tags: [maret, roster, bay-seven, investigation-adjacent]
- id: overheard_012
register: work
speaker_a:
role: dock-worker
text: "Loading arm three is grinding again. Filed the report last week."
speaker_b:
role: dock-worker
text: "Maintenance says next cycle. Means nothing gets done until someone loses a hand."
relationship_type: colleague
topic: equipment-maintenance
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [equipment, maintenance, atmosphere, work]
- id: overheard_013
register: work
speaker_a:
role: dock-worker
text: "Container 4471's been in temp for three days. Someone should move it."
speaker_b:
role: dock-worker
text: "Routing says hold. Don't ask me, I just process what the board says."
relationship_type: colleague
topic: container-routing
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Container 4471 is being deliberately held in temp storage. 'Routing says hold' — someone modified the routing instruction. Cross-reference: investigation.container_delay."
tags: [container-4471, routing, contraband-adjacent, investigation-payload]
- id: overheard_014
register: work
speaker_a:
role: dock-worker
text: "Voss cut the B-section crew by two. Says it's budget. Doesn't feel like budget."
speaker_b:
role: dock-worker
text: "Less crew in B-section means less oversight. Could be budget. Could be something else."
relationship_type: colleague
topic: crew-reduction
location_hints: [the-terminal, maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "B-section is understaffed. The speaker suspects this is deliberate. Cross-reference: ring oversight windows."
tags: [voss, b-section, oversight, investigation-payload]
- id: overheard_015
register: work
speaker_a:
role: dock-worker
text: "Span gate's backed up again. Forty-minute delay on the freight queue."
speaker_b:
role: dock-worker
text: "Forty minutes is nothing. Last month it was three hours. Patience."
relationship_type: colleague
topic: span-gate-delay
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [span-gate, freight, atmosphere, work]
- id: overheard_016
register: work
speaker_a:
role: maintenance-tech
text: "Junction C-2's camera was repositioned. Nobody filed a maintenance request."
speaker_b:
role: maintenance-tech
text: "Someone moved it without logging it. That's a compliance violation."
relationship_type: colleague
topic: camera-anomaly
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Camera at junction C-2 was moved without documentation. Suggests deliberate surveillance manipulation. Cross-reference: awareness.surveillance_change."
tags: [camera, surveillance, c-2, investigation-payload]
- id: overheard_017
register: work
speaker_a:
role: dock-worker
text: "Commission wants another inspection. Fourth one this quarter."
speaker_b:
role: dock-worker
text: "Inspections mean overtime. Inspections mean everyone's watching everyone."
relationship_type: colleague
topic: commission-inspection
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Commission is conducting elevated inspections of the terminal. Something has drawn their attention."
tags: [commission, inspection, atmosphere, investigation-adjacent]
- id: overheard_018
register: work
speaker_a:
role: dock-worker
text: "Weight manifest on the morning freight came in five hundred kilos short. Recalibration error."
speaker_b:
role: dock-worker
text: "Five hundred kilos doesn't disappear from a calibration error. It disappears from somewhere else."
relationship_type: colleague
topic: weight-discrepancy
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Manifest weight discrepancy exists and dock workers are suspicious of the official explanation. Cross-reference: investigation.manifest_discrepancy."
tags: [manifest, weight-discrepancy, contraband-adjacent, investigation-payload]
- id: overheard_019
register: work
speaker_a:
role: dock-worker
text: "Shift handover's going to be rough tonight. Torek's team hasn't filed."
speaker_b:
role: dock-worker
text: "Torek's team never files on time. Let Maret handle it."
relationship_type: colleague
topic: shift-handover
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [torek, maret, shift, atmosphere, work]
- id: overheard_020
register: work
speaker_a:
role: maintenance-tech
text: "Sub-level access has been requested twice this week. Both times outside shift hours."
speaker_b:
role: maintenance-tech
text: "Scheduled maintenance happens in-shift. Off-hours access needs sign-off."
relationship_type: colleague
topic: sub-level-access
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Someone has been accessing sub-level spaces outside normal hours. No sign-off implies unauthorized use. Cross-reference: smuggling-hold activity."
tags: [sub-level, access, maintenance, investigation-payload]
- id: overheard_021
register: work
speaker_a:
role: dock-worker
text: "Bay four's been sealed for inspection since yesterday morning."
speaker_b:
role: dock-worker
text: "Commission authorized it. Not Voss. Commission went over his head."
relationship_type: colleague
topic: bay-inspection
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Bay four inspection bypassed Voss's authority. Commission is operating independently of local management."
tags: [bay-four, commission, voss, inspection, investigation-payload]
- id: overheard_022
register: work
speaker_a:
role: dock-worker
text: "Kael's running Renn's route today. Renn's supposed to be on that."
speaker_b:
role: dock-worker
text: "Kael volunteered. Said Renn had something come up."
relationship_type: colleague
topic: route-substitution
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Kael is voluntarily taking routes outside his assignment. Could be covering for Renn, could be operational flexibility needed by the ring."
tags: [kael, renn, route, ring-adjacent]
- id: overheard_023
register: work
speaker_a:
role: bar-regular
text: "Lera's dealing with the supply chain issue again. Grain spirit supplier changed terms."
speaker_b:
role: bar-regular
text: "Lera will figure it out. Lera always figures it out."
relationship_type: friend
topic: supply-chain
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, supply, atmosphere, work]
- id: overheard_024
register: work
speaker_a:
role: maintenance-tech
text: "Condensation in the B-corridor's gotten worse. Someone's running heat-generation equipment down there."
speaker_b:
role: maintenance-tech
text: "Heat-gen in a maintenance corridor. That's either a storage issue or a very bad idea."
relationship_type: colleague
topic: corridor-anomaly
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Heat-generating equipment is being used in maintenance corridors unofficially. Points toward the smuggling hold or ring operations."
tags: [b-corridor, heat, maintenance, smuggling-adjacent]
- id: overheard_025
register: work
speaker_a:
role: dock-worker
text: "Torek's been doing double manifests for a month. Every container logged twice."
speaker_b:
role: dock-worker
text: "Double logging means one version goes somewhere it shouldn't."
relationship_type: colleague
topic: manifest-anomaly
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Torek is maintaining duplicate manifest records. One version is falsified. Direct evidence of ring operation at the administrative level."
tags: [torek, manifest, contraband, investigation-payload, high-value]
# ===================================================================
# GOSSIP register — third-party knowledge with investigative payload
# ===================================================================
- id: overheard_026
register: gossip
speaker_a:
role: dock-worker
text: "Kael was in corridor B-7 last night. Saw him myself. Off shift, wrong time, wrong place."
speaker_b:
role: dock-worker
text: "Kael lives near B-section. Could have been heading home the long way."
relationship_type: colleague
topic: kael-location
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael was in corridor B-7 off-shift. Eyewitness account. Cross-reference: investigation.kael_corridor_meeting."
tags: [kael, corridor-b7, investigation-payload, friend-arc, high-value]
- id: overheard_027
register: gossip
speaker_a:
role: bar-regular
text: "Kael was talking to someone near B-7 last night. Person I didn't recognize."
speaker_b:
role: bar-regular
text: "Unknown people at junction B-7 at night. That's not normal."
relationship_type: friend
topic: kael-unknown-contact
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael met with an unidentified person at corridor B-7 during off-hours. Cross-reference: investigation.kael_unknown_contact."
tags: [kael, unknown-contact, corridor-b7, friend-arc, high-value, investigation-payload]
- id: overheard_028
register: gossip
speaker_a:
role: bar-regular
text: "Torek spent three thousand credits at Lera's last week. Three thousand. On a dock worker's salary."
speaker_b:
role: bar-regular
text: "Torek's either very lucky or very stupid. Either way, someone's going to notice."
relationship_type: friend
topic: torek-spending
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Torek is spending significantly above his salary at The Last Shift. Cross-reference: investigation.torek_spending_pattern."
tags: [torek, spending, lera, investigation-payload, ring-adjacent]
- id: overheard_029
register: gossip
speaker_a:
role: dock-worker
text: "Renn got new boots. Commission-grade. Renn can't afford Commission-grade boots."
speaker_b:
role: dock-worker
text: "Renn's been running extra shifts. Or extra something."
relationship_type: colleague
topic: renn-spending
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Renn has unexplained extra income. Cross-reference: ring membership payments."
tags: [renn, income, ring-adjacent, investigation-adjacent]
- id: overheard_030
register: gossip
speaker_a:
role: bar-regular
text: "Sera Venn avoids Torek every time he's here. Every single time. Noticed it three weeks straight."
speaker_b:
role: bar-regular
text: "Torek does that to people. He talks too much and says things he shouldn't."
relationship_type: colleague
topic: sera-torek-avoidance
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera Venn has a consistent avoidance pattern toward Torek Lintar. Cross-reference: behavioral.sera_avoidance_pattern."
tags: [sera, torek, avoidance, friend-arc, investigation-payload, high-value]
- id: overheard_031
register: gossip
speaker_a:
role: bar-regular
text: "Commission officer's been asking questions at The Terminal. Polite questions. Thorough ones."
speaker_b:
role: bar-regular
text: "Polite and thorough is the worst combination. That's someone who has time."
relationship_type: colleague
topic: commission-investigation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "A Commission officer is conducting a quiet investigation at The Terminal. Cross-reference: awareness.detective_presence."
tags: [commission, detective, investigation-payload, awareness]
- id: overheard_032
register: gossip
speaker_a:
role: dock-worker
text: "Lattice components went through last month. Unlicensed grade. Manifest said 'mechanical parts.'"
speaker_b:
role: dock-worker
text: "Mechanical parts. Right. People find ways when the Commission won't."
relationship_type: colleague
topic: contraband-transit
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Unlicensed lattice components are moving through The Terminal under falsified manifests. Cross-reference: contraband operation confirmed."
tags: [lattice, contraband, manifest, investigation-payload, high-value]
- id: overheard_033
register: gossip
speaker_a:
role: maintenance-tech
text: "Sub-level temp storage has been accessed four times this week. Door log shows it."
speaker_b:
role: maintenance-tech
text: "Four times and no maintenance ticket filed. Someone's using it off-book."
relationship_type: colleague
topic: unauthorized-access
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "The sub-level storage (smuggling hold) has heavy unauthorized use documented in door logs. Direct evidence of ring operations."
tags: [sub-level, access-log, ring, investigation-payload, high-value]
- id: overheard_034
register: gossip
speaker_a:
role: bar-regular
text: "Voss changed the B-section rotation. Kael and Renn are both on the overnight slot now."
speaker_b:
role: bar-regular
text: "Kael and Renn on overnight in B-section. That's a very specific combination."
relationship_type: colleague
topic: roster-combination
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Renn have been placed on the same overnight B-section rotation. This creates the oversight gap the ring needs."
tags: [kael, renn, voss, roster, ring-adjacent, investigation-payload]
- id: overheard_035
register: gossip
speaker_a:
role: dock-worker
text: "Sera asked me about container routing last week. Wanted to know who approves temp storage extensions."
speaker_b:
role: dock-worker
text: "Commission tech asking about temp storage approvals. That's outside her job scope."
relationship_type: colleague
topic: sera-investigation
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Sera Venn is conducting her own investigation into temp storage approvals, outside her Commission mandate. Cross-reference: behavioral.sera_kiosk_pattern."
tags: [sera, storage, investigation-payload, friend-arc, dual-lens, high-value]
- id: overheard_036
register: gossip
speaker_a:
role: bar-regular
text: "Naia told me Kael hasn't been sleeping. Says he's up late, doesn't explain where."
speaker_b:
role: bar-regular
text: "Naia's worried about him. Kael won't talk about whatever it is."
relationship_type: friend
topic: kael-behavioral-change
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael's behavior has changed at home. Sleepless, secretive, won't explain to Naia. Cross-reference: behavioral.kael_behavioral_change, investigation.kael_attempting_exit."
tags: [kael, naia, behavior, friend-arc, investigation-payload]
- id: overheard_037
register: gossip
speaker_a:
role: dock-worker
text: "Someone flagged a manifest discrepancy on the morning freight. Voss cleared the flag himself."
speaker_b:
role: dock-worker
text: "Voss doesn't clear flags. That's Maret's job. Or Commission's."
relationship_type: colleague
topic: manifest-flag-cleared
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss manually cleared a manifest discrepancy flag, bypassing protocol. Suggests Voss is actively covering for ring activity."
tags: [voss, manifest, flag-cleared, investigation-payload, high-value]
- id: overheard_038
register: gossip
speaker_a:
role: bar-regular
text: "Kael's been asking about exit options. Not just talk — actually asking Lera if she knows anyone who could help someone disappear quietly."
speaker_b:
role: bar-regular
text: "Kael wants out of something. That's the only reason people ask that kind of question."
relationship_type: friend
topic: kael-exit-attempt
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael is actively trying to leave 'something' — most likely the ring. He's researching options. Cross-reference: investigation.kael_attempting_exit."
tags: [kael, exit, ring-adjacent, friend-arc, investigation-payload, high-value]
- id: overheard_039
register: gossip
speaker_a:
role: dock-worker
text: "Medical lattice upgrades are showing up on the black-side. Commission-grade, no paperwork."
speaker_b:
role: dock-worker
text: "People who need them can't wait on Commission approval. Someone's doing a service."
relationship_type: colleague
topic: black-market-lattice
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Medical-grade lattice components are available outside Commission channels. Confirms the moral framing of the contraband operation — it serves real medical need."
tags: [lattice, medical, black-market, contraband, moral-framing]
- id: overheard_040
register: gossip
speaker_a:
role: bar-regular
text: "Torek told someone he had a meeting last night. Past midnight. In maintenance."
speaker_b:
role: bar-regular
text: "Torek having midnight maintenance meetings. Sure. That's completely normal."
relationship_type: colleague
topic: torek-late-meeting
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Torek met with someone in the maintenance corridors late at night. Cross-reference: investigation.torek_ring_meeting."
tags: [torek, maintenance, midnight, ring-adjacent, investigation-payload]
# ===================================================================
# BONUS PAIRS — social and work depth
# ===================================================================
- id: overheard_041
register: social
speaker_a:
role: bar-regular
text: "Lera's thinking about expanding. Back room could seat twenty more."
speaker_b:
role: bar-regular
text: "Back room's the only reason this place has any privacy. Expand it and we lose that."
relationship_type: friend
topic: bar-expansion
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, bar, atmosphere, social]
- id: overheard_042
register: work
speaker_a:
role: dock-worker
text: "Shift change is late again. Maret says weather on Velen is delaying the span gate."
speaker_b:
role: dock-worker
text: "Weather on Velen. Which means fog. Which means the morning run is going to be rough."
relationship_type: colleague
topic: weather-delay
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [velen, fog, span-gate, weather, atmosphere]
- id: overheard_043
register: social
speaker_a:
role: bar-regular
text: "Olin's getting a commendation from Commission. Ten years of service."
speaker_b:
role: bar-regular
text: "Ten years. Commission gives you a piece of paper and a handshake."
relationship_type: colleague
topic: commission-recognition
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [olin, commission, atmosphere, social]
- id: overheard_044
register: work
speaker_a:
role: maintenance-tech
text: "Power fluctuation in sub-level B last night. Lasted three minutes."
speaker_b:
role: maintenance-tech
text: "Three minutes is enough to blind the cameras if you know the timing."
relationship_type: colleague
topic: power-fluctuation
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "A power fluctuation in sub-level B temporarily disabled cameras. Could be timed to enable unobserved access."
tags: [power, cameras, sub-level, timing, investigation-adjacent]
- id: overheard_045
register: gossip
speaker_a:
role: dock-worker
text: "Commission officer asked Drin about cargo manifest irregularities. Drin told him everything he knows, which isn't much."
speaker_b:
role: dock-worker
text: "Drin doesn't know much by design. That's why Drin's on inspection detail."
relationship_type: colleague
topic: commission-inquiry
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "The Commission officer is asking dock workers directly about manifest irregularities. Drin has been interviewed. Cross-reference: awareness.detective_presence."
tags: [drin, commission, detective, interview, investigation-payload]
- id: overheard_046
register: gossip
speaker_a:
role: bar-regular
text: "Sera's been keeping a list. Someone told Naia. Private list, personal data."
speaker_b:
role: bar-regular
text: "Sera keeping a list of what? And why would Naia know?"
relationship_type: friend
topic: sera-documentation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera is documenting something privately — possibly her own investigation or evidence she's sitting on. Cross-reference: investigation.sera_unreported_evidence."
tags: [sera, naia, list, evidence, friend-arc, investigation-payload, dual-lens]
- id: overheard_047
register: social
speaker_a:
role: dock-worker
text: "Sova's been home for twenty years. Still don't know if I love it or just got used to it."
speaker_b:
role: dock-worker
text: "Twenty years is the same thing."
relationship_type: friend
topic: station-life
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [sova, atmosphere, personal, social]
- id: overheard_048
register: work
speaker_a:
role: dock-worker
text: "Voss approved overtime for two extra heads on the B-section night shift. Unusual."
speaker_b:
role: dock-worker
text: "Overtime plus extra bodies in B at night. Either something's wrong or something's being made to look right."
relationship_type: colleague
topic: overtime-approval
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss approved extra overnight staffing in B-section — possibly to create cover or provide legitimate-looking explanation for movement in that area."
tags: [voss, overtime, b-section, night-shift, investigation-adjacent]
+1
View File
@@ -83,6 +83,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Raised by:** Gestalt (schema design, Round 1/2), Mellanie (authoring validation, Round 1/2). Converged across both agents in Round 2.
- **Dissent:** None. Minor consolidations: Mellanie's 8 moods mapped to Gestalt's 8 (different names, same concepts). Mellanie's `crime` topic deliberately excluded (NPCs think of it as `cargo` or `money`).
- **Amendment (Sprint 8):** `focused` added as 9th mood (used in Kael dialogue at The Terminal and maintenance corridors). `greeting` added as 14th situation (used in PC dialogue pools for initial contact lines). Schema updated to match.
- **Amendment (Sprint 14):** Mood vocabulary renamed to match voice guide (monologue-voice-guide.md). Old → new: `fond``warm`, `comfortable``content`, `worried``anxious`, `concerned``frustrated`. Dropped: `analytical` (merged into `focused`), `conflicted` (modeled as `suspicious`+`warm` collision). Added: `hostile`. Final 8 moods: `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`. Neutral = untagged.
### D-036: Sova Transit District / Krenn System as v0.1 setting
- **Date:** 2026-02-11
+10 -7
View File
@@ -273,13 +273,16 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Raised by:** Ozzie (timing values + urgency split, adopted), Araminta (visual transition spec), Gestalt (longer values not adopted, but playtesting may adjust)
- **Dissent:** Gestalt proposed 0.8-1.2s (longer, more contemplative). Lead chose Ozzie's shorter values as starting point.
### D-061: Dialogue box — bottom screen, max 20% height, no portraits
### D-061: Dialogue box — unified conversation log, bottom screen, max 20% height, no portraits
- **Date:** 2026-02-13
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width (not percentage-based — exact value TBD). Layout: NPC speech top, player response options below, left-aligned. Max 3 response options visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness.
- **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen.
- **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Stig (UI spec + no portraits), Lead (20% height constraint + max-width directive)
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width 640px ([D-076](#d-076-dialogue-box-max-width--640px-oq-29-resolution)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness.
- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log — not separate panels for active vs passive dialogue. Player-NPC conversations and overheard NPC-NPC conversations ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)) flow into the same scrolling log. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)).
- **Entry lifecycle:** All entries share the same timeout (15s + 3s fade, configurable via theme YAML). Walk-away clears response options but preserves log entries — earned information is fair game. Panel auto-hides when all entries expire and no active conversation is in progress.
- **Passive overheard lines:** Rendered at reduced opacity (0.9) per [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter). No response options for overheard content. Walk-away does not fire for passive-only display — the panel dismisses naturally when entries expire or the player walks out of earshot.
- **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen. A single unified log avoids a separate UI element for overheard content and makes the flow of conversation feel natural — active and passive dialogue interleave chronologically.
- **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers)), max-width ([D-076](#d-076-dialogue-box-max-width--640px-oq-29-resolution)), overheard NPC conversation ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter))
- **Source:** Control & Interaction Workshop (2026-02-13). Amended Sprint 14 (#535): unified log architecture.
- **Raised by:** Stig (UI spec + no portraits), Lead (20% height constraint + max-width directive). Sprint 14 unified log: Stig (implementation).
- **Dissent:** Stig initially proposed 25% height and 50% width centered. Lead constrained to 20% height and max-width.
### D-067: Recognition chime fires at onset of cognitive delay
@@ -397,4 +400,4 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
---
*32 decisions. Last updated: 2026-02-19 (D-078: overheard NPC conversation — passive dialogue panel with occlusion filter)*
*32 decisions. Last updated: 2026-02-20 (D-061: amended with unified conversation log architecture from Sprint 14 #535)*
+573
View File
@@ -0,0 +1,573 @@
# Access Tier Shift Design Document
**Ticket:** #328 | **Feeds:** #169 (Layer 1 access tier filtering, S15)
**Authors:** Paula (narrative design), Mellanie (line content, to be co-authored)
**Date:** 2026-02-20
**Status:** Draft
**Decision cross-references:** D-028 (dialogue architecture — tagged line pools, four relational layers), D-033 (entity color = relationship to player), D-035 (converged tag taxonomy), D-062 (invisible locked dialogue options), D-063 (confrontation mechanics), D-064 (walk-away consequences), D-075 (dialogue filtering — layered confidence gate)
**Fact ID source:** `docs/design/knowledge-vocabulary-v01.md` (ticket #368)
---
## What This Document Is
This document specifies when and how a player character's access tier changes with a given social site or NPC cluster during v0.1 play. Access tiers (D-028 Layer 1) are hard filters on dialogue line eligibility — they determine *which categories of conversation* the player can have, not which specific lines they get.
**Access tier map:**
| Tier | Who Has It | Relationship State | Social Position |
|------|-----------|-------------------|----------------|
| `public` | Anyone | Unknown or any | Stranger or authority |
| `peer` | Known or Friendly | Known · Friendly | Recognized colleague, social equal |
| `insider` | Friendly only | Friendly | Trusted member, community in-group |
| `authority` | Detective only | Unknown through PersonOfInterest | Institutional leverage operative |
| `hostile` | Post-relationship-break | Hostile | Enemy, threat, someone actively managing you out |
**Key design axiom from D-062:** Players don't know what they're missing. When access tier shifts downward, locked-out content disappears silently. No "you've lost Kael's trust" notification. The absence *is* the signal. Monologue is the only permitted narrator of shift events.
---
## How Access Tier Shifts Work (Engine Model)
Access tier is derived from `RelationshipState` (per D-033) — the engine looks up the player's relationship state with the target NPC at dialogue selection time and applies the tier filter automatically. Content authors do not tag shift events; they author the content that exists on both sides of the transition.
**RelationshipState → AccessTier mapping:**
| RelationshipState | Available Tiers |
|-------------------|----------------|
| `Unknown` | `public`; `authority` (detective) |
| `Known` | `public` · `peer`; `authority` (detective) |
| `Friendly` | `public` · `peer` · `insider` |
| `PersonOfInterest` | `public`; `authority` (detective); `hostile` (if escalated) |
| `Hostile` | `hostile` only |
**What triggers a RelationshipState change** is simulation-side: NPC reactions to player behavior, witnessed events, conversation outcomes, KG fact accumulation. This document specifies the *content conditions* — the game state conditions that must be true for a transition to be meaningful and authoritatively triggered. These map to FactIds and entity attribute checks in the prerequisite system (D-035).
---
## Social Site 1: The Terminal (Logistics Hub)
### Smuggler at The Terminal
**Starting state:** `insider` + `peer` with ring members (Kael, Nils, Voss, Renn); `peer` with non-ring colleagues (Maret, Drin, Harek); `public` with strangers
The Terminal is the smuggler's home ground. They've worked here for two years. The warm `insider` access with ring colleagues is the baseline — ring operational talk, corridor scheduling, quiet coordination between shift tasks. The smuggler doesn't know what it feels like to be `public` here. They will, if things go wrong.
---
#### Transition 1-S-T: `insider → hostile` (ring turns on smuggler; cover blown)
**Direction:** Downward. The most severe possible transition.
**Narrative context:** The ring has concluded the smuggler is a liability — either actively cooperating with the Commission investigation, or so visibly compromised that they're a risk to operations. Once this threshold is crossed, ring members stop sharing operational information and begin actively managing the smuggler's exposure: giving false scheduling, monitoring their movements, possibly preparing to eject them from the ring entirely.
**Observable event that triggers transition:**
One of the following event chains completes:
1. Smuggler is observed in direct sustained conversation with the detective in a non-public context (e.g., maintenance corridor, isolated terminal bay) by a ring operative (Voss, Nils, or Renn)
2. Smuggler's cover story fails under questioning by Nils — Nils determines the smuggler knew about the detective's investigation and didn't report it
3. Ring discovers the smuggler has been observed by Commission surveillance (Sera Venn's unofficial compliance scan results surface)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.detective_presence` | `knows_of` | Ring knows a Commission detective is on station |
| `awareness.detective_investigating_ring` | `suspects` | Ring suspects (or knows) the investigation targets them specifically |
| `awareness.ring_route_compromise` | `suspects` | The current route is perceived as compromised |
| `investigation.ring_existence` | `knows_of` | (Detective's version; smuggler doesn't need this, but the simulation uses it to assess ring panic level) |
**Entity state required:**
- Ring operative (Nils or Voss) RelationshipState to smuggler: shifts from `Friendly``PersonOfInterest``Hostile`
- This chain typically passes through `PersonOfInterest` first (suspicion phase) before completing to `Hostile`
**Content requirement:**
The dialogue pool must include `hostile`-tier lines that feel like community closure, not just interpersonal conflict. Voss gives clipped operational answers. Nils stops acknowledging the smuggler in shared spaces. Renn averts eye contact. These are `hostile`-access lines tagged `situation: [shift_encounter, workplace]` that communicate ostracism through normalcy-performance rather than explicit threat.
**Monologue requirement:**
Smuggler monologue at this transition must do what D-063's pre-delivery beat does for confrontation: surface the weight before the player feels it mechanically. Suggested trigger: `observe_npc` when the smuggler sees a ring member at The Terminal post-transition.
```
trigger: observe_npc
character: smuggler
prerequisite:
relationship:
target: npc:voss # or nils / renn
state: hostile
text: "Voss walked past me without nodding. Two years on the same shift. He just... walked past."
mood: [shocked]
```
**Reversibility:** Near-irreversible within v0.1. The ring operates on operational security logic, not personal forgiveness. To reverse this, the smuggler would need to demonstrate the detective has been misled or has left the station — a scenario beyond the v0.1 scope. Flag as permanent-for-sprint for content authoring purposes.
---
#### Transition 2-S-T: `insider → peer` (ring caution mode; Kael situation escalates ring tension)
**Direction:** Partial downward. `insider` access to ring-specific operational dialogue narrows; `peer` access to social/colleague dialogue remains.
**Narrative context:** This is not a trust collapse — it's a trust contraction. As the ring senses internal pressure (Kael's unauthorized contacts, ring tension escalation), Voss and Nils begin compartmentalizing operational information. The smuggler still belongs, still gets treated like a colleague, but the operational coordination talk goes quiet. Ring members are self-protective, not hostile. They're not excluding the smuggler — they're excluding *everyone* from the sensitive operational layer while they assess.
**Observable event that triggers transition:**
- Nils or Voss observes or suspects Kael's unauthorized meeting (the same event the smuggler may discover independently in THE FRIEND arc — different observer, same event)
- Ring tension escalation manifests as shortened coordination exchanges at The Terminal
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.ring_tension_escalation` | `suspects` | Ring senses internal stress; caution spreading |
| `investigation.kael_corridor_meeting` | `suspects` | Kael seen with unknown contact (ring operative observed this, not necessarily the player) |
| `investigation.kael_unknown_contact` | `suspects` | The contact is unrecognized — potential exposure |
**Entity state required:**
- Ring operative RelationshipState to smuggler: remains `Friendly` (no state change), but `insider`-tagged dialogue pool for ring operations is gated by a ring-internal caution flag
- This is a content-authored partial exclusion, not a RelationshipState change — implemented by adding a runtime flag to the selection system that suppresses `insider, trust: real` ring-coordination lines even for Friendly NPCs
> **Implementation note for #169:** This transition requires a ring-internal state variable (`ring_on_caution`) that the content team will need to reference in prerequisite format. Proposed fact: `awareness.ring_tension_escalation` at `knows_of` triggers this suppression server-side. Content authors should tag ring-operational insider lines with `tags: [ring-coordination]` and the server system can suppress that tag cluster when the caution flag is active.
**Content requirement:**
Two parallel dialogue pools needed: ring-coordination pool (suppressed when `ring_on_caution` active) and social-colleague pool (always available while Friendly). The player notices that Voss stops mentioning shift windows. Kael stops asking about container routing. The conversation continues, but operational content disappears.
**Reversibility:** Yes. If ring tension resolves (e.g., Kael's situation normalizes, the threat passes), operational talk resumes. Reversible via `awareness.ring_tension_escalation` falling below `suspects` threshold — which in practice means the ring stops tracking the anomaly.
---
#### Transition 3-S-T: `peer → peer` to `hostile` (non-ring colleagues react to cover exposure)
**Direction:** Downward, distinct pathway from Transition 1-S-T.
**Narrative context:** Maret Korr, Harek, and legitimate dock workers don't know about the ring. Their hostility toward the smuggler comes not from ring logic but from community logic: they discover (via detective's investigation becoming public, or visible confrontation at The Terminal) that someone they trusted was running contraband operations through their workspace. Maret, who's been a scheduling colleague for two years, feels used.
**Observable event that triggers transition:**
- Detective's investigation becomes visible enough at The Terminal that legitimate workers understand what's been happening
- Required: detective has interrogated Maret or Drin visibly (witnessed by other dock workers)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.ring_existence` | `knows_of` | Legitimate workers now know a ring was operating |
| `investigation.manifest_discrepancy` | `knows_of` | The discrepancies are understood as intentional, not clerical |
**Reversibility:** Moderate difficulty. Unlike ring-member hostility (operational logic), this hostility is personal-moral. If the smuggler demonstrates they weren't a core operator (partial truth), some colleagues might return to neutral `public` tier.
---
### Detective at The Terminal
**Starting state:** `authority` with Voss, Maret, Drin; `public` with most hub workers; no `insider` access (investigator is always an outsider to the ring's in-group)
The Terminal is adversarial ground for the detective. Authority access gives institutional leverage — the detective can ask hard questions, invoke Commission standing, access records — but it creates social friction. Every exercise of authority makes the `peer` path harder.
---
#### Transition 1-D-T: `authority → peer` (collaboration event; institutional authority softens to personal rapport)
**Direction:** Sideways (not strictly up or down — `authority` and `peer` provide different content, not hierarchically ranked).
**Narrative context:** This transition is the detective learning to work *with* the community rather than on top of it. A specific collaboration event — helping Maret with a non-smuggling problem, or letting a minor infraction go — signals to a hub worker that the detective is operating in good faith. The hub worker begins treating the detective as a person, not a badge. This unlocks `peer`-tier content from that NPC: honest opinions, unguarded speech, actual feelings. These are the lines that contain the most useful investigative texture.
**Observable event that triggers transition:**
- Detective witnesses or intercepts a minor infraction unrelated to the ring investigation (dock worker running personal cargo through, Harek's unofficial equipment borrowing) and explicitly does not file it
- OR: Detective helps Maret resolve a scheduling conflict caused by simulation-generated NPC behavior (non-ring-related problem), giving Maret reason to feel reciprocal goodwill
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.ring_existence` | `suspects` | Detective is on station for a reason; Maret knows this |
| `social.bar_regular_status` | `suspects` | Detective is becoming a known presence on Sova (not bar-specific, but signals integration) |
**Entity state required:**
- Target NPC (Maret, most likely): RelationshipState transitions `Known``Friendly`
- Once `Friendly`, `peer`-tier dialogue becomes available; `authority`-tier remains available simultaneously (detective can shift registers within a conversation)
**Content requirement:**
`peer`-tier Maret dialogue should feel warmer, more candid, and more useful than `authority`-tier Maret dialogue — but for different reasons. `authority` lines give formal, procedurally correct answers. `peer` lines give informal reads: "Voss has been different lately. Can't say why. Just tighter." That line is `trust: real`, `access: [peer]`.
**Reversibility:** Yes, but fragile. If detective subsequently uses institutional leverage against Maret (authority interrogation, official filing of any minor infraction), RelationshipState may revert to `Known` and `peer` access narrows back to `authority`.
---
#### Transition 2-D-T: `authority → hostile` (confrontation fails; community closes ranks)
**Direction:** Downward. The most damaging investigative outcome — hub workers who were cooperating under authority pressure now actively stonewall.
**Narrative context:** If the detective uses institutional leverage badly — pressing too hard in a way that's visible to other workers, making an accusation that doesn't stick, or invoking Commission authority in a situation where the community reads it as overreach — the hub shifts from grudging cooperation to collective non-cooperation. This isn't ring coordination; it's community immune response. Dock workers closing ranks around their own.
**Observable event that triggers transition:**
- Detective conducts a visible confrontation (D-063) with Voss or Maret in a public area of The Terminal, the confrontation fails (NPC successfully deflects, detective doesn't have sufficient evidence), and other workers witness the exchange
- OR: Detective formally reports a minor infraction that hub workers considered a normal part of life — the report reads as persecution, not investigation
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.oversight_gap_pattern` | `suspects` | Detective has been probing, not finding clean answers |
| `awareness.commission_audit_scheduled` | `knows_of` | Hub workers know a formal audit is coming; fear is already elevated |
**Key design tension — confrontation + walkaway (D-063/D-064):**
A confrontation that the detective walks away from (WASD during exchange) doesn't just end the confrontation — the KG records incompleteness. If the detective starts a confrontation with Voss and then leaves mid-exchange, Voss's `contradiction_flagged` attribute gets set, and the ring interprets this as: the detective has partial evidence and couldn't follow through. This triggers ring defensiveness *faster* than a completed confrontation. Content authors should note: `investigation.shift_mismatch` at `knows_of` + an incomplete confrontation event = ring caution acceleration.
**Content requirement:**
Post-hostile Terminal NPCs need `hostile`-tier dialogue that sounds like bureaucratic compliance, not aggression. "Shifts are logged in the system." "You'll want to check with oversight." "I don't have anything more for you." These are lines that technically cooperate while providing nothing — the institutional version of closed doors.
**Reversibility:** Moderate difficulty. The detective would need to either produce evidence that justifies the confrontation retrospectively (making the community feel the investigation was warranted) or wait long enough for the community's defensive posture to relax. Within v0.1 timeline: effectively permanent once triggered.
---
## Social Site 2: The Last Shift (Bar)
### Smuggler at The Last Shift
**Starting state:** The smuggler's starting tier at The Last Shift depends on their history with the bar. If played as a regular (the intended interpretation), they begin at `peer` with most bar regulars and `insider` with Lera (ring-aware, bar owner who runs quiet coordination). With strangers or recent arrivals (Sera Venn, Commission-adjacent individuals), they begin at `public`.
The bar is a social pressure valve. It's where the district's people become people. The smuggler is comfortable here — but comfort makes the downward transition more disorienting.
---
#### Transition 1-S-B: `public → insider` (Kael introduction; bar community adopts smuggler)
**Direction:** Upward. Unusual in the design — most documented transitions are downward. This one represents the smuggler extending their community standing into the bar's inner circle.
**Narrative context:** The smuggler knows Lera and the ring-aware bar regulars already. But Kael's social circle at the bar — Naia, her friends, some bar regulars who are ring-adjacent but not ring members — is a separate network. Kael making a formal introduction signals to this circle that the smuggler belongs. This unlocks insider-tier content from Naia and her social cluster: actual feelings, honest opinions, personal context. Naia's worry about Kael surfaces here.
This transition is the mechanism by which the smuggler gains access to the warning signs of Kael's situation before the corridor B-7 contradiction. Naia is visibly anxious. Her concern is `insider`-tier content — she wouldn't share it with a stranger.
**Observable event that triggers transition:**
- Kael and the smuggler are both present at The Last Shift; Kael initiates introduction exchange ("She's on my shift, been here as long as I have") with Naia or her immediate social circle
- This is a simulation-generated interaction — Kael's routine includes this social gesture if his relationship with the smuggler is `Friendly` and his relationship with Naia is `Friendly`
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `social.bar_regular_status` | `suspects` | Smuggler is becoming a regular; the introduction lands as socially coherent |
| `relationship.kael_naia_connection` | `knows_of` | Smuggler knows Kael and Naia are connected; the introduction isn't out of nowhere |
**Entity state required:**
- Kael RelationshipState to smuggler: `Friendly`
- Naia RelationshipState to smuggler: `Unknown``Known` (minimum for transition to trigger)
- Full `insider` access to Naia unlocks at `Known``Friendly` (second threshold, requires sustained positive interaction)
**Content requirement:**
Naia's `insider`-tier content at The Last Shift should surface her anxiety about Kael in a way that doesn't explain it — it creates the question without the answer. "He's been working late. I don't... he gets like this sometimes." Tagged `trust: real`, `access: [insider]`. This is the content the smuggler can't get as a stranger, and it's the warning they carry into the corridor B-7 observation.
**Reversibility:** Yes. If the smuggler stops visiting the bar, Naia's RelationshipState drifts back to `Known` over time. The `insider` access requires sustained interaction.
---
#### Transition 2-S-B: `insider → hostile` (social contamination from ring crisis)
**Direction:** Downward. Social contamination spreads from ring context to bar context.
**Narrative context:** The bar is where ring community and legitimate community overlap. When the ring turns on the smuggler, the contamination spreads through this overlap. Lera stops holding the smuggler's usual seat. Torek stops buying rounds. Bar regulars who are ring-adjacent start looking uncomfortable when the smuggler arrives. This isn't organized — it's social contagion. Lera doesn't want trouble at her bar. The regulars follow her lead.
**Observable event that triggers transition:**
- Ring has already moved to `hostile` tier with the smuggler at The Terminal (Transition 1-S-T has completed)
- A ring member (Renn or Torek) is present at the bar when the smuggler arrives and visibly changes behavior — leaves, signals to Lera, or gives the smuggler a warning look
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.ring_tension_escalation` | `knows_of` | Bar regulars who are ring-adjacent have sensed something's wrong |
| `awareness.ring_route_compromise` | `suspects` | The ring is protecting its operational footprint |
**Entity state required:**
- At least one ring-adjacent bar regular (Torek or Renn): RelationshipState to smuggler has moved to `PersonOfInterest` or `Hostile`
- Lera: RelationshipState shifts from `Friendly``Known` (bar owner withdraws warmth without active hostility; bar is still `public` accessible, but the home feeling is gone)
**Secondary contamination — Naia:**
If Naia has learned (through Kael or indirect observation) that the smuggler is suspected by the ring, her RelationshipState also shifts. Naia's hostility is different from the ring's — it's protective fear, not operational defense. She doesn't want to lose Kael to whatever the smuggler has gotten caught up in. Content for Naia at this stage should feel like hurt and worry compressed into withdrawal, not anger.
**Content requirement:**
The `hostile`-tier bar content is some of the most important emotional writing in the game. The bar was comfort. The bar is now hostile territory with familiar faces. Lera's lines should feel like professional neutral — no warmth, no cold, just a bartender doing her job. Torek's lines (if present) should feel like he's trying to warn the smuggler while staying deniable. This requires careful access tagging: Torek's warning is `access: [peer]`, `trust: real` — he'll only deliver it if the player is still `Known` with him, not yet `Hostile`.
**Reversibility:** Hard. Requires terminal-level crisis to resolve first. The bar follows the ring's social signal.
---
#### Transition 3-S-B: `insider → hostile` (contraband conversation overheard by hostile observer)
**Direction:** Downward, distinct from Transition 2-S-B. This is an in-bar event, not contamination from outside.
**Narrative context:** The bar creates a specific risk: ring-relevant conversation in a space with poor sound isolation. D-078 established that NPC-to-NPC conversations are Voice events the player can overhear — the same applies in reverse. If the smuggler and a ring member have an `insider`-tier conversation at The Last Shift within earshot of an unfriendly observer (the detective, a Commission-tagged NPC, or a bar regular who's been marked as informant), the result is catastrophic.
**Observable event that triggers transition:**
- Smuggler and ring member (Kael, Voss, or Renn) are in `insider`-tier dialogue at The Last Shift
- A third party with `PersonOfInterest` or `Hostile` relationship to the ring is within Voice range (D-018 three-range sound model)
- The overheard content triggers a KG fact update for the observer
**Required KG facts at transition point (observer's perspective):**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.ring_existence` | `suspects` | Observer already suspected a ring; the conversation confirms it |
| `contraband.ring_lattice_components` | `suspects` | Observer now understands what's being moved |
**The asymmetry:** From D-018's three-range model, the player controls whether they're within Voice range of overheard conversations — but NPCs don't know they're being overheard. If the detective is nearby and the player (as smuggler) initiates ring-coordination dialogue, this is a player error with consequence. The simulation shouldn't make this easy to do accidentally, but it should make the consequence clear and immediate.
**Reversibility:** None. Fact knowledge is retained by the observer. Once the detective has `contraband.ring_lattice_components` at `knows_of`, that confidence doesn't decay to zero.
---
### Detective at The Last Shift
**Starting state:** `public` with bar regulars; `peer` with Sera Venn (pre-existing contact, established friendship); `authority` over Torek (ring-adjacent, financial tells create leverage potential)
The bar is the detective's only social toehold. Sera is the social anchor. Every other transition at the bar depends on either sustaining Sera's goodwill or making it on personal terms.
---
#### Transition 1-D-B: `public → peer` via Sera Venn (social introduction; district integration)
**Direction:** Upward.
**Narrative context:** Sera is the detective's guide to the bar's social geography. She knows the regulars, knows the history, knows who's approachable and who isn't. When she introduces the detective to bar regulars — "We work the same beat, sort of" — she's vouching for the detective as a person, not a badge. This shifts the detective from institutional stranger to known face. `peer`-tier bar content (gossip, complaints, personal context) unlocks.
This is the primary path to bar integration. The buy-rounds path (Transition 2-D-B) is secondary and slower.
**Observable event that triggers transition:**
- Detective has visited The Last Shift 2+ times with Sera present
- Sera is in `Friendly` RelationshipState with detective
- On the third or later visit, Sera initiates an introduction event with a bar regular (Lera, most likely, or a mundane-triangle bar regular)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `social.bar_regular_status` | `suspects` | Detective is becoming a recognized face |
| `behavioral.sera_kiosk_pattern` | `knows_of` | Detective knows Sera's habits — signals the relationship has texture |
**Entity state required:**
- Sera RelationshipState to detective: `Friendly`
- Target bar regular RelationshipState to detective: `Unknown``Known` (introduction creates Known; Friendly requires follow-up interaction)
**Sera dependency risk:** If Sera moves to `PersonOfInterest` (Phase 3 of THE FRIEND arc), this introduction path is compromised. Sera still may be physically present, but her social vouching becomes ambiguous — she might introduce the detective, but bar regulars who are sensitive to institutional dynamics may read the introduction differently. This creates a content authoring requirement: bar regular reactions to Sera-mediated introductions should have two variants — pre-contradiction and post-contradiction.
**Content requirement:**
`peer`-tier bar content for the detective should feel like the community briefly letting the badge off the hook. Lera's peer lines are informal and dry: "You're still here." "Thought Commission people didn't do grain spirit." This is trust-as-tolerance, which is honest to the bar's relationship with institutions.
**Reversibility:** Yes, but depends entirely on Sera's RelationshipState. If Sera's contradiction is confronted badly and she moves to hostile tier, the detective loses the introduction pipeline and must earn bar integration directly.
---
#### Transition 2-D-B: `authority → peer` after buying rounds (social gesture; institutionality dropped)
**Direction:** Sideways, same dynamic as Transition 1-D-T but in the bar context.
**Narrative context:** The detective has authority access to Torek (ring-adjacent, financial tells create leverage). But authority access at a bar is uncomfortable — for everyone, including the detective. At some point the detective can choose a different register: stop invoking institutional leverage and just buy a round. This social gesture — explicitly off-the-record, no questions, just drinks — shifts a specific NPC from authority-tier to peer-tier for that interaction and potentially permanently if sustained.
This is the slower path to bar integration — not mediated by Sera, but earned directly through social investment.
**Observable event that triggers transition:**
- Detective is at The Last Shift with Torek present
- Detective initiates a non-investigative social exchange (no use of `authority`-tagged dialogue options for 2+ consecutive conversations)
- Buys drinks as a social action (if implemented as an interaction verb in the v0.1 interaction set)
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `social.bar_regular_status` | `knows_of` | Detective is now recognized as a regular, not a visitor |
**Entity state required:**
- Torek RelationshipState to detective: `Known``Friendly`
- Once `Friendly`, `peer`-tier content unlocks; `authority`-tier remains available but the player must actively choose it
**The buy-rounds mechanism:** The interaction system (D-interaction-verbs) needs a "buy drinks" verb that registers as a positive social action toward present NPCs without triggering dialogue. This is a goodwill investment that accumulates toward RelationshipState advancement. For #169 (S15 implementation): this should be a flagged interaction event that the simulation picks up and applies as relationship credit to all `Known`+ NPCs in proximity.
**Torek content note:** Torek is ring-adjacent and has financial tells (`investigation.torek_spending_pattern`). Peer-tier Torek content is valuable for the investigation — he's more likely to let something slip in casual conversation than under authority questioning. The buy-rounds path is a legitimate investigative technique, not just social filler. Torek's `peer, trust: real` lines should carry genuine investigative texture.
**Reversibility:** Yes. If the detective subsequently invokes authority leverage against Torek (raises `investigation.torek_spending_pattern` as direct confrontation material), Torek's RelationshipState reverts. The trust investment is not permanent.
---
#### Transition 3-D-B: `peer → authority` (Sera arc activates; trust contaminated)
**Direction:** Sideways. The detective's relationship with Sera shifts from warm peer-tier to analytically-charged authority-tier as THE FRIEND arc progresses.
**Narrative context:** Per D-063 and the FRIEND arc specification, when Sera moves to `PersonOfInterest` (Phase 3 of the detective's FRIEND arc), the detective gains access to `authority`-tier dialogue with Sera — institutional leverage questions, direct evidence-seeking. This is distinct from standard authority access (the detective doesn't arrest Sera, doesn't invoke formal Commission processes). The authority content here is personal authority: "I know you know something. I'm asking you." The formality is in the weight of the question, not the institutional mechanism.
**Observable event that triggers transition:**
- Detective observes Sera leave when Torek arrives for the third time (or any two confirmed avoidance events matching the same pattern)
- `behavioral.sera_avoidance_pattern` reaches `knows_of`
- Monologue triggers Phase 3 recognition: "That's the third time."
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `behavioral.sera_avoidance_pattern` | `knows_of` | Pattern is confirmed, not suspected |
| `behavioral.sera_topic_deflection` | `suspects` | Sera has deflected at least one sensitive topic |
| `investigation.sera_evidence_held` | `suspects` | Detective senses Sera knows something she hasn't shared |
**Entity state required:**
- Sera RelationshipState to detective: `Friendly``PersonOfInterest`
- `peer`-tier Sera content remains available (it doesn't vanish), but `authority`-tier content unlocks alongside it
- The player can choose which register to use — and the choice has consequences (D-064 walk-away logic applies: if detective initiates authority-tier inquiry and then drops it, the KG logs the incompleteness)
**Content requirement:**
The `authority`-tier Sera dialogue should feel qualitatively different from authority-tier dialogue with Voss or Maret. Voss authority lines are evasive, procedural. Sera authority lines are emotionally charged — she knows the detective knows, and she's working to contain that. Her `authority, trust: surface` lines are careful, calibrated. Her `authority, trust: real` lines (if unlocked) are the closest she gets to disclosure: "Some things aren't mine to report. You know how it is." This is the line that changes everything.
**Reversibility:** Conditional. If the detective chooses not to press (avoids authority-tier options with Sera across 2+ subsequent interactions), Sera's RelationshipState may re-stabilize at `Friendly`. But `investigation.sera_evidence_held` remains in the KG at `suspects` — the detective knows something is wrong, even if the relationship surface normalizes.
---
## Social Site 3: Maintenance Corridors (Smuggling Spaces)
### Smuggler at Maintenance Corridors
**Starting state:** `insider` with all ring members (Kael, Nils, Renn); this is the ring's operational space. The smuggler knows the layout, the camera gaps, the timing windows.
---
#### Transition 1-S-M: `insider → hostile` (corridor operations suspended; smuggler shut out)
**Direction:** Downward.
**Narrative context:** If ring crisis reaches operational shutdown level (investigation too hot, Kael situation destabilizing the ring's trust structure), Nils may suspend corridor operations entirely or change the access protocol. The smuggler's knowledge of the old layout becomes liability — they know where the blind spots are, but those blind spots may have been deliberately exposed to flush out threats. The smuggler navigating the corridors under these conditions is walking into a trap.
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `awareness.ring_tension_escalation` | `knows_of` | Operations are suspended or restructuring |
| `awareness.surveillance_change` | `suspects` | The surveillance pattern has changed |
| `investigation.kael_ring_membership` | `knows_details` | Kael's exit attempt is now broadly known inside the ring |
**Reversibility:** No. If the ring has suspended the smuggler's access, that's a terminal state for their ring membership in v0.1.
---
#### Transition 2-S-M: `insider → isolated` (Kael not present; smuggler has operational access but no coordination)
**Direction:** Partial downward. Technical access to the corridors remains, but social coordination disappears.
**Narrative context:** If Kael is the smuggler's primary coordination contact in the corridors and Kael has shifted to `PersonOfInterest` (Phase 3-4 of THE FRIEND arc), the smuggler navigates the corridors without their usual partner. They have access. They don't have backup. Monologue in this state should reflect operational vulnerability without dialogue partner.
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `investigation.kael_corridor_meeting` | `knows_of` | Smuggler has discovered Kael's unauthorized contact |
**Entity state required:**
- Kael RelationshipState to smuggler: `PersonOfInterest`
**Content requirement:**
No `insider` Kael dialogue in corridors post-transition. Instead: monologue lines triggered by locations where Kael would normally have been present. The absence of a colleague, not a confrontation.
**Reversibility:** Yes, if THE FRIEND arc resolves toward reconciliation.
---
### Detective at Maintenance Corridors
**Starting state:** `public`; the corridors are officially restricted (`location.corridor_b7_restricted` is `knows_of`), but the detective has institutional basis to enter with credentials or cause.
---
#### Transition 1-D-M: `public → peer` (legitimate investigative access established)
**Direction:** Upward.
**Narrative context:** The detective earns corridor access not through breaking in but through institutional process — flagging the surveillance gaps, getting Maret's cooperation, or invoking Commission authority to access restricted zones. Once inside legitimately, NPCs who are present (maintenance workers, off-duty dock workers moving through) treat the detective as a known investigator rather than a suspicious intruder.
**Required KG facts at transition point:**
| Fact ID | Min Confidence | Role in Transition |
|---------|---------------|-------------------|
| `location.corridor_b7_restricted` | `knows_of` | Detective knows the official access structure |
| `investigation.surveillance_gaps` | `knows_of` | Detective has identified the pattern — not just noticed one gap |
| `investigation.oversight_gap_pattern` | `suspects` | Investigation gives the detective standing to be in the corridors |
**Reversibility:** Yes, conditional on investigation standing.
---
#### Transition 2-D-M: `public → hostile` (trespassing observed; ring response)
**Direction:** Downward. The most immediate downward transition in the entire tier system — ring operatives in the corridors are not subtle.
**Narrative context:** If the detective enters the corridors without establishing legitimate access, and a ring operative (Renn is the most likely; Nils if the drop is tonight) observes the intrusion, the ring defensive response is immediate. They don't confront the detective physically in v0.1 — they evacuate, alert, and begin managing their exposure. The detective has triggered ring paranoia, which both accelerates the investigation (the ring is now visible through its defensive behavior) and destroys any chance of observation before the ring goes dark.
**Required KG facts at trigger point:**
| Fact ID | Min Confidence | Role |
|---------|---------------|------|
| `location.maintenance_corridors_access` | `suspects` (detective) | Detective knows approximately where the corridors are |
| `contraband.drop_tonight` | `suspects` (smuggler/ring) | If a drop is scheduled, ring presence in corridors is high |
**The observation paradox:** The most investigatively valuable time to enter the corridors is when a drop is scheduled (`contraband.drop_tonight` at `knows_details` for the smuggler). The most dangerous time is the same. Content for this transition should reflect that the detective's discovery is genuinely useful (they may observe the drop in progress, gain `investigation.smuggling_route` at `knows_of`) even as it triggers hostile ring response.
**Reversibility:** None within v0.1. The ring will not forget an investigator in the corridors.
---
## Reversibility Summary
| Transition | Reversible? | Reversal Condition |
|-----------|------------|-------------------|
| 1-S-T: `insider → hostile` (ring burns smuggler) | No | Ring trust requires full departure from investigation pressure — out of scope v0.1 |
| 2-S-T: `insider → peer` (ring caution mode) | Yes | `awareness.ring_tension_escalation` falls below `suspects`; ring stabilizes |
| 3-S-T: `peer → hostile` (legitimate colleagues react) | Moderate | Smuggler demonstrates non-core role; social repair over time |
| 1-D-T: `authority → peer` (collaboration at Terminal) | Yes, fragile | Detective re-invokes authority leverage; trust reverts |
| 2-D-T: `authority → hostile` (confrontation fails) | Moderate | Retrospective evidence justifies the confrontation; community accepts it |
| 1-S-B: `public → insider` (Kael introduces smuggler at bar) | Yes | Requires sustained bar attendance; decays if absent |
| 2-S-B: `insider → hostile` (social contamination from ring) | Hard | Requires ring crisis to resolve first |
| 3-S-B: `insider → hostile` (overheard contraband talk) | None | Fact knowledge is permanent in detective's KG |
| 1-D-B: `public → peer` via Sera (bar integration) | Yes | Depends on Sera RelationshipState; breaks if Sera moves hostile |
| 2-D-B: `authority → peer` (buy-rounds path) | Yes | Detective reinvokes authority; trust reverts |
| 3-D-B: `peer → authority` (Sera arc activates) | Conditional | Detective avoids pressing; surface normalizes, but KG suspicion persists |
| 1-S-M: `insider → hostile` (corridors suspended) | No | Terminal state for ring membership |
| 2-S-M: `insider → isolated` (Kael absent) | Yes | Kael arc resolves toward reconciliation |
| 1-D-M: `public → peer` (investigative access) | Yes | Investigation standing maintained |
| 2-D-M: `public → hostile` (trespassing) | None | Ring defensive response is permanent |
---
## Content Authoring Implications
### Rule: Every social site needs three pools per character
For each of the three social sites, content authors must produce:
1. **Starting-state pool** — the baseline access tier, representing the first hour of play. Heavy on `public` and `peer` for the detective; heavy on `insider` and `peer` for the smuggler. This is the content that makes the world feel normal.
2. **Shifted-state pool** — dialogue for the 1-2 most likely tier transition per character per site. The hostile-tier ring dialogue. The peer-tier bar content after Sera's introduction. These pools are smaller but carry heavier narrative weight.
3. **Monologue tracking pool** — internal voice lines that run parallel to access tier changes. The smuggler noticing that Voss didn't nod. The detective noticing that bar regulars are warmer. Monologue is the only system that communicates access tier shift to the player (per D-062 — no UI signals, no notifications).
### The hostile-tier authoring challenge
`hostile`-tier dialogue is the hardest to write. The temptation is to write it as confrontational, explicit. But most hostile-tier dialogue in this setting is *bureaucratic hostility* — people performing cooperation while providing nothing. Maret gives the detective technically accurate answers that advance nothing. Voss gives the smuggler shift updates that contain no ring coordination. Lera pours drinks without warmth.
The exception: confrontation-specific lines (D-063). These are the lines written in the character's internal voice, italicized, first-person. *"I saw you in corridor B-7."* These require their own pool: `access: [hostile], trust: real, situation: [confrontation]`.
### The transition-moment authoring challenge
There is a brief, critical window around tier transitions where monologue should surface what the system cannot say. When Kael's `insider` content locks out, the first post-transition observation of Kael should trigger a monologue line that acknowledges the absence without naming the mechanism. The player shouldn't think "I've lost insider access to Kael." They should think "something is different."
Suggested approach: author a `post_conversation` monologue line for each major downward transition that fires once on the first post-transition interaction. These are `priority: 9` lines (high priority, likely to be selected) that serve as narrative transition markers.
---
## Implementation Notes for #169 (S15 Dependency)
This document feeds the Layer 1 access tier filtering implementation (deferred to Sprint 15). The following architectural notes are for Dudley/Tyre when implementing:
1. **The ring-caution suppression** (Transition 2-S-T) requires a runtime flag — not a RelationshipState change, but a content-tag suppression. The proposed mechanism: a `ring_caution_active` boolean in the simulation state that the line selection system checks before returning `insider, tags: [ring-coordination]` lines. FactId gate: `awareness.ring_tension_escalation` at `knows_of`.
2. **The buy-rounds interaction** (Transition 2-D-B) requires a non-dialogue interaction verb that registers social goodwill. The v0.1 interaction verb set (D-interaction-verbs) should include "buy drinks" as a social investment action. Simulation should apply a small positive relationship_state weight to all `Known`+ NPCs in Voice range.
3. **The `authority`-to-`peer` coexistence** (Transitions 1-D-T, 1-D-B, 2-D-B) is an important design point: these are not mutually exclusive tiers. When a detective NPC relationship reaches `Friendly`, both `authority` and `peer` content should be available. The selection system should prefer `peer` in casual contexts and `authority` when the player selects investigative dialogue options. The selection algorithm needs a context signal for this (conversation-type flag, or topic tag check).
4. **The overheard-conversation hostile trigger** (Transition 3-S-B) is the only tier shift triggered by the player's presence in Voice range rather than by direct interaction. This requires the D-018 three-range sound model implementation to cross-reference the player's current relationship states with nearby NPCs. If the player is in Voice range of ring-coordination NPC dialogue and the NPC is in `PersonOfInterest`-or-better relationship with an observer also in range, the observer's KG should be updated.
---
*Document current as of 2026-02-20. All FactIds sourced from `docs/design/knowledge-vocabulary-v01.md` (#368). Implementation dependency: #169 (S15). Content dependency: #120 (line pool files), #121 (voice variation guide).*
+220
View File
@@ -0,0 +1,220 @@
# Entity Color System — Visual Specification
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #304
**Status:** Active — input to client implementation (future sprint), constrains #318 (THE FRIEND visual treatment)
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§3), Decision D-033
---
## 1. Core Principle
Entity color encodes the **player character's subjective relationship** to an NPC — not an objective property of the NPC. The same entity can appear as different colors to the detective and the smuggler simultaneously. Color is a **client-side derivation** from the `RelationshipState` field on each `VisibleEntity` in the server's `ObserverSnapshot`. The server never says "this NPC is hostile" — it sends the relationship state the client derives the color from.
This is asymmetric information rendered visually. It is the most important single system in the game to get right because it is the visual language through which the player reads the world.
---
## 2. Relationship → Color Mapping
### 2.1 Canonical Palette
| RelationshipState | Color name | Hex | Visual quality | Notes |
|-------------------|-----------|-----|----------------|-------|
| `Unknown` | Cool teal | `#4a9ebb` | Default for unassessed entities. Cooler than sky, not clinical. | Every new NPC the player hasn't built a view of |
| `Known` / `Friendly` | Soft green | `#6bc9a6` | Trusted. Known person. Not safe — just trusted. | People the character knows and has reason to trust |
| `PersonOfInterest` | Warm amber | `#e8c547` | Monologue or case file has flagged something. | Not necessarily hostile — flagged |
| `Hostile` | Muted red | `#d45d5d` | Player character perceives subjective danger. Not omniscient. | **Red means danger TO YOUR CHARACTER**, not danger in the abstract |
### 2.2 Non-Entity Colors
| Entity type | Color name | Hex | Notes |
|-------------|-----------|-----|-------|
| Static objects | Muted grey | `#8b8ba0` | See §5 for what counts as static |
| Player — Detective | Cool blue-white | `#e0e8ff` | Near-neutral. "Self." No relationship loading. |
| Player — Smuggler | Warm cream | `#e8e0d0` | Near-neutral. "Self." Slightly warmer than detective. |
### 2.3 Color Psychology Notes
The palette was selected for functional warmth (D-043), not conventional danger-coding. Teal (`#4a9ebb`) reads as neutral-curious rather than cold. Green (`#6bc9a6`) reads as familiar rather than "good." Amber (`#e8c547`) reads as noteworthy rather than "warning." Red (`#d45d5d`) is muted — subjective danger, not objective alarm.
This matters because the detective might see amber where the smuggler sees green for the same NPC. Both are correct. Neither color is lying — they are rendering different epistemic positions.
---
## 3. Runtime Derivation
### 3.1 Data Source
The server sends each `VisibleEntity` in the `ObserverSnapshot` with a `relationship: RelationshipState` field (`server/src/bridge/types.rs`, `VisibleEntity` struct, line 258). This field is computed per-observer on the server using the observer's knowledge graph — it is NOT a shared NPC property.
`RelationshipState` variants:
- `Unknown` — no assessment, or newly visible
- `Known` — character has a relationship but nothing flagged
- `PersonOfInterest` — knowledge graph or case file has flagged this entity
- `Hostile` — character perceives active danger from this entity
### 3.2 Client Lookup
`entity_renderer.gd` maps `RelationshipState` to color via `Constants.color_for_entity_kind()`. The current implementation (`_color_for_kind`, line 181) delegates to this function. The full color lookup should follow:
```
RelationshipState → Color
Unknown → #4a9ebb
Known / Friendly → #6bc9a6
PersonOfInterest → #e8c547
Hostile → #d45d5d
Static object → #8b8ba0
Player entity → #e0e8ff (detective) or #e8e0d0 (smuggler)
```
The player's own entity is identified via `GameState.player_entity_id`. Player color does not participate in the relationship lookup — it is a fixed constant per character selection.
---
## 4. Transition Behavior
### 4.1 Standard Transition
When an entity's `RelationshipState` changes between snapshots, the color shift is a **0.5-second smooth fade** (linear lerp). This is already implemented in `entity_renderer.gd` via the `_entity_tweens` dictionary and `COLOR_FADE_DURATION = 0.5` constant.
**Never use an instant color swap.** The visual transition is part of the information delivery — the player reads the relationship changing as a small dramatic moment.
### 4.2 THE FRIEND's First Shift — Staged Priority
THE FRIEND's transition from `Known/Friendly` (green `#6bc9a6`) to `PersonOfInterest` (amber `#e8c547`) must be the **first relationship color change** the player observes in the session. The opening 2025 minutes of gameplay must be staged so no other NPC's relationship state changes before THE FRIEND's shift.
This requires narrative coordination: the player must have enough time with THE FRIEND as green to internalize what green means. When amber arrives, the player has a reference point. Without that contrast, the color change means nothing.
See `docs/design/the-friend-visual-treatment.md` (#318) for staging details.
### 4.3 Edge Cases
**Entity in fog (unrecognized):** D-033 colors do **not** show through fog for unrecognized entities. An unrecognized entity in fog renders as a neutral grey `#555566` blob with no silhouette features. Once the cognitive delay resolves (D-060, 0.6s base) and recognition completes, the blob transitions to the entity's D-033 color + identifying silhouette feature.
**Entity in fog (recognized):** If the character recognizes an entity in fog (their knowledge graph identifies them), the insert overlay (z-layer 6) can show a D-033 color glow + faint silhouette feature at ±0.5 tile approximate position. This is insert data, not visual data — the character knows where they were, not where they are.
**Entity at periphery:** Per D-015 (forward/peripheral/blind vision sectors), entities in the peripheral vision zone are rendered at reduced saturation and alpha. The `modulate.a` value is `Constants.PERIPHERAL_ALPHA` when `visibility == "Peripheral"`. This is already implemented in `entity_renderer.gd`. The D-033 color itself does not change — saturation reduction is handled via the visibility dimming, not a color override.
**Simultaneous transitions:** Two entities changing relationship state in the same tick each get independent 0.5s tweens. There is no synchronization between entity transitions. This is intentional.
---
## 5. Static Objects — Definition and Color
### 5.1 What is a Static Object
Static objects are non-person entities that do not have a relationship to the player and are not part of the NPC simulation. They always render at `#8b8ba0` regardless of any game state.
**Static objects (always `#8b8ba0`):**
- Furniture: chairs, tables, desks, counters, crates
- Fixtures: terminals, consoles, lockers, shelving
- Environmental: doors (in closed state), hatches, panels
- Props: datapads, mugs, comms units, cargo containers
**Not static objects (use D-033 colors):**
- All NPCs including Background-tier
- Player characters
- Carried items (if items become carriable entities, they follow their carrier's relationship color — this is a future sprint consideration, default to static color in v0.1)
### 5.2 Why Grey, Not Zone Palette
Static objects use `#8b8ba0` rather than blending into zone floor/wall colors because they need to be visually distinct from the environment while remaining subordinate to entity colors. Grey sits between structure (very low saturation zone palettes) and entities (moderate-to-high saturation D-033 colors) in the saturation hierarchy.
**Saturation hierarchy (D-044):**
| Tier | Saturation range | Examples |
|------|-----------------|---------|
| Entity (D-033) | 4060% | `#4a9ebb`, `#6bc9a6`, `#e8c547`, `#d45d5d` |
| Objects (static + D-052 favorites) | 1030% | `#8b8ba0`, dusty blue, warm terracotta |
| Structure (zone palette) | 515% | Zone floor tiles, wall faces |
Never add an object color that approaches entity saturation levels.
---
## 6. Color Blindness Assessment
### 6.1 Palette Under Common Deficiencies
The D-033 palette (`#4a9ebb` teal / `#6bc9a6` green / `#e8c547` amber / `#d45d5d` red) relies on hue differentiation as its primary signal. This creates accessibility challenges.
**Deuteranopia (green-blind, ~5% of males):**
The green (`#6bc9a6`) and teal (`#4a9ebb`) may be difficult to distinguish. Both occupy the blue-green range. Under deuteranopia simulation, they compress toward a similar cool-blue appearance. The amber (`#e8c547`) and red (`#d45d5d`) differentiate better — amber reads as yellow, red reads as brownish-orange.
**Protanopia (red-blind, ~1% of males):**
The red (`#d45d5d`) shifts toward olive/brown. More concerning: the red and green (`#6bc9a6`) may become difficult to distinguish. The amber (`#e8c547`) remains clearly distinct.
**Tritanopia (blue-blind, rare):**
The teal (`#4a9ebb`) shifts toward green, potentially conflating Unknown and Known. Less critical as tritanopia is uncommon.
### 6.2 Risk Assessment
**High risk:** Deuteranopia — teal/green confusion. A deuteranopic player may not clearly distinguish Unknown from Known/Friendly NPCs.
**Moderate risk:** Protanopia — red/green confusion. A protanopic player may not clearly distinguish Hostile from Known/Friendly.
### 6.3 Mitigation Recommendation
The current palette does not include a secondary differentiation signal beyond hue. For v0.1 (functional placeholder stage with colored rectangles), this is acceptable — the game ships with boxes, not full art, and accessibility features are milestone work.
**Recommended for v0.1.2+:** Add a shape or pattern secondary signal to entity sprites — border dash pattern or icon badge — that persists independently of hue. E.g., Hostile gets a diamond border, PersonOfInterest gets a cross-hatch border, Unknown gets no border treatment. This would not require changing the D-033 colors (which have been visually designed and approved) but would layer a non-color signal on top.
**Flag:** This is a known gap to be addressed before the game exits early access. Tracked as future accessibility ticket (not yet created — flag during Sprint 15 planning).
---
## 7. Insert Overlay Interaction (D-048)
In the insert overlay (z-layer 6), entity D-033 colors gain **soft halos**: 23px gaussian blur at ~40% blend. This is the insert's interpretation of the relationship data — rendered with organic neural texture.
In the natural vision layer (z-layer 3), entity colors are rendered with **2px outline in the relationship color** — hard edge, no bloom. This is the player's naked visual perception.
The difference: the insert annotates, the eye sees. Both use the same color, different rendering treatment.
When `insert_active == false`, the bloom halos disappear. The underlying hard-edge entity color remains (the character still sees the NPC). Only the insert's annotation layer suppresses.
---
## 8. Implementation Notes for Stig
The core lookup table is already partially implemented. The complete client-side mapping lives in `client/scripts/constants.gd` in the `color_for_entity_kind()` function. The entity renderer at `client/scripts/rendering/entity_renderer.gd` already:
- Tracks relationship state per entity in `_entity_relationships`
- Detects state changes and starts 0.5s color tweens
- Handles peripheral visibility dimming independently
**Outstanding for future implementation sprint:**
1. Ensure `Constants.color_for_entity_kind()` uses all five states above (including the player entity case keyed to `GameState.player_entity_id` and `lattice_profile`).
2. Fog-entity rendering (recognized vs unrecognized) is handled by the fog system, not `entity_renderer.gd`. The fog renderer queries the knowledge graph for recognition state.
3. Peripheral saturation reduction: currently implemented as alpha reduction (`modulate.a`). This is sufficient for v0.1. True saturation reduction (keeping brightness, reducing colorfulness) would require a shader and is deferred.
---
## Appendix A — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-033 | Source of truth for relationship state → color mapping. Hex values in this spec are canonical per D-033 approval. |
| D-043 | "Functional warmth" art direction. Palette tone and production principle. |
| D-044 | Visual hierarchy (entity > object > structure). Saturation rules. |
| D-048 | Neural insert overlay — bloom treatment for D-033 colors on z-layer 6. |
| D-049 | Z-level rendering stack. Entities at z-layer 3. Insert at z-layer 6. |
| D-059 | Fog shader — recognized vs unrecognized entity treatment in fog. |
| D-060 | Cognitive delay — controls when grey fog blob transitions to D-033 color. |
## Appendix B — Quick Reference for Stig
| State | Hex | Duration of transition |
|-------|-----|----------------------|
| Unknown | `#4a9ebb` | 0.5s fade from prior color |
| Known/Friendly | `#6bc9a6` | 0.5s fade from prior color |
| PersonOfInterest | `#e8c547` | 0.5s fade from prior color |
| Hostile | `#d45d5d` | 0.5s fade from prior color |
| Static object | `#8b8ba0` | Fixed — no transitions |
| Player (detective) | `#e0e8ff` | Fixed — not subject to relationship |
| Player (smuggler) | `#e8e0d0` | Fixed — not subject to relationship |
| Fog blob (unrecognized) | `#555566` | Transitions to D-033 over 0.3s during 0.6s cognitive delay (D-060) |
| Peripheral entities | Any D-033 color at reduced alpha | No separate color |
+252
View File
@@ -0,0 +1,252 @@
# Environmental Text Visual Standards
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #334
**Status:** Active — constrains world-layer text implementation and copy authoring
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§5), [Text Display Hierarchy](text-display-hierarchy.md) (#316), Decisions D-036, D-049, D-043
---
## 1. What Environmental Text Is (and Isn't)
Environmental text is text that exists in the physical world of Sova Transit District — on walls, above terminals, scrolling across displays. It is **not HUD**, not dialogue, not monologue. It is part of the same world layer that contains crates, chairs, and NPCs.
The key distinction: if a sign would survive the neural insert being switched off, it is environmental text. If it requires the insert to display, it belongs in the HUD layers.
In practice, environmental text renders in the world layer (z-layers 24) and is subject to overhead occlusion rules. It is NOT exempt from the fog shader — if a sign is in an unexplored area, the player cannot read it.
All environmental text renders in **Michroma** — the same typeface as all other game text. The fiction is that all text in the world is mediated through the character's insert perception layer. There is no "world handwriting" typeface separate from the insert-layer font.
---
## 2. Signage
### 2.1 Character Limits
| Sign type | Max characters | Notes |
|-----------|---------------|-------|
| Zone identifier (primary) | 20 chars | Facility name, zone designation |
| Zone identifier (secondary) | 30 chars | Sublevel, sector, unit designation |
| Directional/wayfinding | 15 chars | Single-line, all-caps convention |
| Safety/notice | 40 chars | Two-line max, 20 chars per line |
| Personal/informal (posted notice) | 60 chars | More casual, can be three lines |
**Hard limit rationale:** Signs at 64px tile width render at 11px Michroma. At this size, ~1012 characters fit per 64px. Signs wider than ~3 tiles (192px) become disproportionately large relative to the world. Character limits enforce realistic sign-making.
### 2.2 Visual Treatment
| Property | Value | Notes |
|----------|-------|-------|
| Font | Michroma Regular 400 | No exceptions |
| Font size | 11px | At 1080p base |
| Z-layer | 2 (wall surface) or 4 (overhead) | Depends on mounting position |
| Primary color | `#8899aa` | Concordat Standard signage |
| Informal color | `#9aa890` | Krenn vernacular or personal notices |
| Opacity | 70% for formal, 65% for informal | World layer — subordinate to entities |
| Case | ALL CAPS for formal zone signage | Standard for institutional text |
| Case | Mixed case for informal and vernacular | More human, less bureaucratic |
### 2.3 Mounting Position
**Wall-mounted (z-layer 2):** Signage on vertical wall surfaces renders as part of the wall face. Y-sorted with the wall, not with entities. The sign appears at approximately 1.52 visual tile height (upper third of wall face in the tilt view).
**Overhead (z-layer 4):** Suspended signs, hanging banners, and ceiling-mounted displays render in the overhead layer. These are subject to the semi-transparent occlusion rule (70% opacity when above entities/player). An NPC walking under a suspended sign becomes partially visible through the sign's opacity.
**Floor-mounted (z-layer 01):** Painted floor markings, directional arrows, hazard lines. These render under everything and are walked over. No occlusion consideration.
### 2.4 Bilingual Sign Treatment
When a sign has both Concordat Standard and Krenn vernacular text:
- Concordat Standard: primary size (11px), 70% opacity
- Krenn vernacular: secondary size (9px), 50% opacity, rendered below the primary line
- Both in Michroma. The size + opacity difference signals language register without a separate "handwriting" font.
- 2px vertical gap between language lines.
Only signs explicitly serving both audiences carry both languages. Official Commission signage is Concordat Standard only. The Last Shift menu and informal worker notices are Krenn vernacular only.
---
## 3. Terminals
### 3.1 Two-State Model
Terminals exist in two visual states depending on player proximity and interaction.
**State 1 — Ambient (player at range, not interacting):**
The terminal sprite displays a short ambient identifier: equipment type + designation code. This is always visible within LOS, no interaction required. It is the equivalent of a label on a machine — something a worker in this district would glance at to orient themselves.
| Property | Value |
|----------|-------|
| Content | 12 words + code (e.g., "MANIFEST — TK-07") |
| Font | 11px Michroma |
| Color | `#8899aa` |
| Opacity | 60% |
| Z-layer | 4 (overhead, above terminal sprite) |
| Visibility range | Within LOS, up to ~4 visual tiles (~256px at 64px/tile) |
**State 2 — Active (player adjacent, Observe/Interact triggered):**
When the player triggers Observe on a terminal, the full content becomes readable. This is the only time environmental text content transitions — the ambient identifier expands to full text.
| Property | Value |
|----------|-------|
| Content | Full terminal display — up to ~12 lines |
| Font | 13px Michroma |
| Color | `#a8b8c8` |
| Opacity | 85% |
| Z-layer | 4 |
| Max width | 200px floating block |
| Text wrapping | Word-wrap within 200px |
| Dismissal | Player moves away (WASD) |
**Transition between states:** No animation. The ambient identifier is replaced by the active block when the Observe verb fires. The opacity increase (60% → 85%) and size increase (11px → 13px) signal the mode change without a sliding panel.
### 3.2 Terminal Content Format
Terminal text is formatted as structured data — this is a computer display, not prose.
```
[HEADER LINE IN ALL CAPS]
Field: Value
Field: Value
---
Additional section
```
The Michroma font at 13px with `#a8b8c8` at 85% renders this structure clearly. Colon-separated fields mimic insert data formatting and reinforce the diegetic conceit.
**Character limits per line:** ~25 characters at 200px max width, 13px Michroma. Content authors should target 20 characters per line for comfortable reading.
### 3.3 Terminal Icon (Insert Layer)
When the player's insert is active and a terminal is within interaction range (~2 visual tiles), the insert may overlay a small geometric icon on the terminal — a 4px cross or bracket indicating "this is interactive." This is insert data (z-layer 6), not environmental text (z-layer 4). It renders regardless of whether the player is looking at the terminal.
The insert icon uses the standard insert chrome color (`#c8d0e0` at 80% opacity). If the terminal has flagged data (e.g., manifest discrepancy), the icon shifts to amber `#e8c547`.
---
## 4. News Tickers
### 4.1 Overview
News tickers are ambient scrolling text elements attached to specific display fixtures in the scene. They are world-layer content — not HUD overlays. The player must physically approach and observe them.
News tickers serve two functions:
1. **World texture:** Makes the station feel like a living, connected place (Commission bulletins, interstellar news)
2. **Incidental information:** Occasionally carries plot-relevant data the player can discover by paying attention
The player is never required to read a news ticker to progress. When a ticker carries relevant information, the character's monologue will comment on it (via `observe_anomaly` or `enter_location` trigger) — the player doesn't have to stare at a scrolling display.
### 4.2 Visual Treatment
| Property | Value |
|----------|-------|
| Z-layer | 4 |
| Font | 10px Michroma |
| Color | `#8899aa` |
| Opacity | 55% |
| Language | Concordat Standard |
| Scroll direction | Right-to-left |
| Scroll speed | 30px/second |
| Loop | Yes — repeating |
| Pause on proximity | Yes — when player within 2 visual tiles, scroll pauses |
**Why 55% opacity:** Tickers are the most ambient environmental text element. They must not draw attention during active gameplay. A player engaged with NPCs should not be distracted by scrolling text in their peripheral vision. 55% opacity makes them effectively invisible until the player deliberately turns toward them.
**Pause on proximity:** When the player stands within 2 visual tiles of a ticker display, the scroll pauses to allow reading. This rewards deliberate attention without requiring the player to chase scrolling text.
### 4.3 Content Length
News ticker content is authored in segments:
- **Segment:** A complete sentence or news item, max 80 characters
- **Gap:** A `————` separator between segments (34 em-dashes, full width)
- **Loop depth:** 35 segments per ticker. More segments means longer before repeating — less metagameable.
Segments scroll sequentially separated by the gap. The player who watches a full loop has seen all content for that ticker.
### 4.4 Fixture Attachment
Each news ticker is attached to a specific fixture sprite (terminal, display panel, wall screen). The ticker text renders in a `Rect` overlaid on the upper portion of the fixture sprite. The fixture artist marks the text bounds when designing the fixture.
For v0.1 (colored rectangle stage), the ticker is a floating text element positioned above the fixture's placeholder rectangle. When sprite art arrives, the artist provides the correct text bounds.
---
## 5. Rendering Rules Summary
### 5.1 Z-Layer Assignment
| Text sub-type | Z-layer | Occlusion? | Fog-affected? |
|---------------|---------|------------|---------------|
| Floor signage | 01 | No (under entities) | Yes |
| Wall signage | 2 | Via y-sort | Yes |
| Terminal ambient ID | 4 | Semi-transparent occlusion | Yes |
| Terminal active display | 4 | Semi-transparent occlusion | Yes |
| News ticker | 4 | Semi-transparent occlusion | Yes |
| Insert terminal icon | 6 | No (insert layer) | No |
**Fog-affected:** Yes means the text is invisible in unexplored areas. The player cannot read a sign they haven't walked past. This is correct and intentional.
### 5.2 Entity Occlusion Rule
Environmental text on z-layer 4 (overhead) renders at **70% opacity maximum** — the same rule as all overhead layer content. An entity moving below an overhead sign remains partially visible through the sign. This is the "local information gap within a known space" design (D-049 §4.4).
Environmental text on z-layer 2 (wall surface) is y-sorted with entities. If an entity is standing in front of a wall sign, the sign is occluded by the entity. This is correct — the entity is always the primary visual element (D-044).
### 5.3 Text Never Occludes Entities
Entities are always visually dominant (D-044: entity > object > structure). Environmental text never achieves enough opacity to obscure entity colors. If a layout choice places text where it would overlap an entity, the text loses — reduce opacity or reposition. The entity's D-033 color must remain readable.
---
## 6. Language by Context — Decision Matrix
| Location | Formal element | Informal element |
|----------|---------------|-----------------|
| The Terminal (logistics hub) | Concordat Standard | Krenn vernacular where workers have added personal notices |
| The Last Shift (bar) | Concordat Standard (Commission notices, if any) | Krenn vernacular (menu, worker notices, personal signs) |
| Maintenance corridors | Concordat Standard (safety markings) | Krenn vernacular (informal worker notes) |
| Cargo containers | Concordat Standard (manifests, labels) | None |
| Personal effects area | Neither — no formal signage | Krenn vernacular if labeled |
**Decision rule:** Formal = the institution did it. Informal = a person did it. The institution writes in Concordat Standard. People write in Krenn vernacular. When you're not sure, ask who made the sign.
---
## Appendix A — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-036 | Sova Transit District / Krenn System. Concordat Standard vs Krenn vernacular framework. |
| D-043 | "Functional warmth" art direction. Environmental text is world texture, not decoration. |
| D-044 | Visual hierarchy. Environmental text is subordinate to entities at all times. |
| D-049 | Z-level rendering stack. Layer assignments for all text types. |
| D-051 | Settling is placement. Environmental text density reflects the district's history. |
| D-066 | Dual-scale grid. 64px tiles → ~1012 characters per tile at 11px Michroma. |
## Appendix B — Quick Reference for Copy Team
| Format | Max chars/line | Lines | Language |
|--------|---------------|-------|----------|
| Zone signage (primary) | 20 | 1 | Concordat Standard |
| Zone signage (secondary) | 30 | 1 | Concordat Standard |
| Directional | 15 | 1, ALL CAPS | Concordat Standard |
| Safety notice | 20 | 2 | Concordat Standard |
| Informal posted notice | 20 | 3 | Krenn vernacular |
| Terminal active (per line) | 25 | Up to 12 | Either, per context |
| News ticker (per segment) | 80 | 1 | Concordat Standard |
## Appendix C — Quick Reference for Stig
| Sub-type | Z-layer | Font | Size | Hex | Opacity |
|----------|---------|------|------|-----|---------|
| Formal signage | 2 or 4 | Michroma | 11px | `#8899aa` | 70% |
| Informal signage | 2 or 4 | Michroma | 11px | `#9aa890` | 65% |
| Terminal ambient | 4 | Michroma | 11px | `#8899aa` | 60% |
| Terminal active | 4 | Michroma | 13px | `#a8b8c8` | 85% |
| News ticker | 4 | Michroma | 10px | `#8899aa` | 55% |
| Insert terminal icon (active) | 6 | — | 4px glyph | `#c8d0e0` | 80% |
| Insert terminal icon (flagged) | 6 | — | 4px glyph | `#e8c547` | 80% |
@@ -0,0 +1,602 @@
# Monologue Content Architecture
**Ticket:** #253
**Authors:** Mellanie (primary), Paula (narrative review), Gestalt (KG validation)
**Date:** 2026-02-20
**Status:** Draft v1.0
**Decisions:** D-016, D-032, D-034, D-035, D-041
---
## Purpose
This document is the authoring contract for all internal monologue content in The Settled Reach. It defines:
- The interpretive frame each character applies to the same world
- The five monologue categories and what each does
- Which triggers map to which categories (and why)
- Volume targets for v0.1 by trigger type and location
- The prerequisite map system and how it gates lines from KG state
Every monologue line authored for this game — past, present, and future — should be writable against this spec without ambiguity. If the spec doesn't cover a case, that's a gap to resolve here before writing lines.
**Cross-references:**
- [Voice Patterns](voice-patterns.md) — sentence-level execution (contractions, punctuation, sentence length)
- [Dual Lens Authoring Guide](dual-lens-authoring-guide.md) — high-level character perspective framework
- [Knowledge Vocabulary v0.1](knowledge-vocabulary-v01.md) — all prerequisite formats and fact IDs
- D-016: the four functions of internal monologue
- D-032: hard partition between character pools
- D-035: tag taxonomy including monologue-specific additions
---
## 1. Identity-Driven Interpretation Frame
The same event produces different monologue because the characters are different people, not because the system routes differently. The engine fires a `witness_interaction` trigger for both characters when they see Voss and Maret arguing. What they think about it depends on who they are.
This section defines the interpretive lens for each character. Every line should be writable from this frame. If you can't explain why this character would think this, in these terms, the line is wrong.
### 1.1 The Smuggler
**What she cares about:**
- Kael's loyalty and safety (he's the person she trusts most in the ring)
- The ring's operational security — routes, timing, who knows what
- The moral dimension: this is access, not weapons; she needs to believe it matters
- Naia's wellbeing (Kael's partner, the emotional stake beneath everything)
**What she fears:**
- Exposure — for herself, for Kael, for anyone she pulled into this
- Kael pulling away, changing, hiding something she can't protect him from
- The Commission finding the routes before she can clear the slate
- Her own judgment being wrong — that she's put good people in danger
**What she feels responsible for:**
- Every member of the ring she enabled or included
- Kael, specifically — his involvement is partly her
- The shipments and what they're used for; she has opinions about every cargo type
**The interpretive consequence:** The smuggler reads every situation for operational risk and personal loyalty. A sealed bay isn't interesting infrastructure — it's a threat vector or a cover. Torek pacing isn't distressing behavior — it's someone who might talk. Kael eating alone isn't a social signal — it's a break in a pattern she memorized for exactly this kind of moment.
**What the smuggler doesn't notice:** systemic patterns, documentation trails, jurisdictional implications, institutional relationships. She notices people, not processes.
---
### 1.2 The Detective
**What he cares about:**
- Institutional duty — the case, the evidence, the commission mandate
- Procedural integrity — how evidence is gathered matters, not just what it says
- Sera's reliability as a social anchor (the one person in the district who speaks his language)
- Getting to the actual truth, not just a closable case
**What he fears:**
- Personal cost compromising the case — particularly the Sera question (D-034)
- Being wrong about a core assumption after committing to it
- Institutional failure: the Commission missing something they should have caught
- Losing objectivity; his analytical frame is protective, and he knows it
**What he feels responsible for:**
- The investigation — what he sees, what he files, what he flags
- Not contaminating the case with personal relationships
- Doing the duty correctly even when the correct action is uncomfortable
**The interpretive consequence:** The detective reads every situation for evidentiary value and behavioral deviation from established baseline. The same sealed bay is a documentation anomaly, not a personal threat. Kael eating alone is a deviation from observed routine. Voss pacing is an elevated behavioral indicator. He files, he notes, he compares to baseline.
**What the detective doesn't notice:** interpersonal warmth (at baseline), intuitive risk, the moral weight of what the ring is doing. He notices patterns, not people — until Sera makes him notice a person, which is its own problem.
---
### 1.3 The Dual-Lens Test
Before writing any line, ask: **could this line belong to either character?** If yes, it's not specific enough.
| Same event | Smuggler | Detective |
|------------|----------|-----------|
| Voss changes the rotation | "Voss changed the rotation again. Third time this month. Covering something." | "Davan's rotation has shifted three times in thirty days. Pattern or coincidence?" |
| Kael eats alone | "Kael's eating alone today. He always eats with me." | "Davan, K. — not with his usual group. Worth monitoring." |
| Loading arm grinds | "Loading arm three is grinding again. Someone should file that." | "Loading arm three has a grind in its cycle. Maintenance deferred — budget, or negligence?" |
| Bay 4 sealed | "Bay three's been sealed off. Inspection, or something else?" | "Containers stacked to regulation height in most bays. Bay four is the exception." |
The smuggler makes it personal. The detective files it analytically. They're both right. Neither has the full picture.
---
## 2. Monologue Categories
D-016 defines four functions for internal monologue. This spec extends those into five authoring categories, each with distinct content goals, trigger affinities, and voice requirements.
### 2.1 Perception
**D-016 function:** Translating non-visual senses — things the camera can't show.
**What it does:** Converts audio/haptic/olfactory events into the character's voice. The player hears footsteps at the fog edge; the monologue tells them what that means to this character.
**Primary trigger:** `hear_sound`
**Secondary trigger:** `enter_location` (smell, air quality, ambient temperature on arrival)
**Key rule:** Perception lines interpret *what the character senses*, not what they conclude. Conclusion is Investigation. *"Footsteps behind me. Two people, unhurried."* is Perception. *"That's not the usual patrol pattern."* is Investigation.
**Smuggler examples:**
- *"Footsteps behind me. Light. Someone I know."*
- *"Cargo lubricant and recycled air. Home sweet home."*
- *"The conveyor hums at a different pitch when it's loaded heavy. This one's heavy."*
**Detective examples:**
- *"Loading arm three has a grind in its cycle. Maintenance deferred."*
- *"Something in the air beyond the lubricant. Chemical, faint. Not standard freight residue."*
- *"Footsteps in the service corridor. Measured pace — not trying to be quiet."*
---
### 2.2 Atmosphere
**D-016 function:** Character's running commentary on environment, mood, situation.
**What it does:** Establishes location identity, time of day, and the character's emotional register at that moment. These are the "color" lines — they don't advance investigation but they make the world feel inhabited.
**Primary triggers:** `enter_location`, `time_idle`, `return_visit`
**Secondary triggers:** `witness_interaction` (when the atmosphere is the point, not the content)
**Key rule:** Atmosphere lines are mostly unconditional — they fire regardless of what the player knows. They establish the baseline feel of a location. A location without atmosphere lines feels hollow.
**Smuggler examples:**
- *"Morning shift. Recycled air and cargo lubricant. Home sweet home."*
- *"The freight bay looks better in the dark."*
- *"Quiet morning. Containers moving, nobody talking. I like it this way."*
**Detective examples:**
- *"Logistics hub. Standard prefab, heavy foot traffic. Let's see what the shift change tells me."*
- *"Only place in the district that doesn't smell like freight lubricant."* [The Last Shift]
- *"Back at the hub. Different shift, different faces. Same manifest board."*
---
### 2.3 Tutorial
**D-016 function:** Diegetic hints — character thinks about what they might do. No UI popups.
**What it does:** Teaches mechanics and interactables through the character's voice. The character thinks *"That terminal might have access logs"* and the player learns that terminals are interactable. Everything the tutorial needs to convey, the character can plausibly think.
**Primary triggers:** `discover_evidence`, `enter_location` (first visit to a new space)
**Secondary triggers:** `observe_anomaly` (anomaly prompts consideration of action)
**Key rule:** Tutorial lines are almost always unconditional. They fire on first encounter, not on investigation depth. A player who already knows what a terminal does doesn't need the tutorial line again — the engine manages repetition via the cooldown system, but the content itself shouldn't gate on knowledge.
**Smuggler examples:**
- *"Manifest board updates live. Container movement, weight, destination. Easy to check if you know what you're looking for."*
- *"The terminal's accessible from here. Useful."*
- *"That container's been in temp storage since yesterday. Someone put it there on purpose."*
**Detective examples:**
- *"Commission terminal access. That'll have import logs, weight discrepancies, anything flagged in the last thirty days."*
- *"The manifest board updates in real-time. Container movement, weight, destination. All logged."*
- *"Locked bay. Inspection record will show who authorized it."*
**Cross-character rule:** Tutorial lines don't reference character-specific knowledge or relationships. They explain the world, not the case. If you find yourself writing a tutorial line that only one character would think, it's probably Investigation wearing a tutorial hat.
---
### 2.4 Observation
**D-016 function:** Character-specific commentary on NPCs — who they see, what they notice.
**What it does:** Produces NPC-specific monologue that reflects the character's relationship to that NPC and their current emotional register. This is where character voice does the most work — the same NPC looks completely different from each character's perspective.
**Primary triggers:** `observe_npc`, `witness_interaction`
**Secondary triggers:** `post_conversation` (observations from conversation, not Investigation conclusions)
**Key rule:** Observation lines read *what the character sees*. The emotional color comes from the relationship. A friendly NPC gets warm observation. A person_of_interest gets wary observation. A hostile NPC gets guarded observation.
**Smuggler examples (Kael, progressive):**
- Friendly baseline: *"Kael's already at the dock. Good. The day's better when he's on shift."*
- Early concern: *"Kael's distracted today. Probably nothing."*
- Person of interest: *"Kael nodded at me across the bay. Same as always. Exactly the same as always."*
**Detective examples (Sera, progressive):**
- Friendly baseline: *"Venn, S. — already at the bar. At least someone speaks my language here."*
- Early concern: *"Sera's avoiding the corner near the window. She's usually over there."*
- Person of interest: *"Sera ordered the usual. Didn't look at me when I came in. That's new."*
**Named NPC coverage requirement:** Every named NPC in a location needs at least 3 Observation lines per character who could plausibly observe them there, across the relationship arc (baseline, early concern, person_of_interest). FRIEND NPCs need 8-10.
---
### 2.5 Investigation
**What it does:** The character builds a mental case — connecting dots, forming hypotheses, processing what they've just learned. This is where the internal monologue becomes an unreliable narrator (D-016): the character reaches a conclusion that may be wrong.
**Primary triggers:** `post_conversation`, `discover_evidence`, `observe_anomaly`
**Secondary triggers:** `time_idle` (ruminative investigation — sitting with unresolved questions), `witness_interaction` (when the content of the interaction matters, not just the fact of it)
**Key rule:** Investigation lines are almost always prerequisite-gated. The character can't investigate what they don't know. A smuggler without knowledge of Kael's corridor meeting can't ruminate about it. The prerequisite map (Section 5) is how we gate these.
**Smuggler examples (knowledge-gated):**
- `suspects(investigation.kael_corridor_meeting)`: *"Kael was in corridor B-7 last night. Off-shift. Off-route. That's not nothing."*
- `suspects(investigation.kael_unknown_contact)`: *"Who was Kael talking to? Not anyone from our rotation. I'd know the face."*
- `direct(behavioral.kael_evasion)`: *"He looked left before answering. He always looks left when he's lying."*
**Detective examples (knowledge-gated):**
- `knows_of(investigation.kael_corridor_meeting)`: *"Davan, K. — Corridor B-7, off-shift. Unregistered contact. Not a coincidence."*
- `suspects(behavioral.sera_avoidance)`: *"She moved away from Lintar, T. immediately. Pre-existing avoidance pattern, or recent?"*
- `knows_details(investigation.manifest_discrepancy)`: *"Container 4471. Wrong weight class, wrong routing, wrong time of day. Three deviations. That's data."*
**The unreliable narrator rule:** Investigation lines can be wrong. The smuggler might conclude Voss is covering for himself when Voss is covering for someone else. The detective might file an observation as coincidence that isn't. The engine doesn't correct them. The monologue is character interpretation, not game truth.
---
## 3. Trigger → Category Mapping
One trigger can source multiple categories. The category is determined by what the line *does*, not which trigger fires it.
| Trigger | Primary Category | Secondary Category | Notes |
|---------|-----------------|-------------------|-------|
| `enter_location` | Atmosphere | Tutorial | First visit: tutorial. Return: atmosphere only. |
| `observe_npc` | Observation | Investigation | Post-discovery, Observation lines gain Investigation coloring. |
| `hear_sound` | Perception | Observation | If the sound is an identifiable NPC (recognized voice, steps), it's also Observation. |
| `observe_anomaly` | Observation | Investigation | Anomaly = deviation from known baseline → triggers Investigation query. Requires some prior knowledge. |
| `post_conversation` | Investigation | Observation | Processes what just happened. Reflection before action. |
| `discover_evidence` | Tutorial | Investigation | Tutorial for new interactable types. Investigation for case-relevant content. |
| `witness_interaction` | Observation | Investigation | Named pair interacting = Observation. Named pair + player knows one has a secret = Investigation. |
| `time_idle` | Atmosphere | Investigation | Short idle: ambient atmosphere. Long idle: ruminative Investigation if knowledge gates open. |
| `return_visit` | Atmosphere | Observation | "Something's different" — comparison to last visit. Observation of change. |
**Not 1:1:** A single `observe_anomaly` trigger can fire either an Observation line ("Torek's spending too much at Lera's. Someone's going to notice.") or an Investigation line ("Torek's spending too much at Lera's. Someone's going to notice." with a prerequisite linking it to ring finance). The difference is whether the line requires knowledge state to fire.
---
## 4. Volume Targets for v0.1
All volumes are per-character (D-032 hard partition). These are minimum targets for v0.1, not caps.
### 4.1 Per Trigger Per Location
| Trigger | Min lines per location per character | Notes |
|---------|--------------------------------------|-------|
| `enter_location` | 5 | 3 unconditional, 2 knowledge-gated. Mix atmosphere/tutorial. |
| `time_idle` | 4 | 2 unconditional (ambient), 2 knowledge-gated (investigation). |
| `observe_npc` | 3 per named NPC present | Named NPCs only. Generic NPCs get 1 line ("New face at dock seven."). FRIEND NPCs: 8-10. |
| `hear_sound` | 3 | 2 environmental, 1 NPC-identifying (conditionally). |
| `observe_anomaly` | 4 | 2 unconditional (structural anomalies), 2 knowledge-gated (behavioral). |
| `witness_interaction` | 3 per named pair | Named pairs (Voss+Maret, Kael+unknown). Generic: 1. |
| `post_conversation` | 3 per named NPC | After each major NPC, across arc phases. |
| `discover_evidence` | 2 per evidence type | Tutorial + 1 investigation variant. |
| `return_visit` | 3 | 1 baseline, 2 knowledge-gated comparisons. |
### 4.2 Sprint 14 Minimum Deliverable (#120)
For Sprint 14, the minimum deliverable covers `enter_location` and `time_idle` across eight locations, two characters:
| Location | Characters | `enter_location` | `time_idle` | Total lines |
|----------|-----------|------------------|-------------|-------------|
| The Terminal (hub) | Smuggler + Detective | 5+5 | 4+4 | 18 |
| The Last Shift (bar) | Smuggler + Detective | 5+5 | 4+4 | 18 |
| Maintenance Corridor A | Smuggler + Detective | 5+5 | 4+4 | 18 |
| Maintenance Corridor B | Smuggler + Detective | 5+5 | 4+4 | 18 |
| Smuggling Hold | Smuggler only | 5 | 4 | 9 |
| Commission Kiosk | Detective only | 5 | 4 | 9 |
| Transit Passage | Smuggler + Detective | 3+3 | 2+2 | 10 |
| Span Gate Approach | Smuggler + Detective | 3+3 | 2+2 | 10 |
**Sprint 14 minimum total: ~110 lines.** Full v0.1 (all triggers, all named NPCs) projects to ~320-380 lines across both characters.
### 4.3 FRIEND NPC Volume
FRIEND NPCs (Kael Davan for smuggler, Sera Venn for detective) require expanded coverage because they carry D-034's arc across multiple phases. These do not use generation expansion (D-034: "no generation expansion" applies here).
| Trigger | Kael (smuggler) | Sera (detective) | Notes |
|---------|-----------------|-----------------|-------|
| `observe_npc` | 10 | 10 | Across 3 relationship phases: friendly / early concern / poi |
| `post_conversation` | 8 | 8 | After major arc beats: warmth / evasion / confrontation |
| `observe_anomaly` | 6 | 6 | Behavioral tells, pattern deviations |
| `witness_interaction` | 4 | 4 | Named pair with FRIEND |
| `time_idle` | 4 | 4 | Ruminative Investigation about FRIEND |
**FRIEND total per character: ~32 lines.** All gated except 3-4 baseline warmth lines.
---
## 5. Prerequisite Map Design
Monologue prerequisites gate lines on knowledge state. The `prerequisite` field (D-035) accepts three gate types: `relationship`, `entity_attributes`, and `facts`. These reflect what's queryable from the KG (D-041).
All prerequisite formats are defined in full in [Knowledge Vocabulary v0.1](knowledge-vocabulary-v01.md). This section covers the *design logic* — which gate type to use when, and example mappings for common cases.
### 5.1 Gate Types
**Relationship gates** — use when the line's emotional register depends on the character's relationship to a specific NPC.
```yaml
prerequisites:
relationship:
target: npc:kael-davan
state: friendly # unknown | known | friendly | person_of_interest | hostile
```
*Use for:* warmth lines (require `friendly`), suspicion lines (require `person_of_interest`), observation lines that assume prior contact.
**Entity attribute gates** — use when the line responds to a specific behavioral flag on an NPC.
```yaml
prerequisites:
entity_attributes:
- entity: npc:kael-davan
key: behavior_flags
value: avoidance
```
*Use for:* tell-observation lines (requires `tell_observed: true`), avoidance pattern lines, behavioral contradiction lines.
**Fact gates** — use when the line requires knowledge of a world event or fact, not just a relationship.
```yaml
prerequisites:
facts:
- fact_id: investigation.kael_corridor_meeting
min_confidence: suspects
```
*Use for:* investigation lines that connect dots, operational lines that require ring knowledge, awareness lines about other characters.
**Compound gates** — AND logic. All conditions must be true. Use sparingly — most lines need one gate type. Compounds are for late-arc lines where relationship depth AND specific knowledge must both be present.
```yaml
prerequisites:
relationship:
target: npc:kael-davan
state: person_of_interest
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: suspects
```
### 5.2 Common Prerequisite Patterns
These are recurring patterns across the monologue pools. Write these before the edge cases.
**Baseline (no prerequisite):**
- Arrival atmosphere lines
- Generic environmental perception lines
- Tutorial lines for interactables
- Generic NPC presence lines for unnamed NPCs
**Ring awareness (smuggler-specific):**
```yaml
facts:
- fact_id: knowledge.ring_routing_knowledge
min_confidence: knows_details
```
*Gates:* lines about specific routes, container timings, next drops.
**Detective presence (smuggler):**
```yaml
facts:
- fact_id: awareness.detective_presence
min_confidence: knows_of
```
*Gates:* lines about the detective, caution lines when detective is visible.
**Kael's corridor meeting (both characters, different emotional color):**
```yaml
facts:
- fact_id: investigation.kael_corridor_meeting
min_confidence: suspects
```
*Smuggler:* worry, confusion, loyalty strain.
*Detective:* evidentiary interest, pattern notation.
**Kael's unknown contact:**
```yaml
facts:
- fact_id: investigation.kael_unknown_contact
min_confidence: suspects
```
*Smuggler:* "Not anyone from our rotation."
*Detective:* "Unregistered contact. Worth investigating."
**Kael behavioral tell (post-conversation):**
```yaml
facts:
- fact_id: behavioral.kael_evasion
min_confidence: direct
```
*Gates:* the tell-recognition line ("He looked left before answering"). Requires Direct confidence — player witnessed this themselves.
**Sera avoidance pattern (detective-specific):**
```yaml
entity_attributes:
- entity: npc:sera-venn
key: behavior_flags
value: avoidance
```
*Gates:* detective's observation that Sera is avoiding someone or something.
**Manifest discrepancy (detective):**
```yaml
facts:
- fact_id: investigation.manifest_discrepancy
min_confidence: knows_of
```
*Gates:* investigative lines connecting container weight anomalies to the ring.
**FRIEND trust-contamination (both characters, highest priority):**
```yaml
relationship:
target: npc:kael-davan # or npc:sera-venn
state: person_of_interest
facts:
- fact_id: investigation.kael_corridor_meeting # or behavioral.sera_avoidance
min_confidence: suspects
```
*Gates:* the knife-twist lines that mark the moment trust begins to curdle. These are the emotional core of the FRIEND arc.
### 5.3 Priority Field
Lines with prerequisites should carry a `priority` field (1-10, default 5). Higher priority wins when multiple eligible lines compete for the same trigger slot.
**Priority guidelines:**
- Unconditional atmosphere: 3-5
- Generic NPC observation: 4
- Named NPC observation (no prerequisite): 5-6
- Knowledge-gated investigation: 6-8
- FRIEND arc lines (knowledge-gated): 7-9
- FRIEND trust-contamination (highest stakes): 9-10
The priority system ensures that when the player has uncovered something significant, the monologue fires the most relevant line rather than a generic arrival comment.
### 5.4 KG Expressibility
Gestalt validates that all prerequisite conditions in authored content are queryable from the KG. The design constraint: **if the prerequisite references a fact_id or entity attribute, that fact_id must exist in a `content/global/knowledge/*.yaml` file.**
New prerequisite patterns that reference fact_ids not currently in the vocabulary must be added to the knowledge vocabulary document before the lines using them can be marked complete.
**Fact categories available for prerequisites:**
- `investigation.*` — case facts (corridor meeting, unknown contact, manifest discrepancy)
- `knowledge.*` — ring operational facts (routing, cargo types)
- `awareness.*` — situational awareness (detective presence, Commission activity)
- `behavioral.*` — observed behavioral patterns (kael_evasion, sera_avoidance)
- `social.*` — relationship network facts
- `contraband.*` — ring membership, cargo specifics
- `location.*` — spatial facts (who was where, when)
- `progress.*` — narrative progress gates (first conversation with FRIEND, confrontation attempted)
---
## 6. Authoring Notes
### 6.1 Category-to-Tag Mapping
Monologue lines use freeform `tags` (D-035 selection tag) for filtering and tooling. The category system above maps to tag conventions:
| Category | Recommended tags |
|----------|-----------------|
| Perception | `environmental`, `perception`, `sensory` |
| Atmosphere | `arrival`, `atmospheric`, `ambient`, `operational` |
| Tutorial | `tutorial`, `orientation`, `interactable` |
| Observation | `npc`, `[npc-name]`, `tell`, `behavior` |
| Investigation | `investigation`, `contraband`, `caution`, `friend-arc`, `contradiction` |
FRIEND arc lines always include `friend-arc` tag. This enables filtering for arc-coherence review.
### 6.2 Display Constraints
All monologue lines display in the z-layer 7 overlay (D-049). Hard constraints from voice patterns spec:
- **Max characters:** ~160
- **Max visual lines:** 2
- **Target word count:** 10-25 words (smuggler avg 10, detective avg 13)
- **Display time:** 4-6 seconds, length-adjusted
- **Must be self-contained:** Each line reads independently; no setup line required
### 6.3 Review Responsibilities
| Review gate | Who | What they check |
|-------------|-----|----------------|
| Voice consistency | Mellanie | Every line matches voice-patterns.md. Contraction test, name test, length targets. |
| Narrative coherence | Paula | FRIEND arc lines and `friend-arc` tagged content honor D-034 arc structure. Kael/Sera arcs are internally consistent. |
| KG expressibility | Gestalt | All prerequisite fact_ids exist in knowledge vocabulary. Compound gates are achievable from KG state. Priority values are reasonable. |
Mellanie is final authority on voice. Paula is final authority on arc coherence. Gestalt is final authority on KG expressibility. No line that fails any review is shippable.
---
## 7. Open Questions for Sprint 14
1. **`progress.*` fact IDs** — the `progress` knowledge category covers narrative milestones (first Kael conversation, confrontation attempted). These are needed for some late-arc gating but the fact_ids aren't fully defined yet. **Action:** Gestalt to confirm `progress.*` fact_id format before #120 writes late-arc Investigation lines.
2. **Span Gate Approach atmosphere** — the span gate approach location is in the Sprint 14 minimum list but no NPC profiles exist there. Atmosphere and Perception lines only; no Observation lines. **Paula's answer (2026-02-20):** No named NPC routines bring characters to the span gate approach in normal play. This is a transitional space — liminal, between home and away. Monologue here should reflect that liminality: the smuggler thinks about what she's leaving behind or returning to; the detective thinks about what the arrival is telling him. The span gate approach is where internal monologue speaks to the gap between worlds, not to specific NPCs. Author 3 unconditional Atmosphere lines per character (arrival version + departure version + idle version), no Observation lines needed.
3. **`time_idle` cooldown handling** — the engine prevents the same line firing twice within a session. The spec assumes this is handled engine-side. If the cooldown is content-layer (authored `cooldown_ticks` field), the schema needs updating before #120. **Action:** Gestalt to confirm.
4. **Witness_interaction pair detection** — the trigger fires when two NPCs interact near the player. Does the trigger pass which two NPCs are interacting, so the line pool can filter for `[npc-a, npc-b]` tagged lines? If not, witness_interaction lines can't target specific pairs. **Action:** Dudley/Gestalt to confirm trigger payload before #120 writes witness_interaction lines.
---
---
## 9. Gestalt — KG Expressibility Validation (2026-02-20)
**Overall assessment:** The prerequisite map is expressible from KG state. All referenced fact_id categories exist in the knowledge vocabulary. One naming error, one duplicate in the vocabulary, and two open questions requiring server-side confirmation before #120 can write gated lines.
---
### Validated fact_id references
All prerequisite patterns from Section 5.2 verified against `content/global/knowledge/*.yaml`:
| Fact ID in doc | Status | Notes |
|----------------|--------|-------|
| `investigation.kael_corridor_meeting` | ✅ EXISTS | Shared ID, both characters |
| `investigation.kael_unknown_contact` | ✅ EXISTS | Shared ID |
| `behavioral.kael_evasion` | ✅ EXISTS | Smuggler only, `direct` confidence |
| `behavioral.kael_behavioral_change` | ✅ EXISTS | Smuggler only (Paula's Phase 2 gate) |
| `behavioral.sera_avoidance` | ⚠️ NAMING ERROR | Actual ID is `behavioral.sera_avoidance_pattern` — doc must use the full name or lines won't load |
| `behavioral.sera_topic_deflection` | ✅ EXISTS | Both characters |
| `knowledge.ring_routing_knowledge` | ✅ EXISTS | Smuggler only, Background-level (`knows_details`) |
| `awareness.detective_presence` | ✅ EXISTS | Smuggler only |
| `investigation.manifest_discrepancy` | ✅ EXISTS | Shared; detective starts at `suspects` (Background) |
**Action required (Mellanie):** Every authored line using `behavioral.sera_avoidance` as a prerequisite must be corrected to `behavioral.sera_avoidance_pattern` before content validation will pass.
---
### KG vocabulary issue: duplicate fact_id
`investigation.drin_inspection_pattern` appears **twice** in `content/global/knowledge/investigation.yaml` with slightly different descriptions. This is a vocabulary error that will cause undefined behavior when the fact is queried. Action: Qatux to deduplicate and merge descriptions in the knowledge vocabulary.
---
### Open question resolutions
**OQ from Section 7, item 3 — `time_idle` cooldown handling:** CONFIRMED as content-layer. The monologue schema already has `cooldown` as an integer field per line (minimum ticks before refiring). Authors set this in YAML. No schema update needed. Atmosphere lines: `cooldown: 300` (roughly 5 minutes at 60 ticks/second). Investigation lines: `cooldown: 600`. FRIEND arc lines that should fire once and not again: `cooldown: 99999`.
**OQ from Section 7, item 4 — `witness_interaction` trigger payload:** NOT YET CONFIRMED. The trigger needs to pass which two NPCs are interacting so pool queries can filter for named-pair lines. If the trigger doesn't provide this, witness_interaction lines can't target Kael+unknown or Voss+Maret specifically — they'd have to fire generically. **Action:** Dudley to confirm trigger payload before #120 writes witness_interaction lines. Do not author named-pair witness_interaction lines until this is confirmed.
---
### Priority field review
Priority guidelines from Section 5.3 are mechanically sound. The selection pipeline uses priority as a tiebreaker when multiple eligible lines compete for one trigger slot. Values are reasonable. One note: "FRIEND trust-contamination" lines tagged 9-10 will always win when eligible — authors must make sure these only fire when the KG state is correct, or they'll dominate every trigger slot during that arc phase. Keep the prerequisite gates tight (compound gates with both relationship + fact conditions are the right call for 9-10 priority lines).
**Prerequisite map validation: APPROVED** with the `behavioral.sera_avoidance_pattern` naming fix required before ship.
---
## 8. Paula — Narrative Review (2026-02-20)
**Overall assessment:** The architecture is sound and coherent with THE FRIEND arc. All critical design constraints (D-032 hard partition, D-034 no generation expansion, D-016 four functions) are correctly applied. No blocking issues.
**Specific notes:**
**1. FRIEND arc prerequisite patterns (Section 5.2) — approved with one clarification:**
The `FRIEND trust-contamination` pattern correctly identifies the emotional core lines. One addition: the intermediate phase (Phase 2 — early concern, before contradiction is confirmed) needs its own prerequisite pattern. These are the lines that sit between baseline warmth and the knife-twist — the "probably nothing" lines that retroactively become ominous. Suggested gate:
```yaml
# Phase 2 "early concern" gate — not yet contradiction, but off-baseline
facts:
- fact_id: behavioral.kael_behavioral_change # or behavioral.sera_topic_deflection
min_confidence: suspects
# Do NOT add relationship: person_of_interest — that would gate them too late.
# These lines must fire while the relationship is still Friendly.
```
These lines are the most valuable in the FRIEND arc — they only land correctly on second playthrough, when the player knows what they missed. Mellanie: please author at least 3 per FRIEND NPC in this intermediate gate range.
**2. Identity-driven interpretation frame (Section 1.1/1.2) — approved:**
The "what she fears" and "what she feels responsible for" axes correctly anchor the FRIEND arc. Kael's betrayal lands as devastating for the smuggler because she feels responsible for his ring involvement. Sera's concealment lands as devastating for the detective because it costs him the one thing that wasn't institutional. The section captures this well.
**3. Observation line examples (Section 2.4) — minor authoring gap:**
The examples for Phase 3 `person_of_interest` observation (`"Kael nodded at me across the bay. Same as always. Exactly the same as always."`) don't show the prerequisite that makes them fire at the right time. In the YAML, these need:
```yaml
prerequisite:
relationship:
target: npc:kael-davan
state: person_of_interest
```
Without this gate, they'll fire too early and spoil the contradiction. Not a doc issue — it's an authoring reminder for #120.
**4. THE FRIEND arc coherence across the two characters:**
One verification needed before #120 ships: the FRIEND arc observation lines for the *other* character (smuggler's monologue about Sera, detective's monologue about Kael) need to be internally consistent with what each character would plausibly know. The smuggler sees Sera as Commission presence — her observation lines about Sera should be cautious, not warm. The detective sees Kael as "Davan, K." until late in the arc — his observation lines about Kael should maintain surname-first register throughout, even at `person_of_interest`. The emotional weight is different, not the naming convention.
**5. Open question #2 answered** — see above in Section 7.
**Status: Narrative review complete. Document approved for #120 authoring to proceed.**
+276
View File
@@ -0,0 +1,276 @@
# Monologue Display System — Visual Specification
**Version:** v0.1 (Sprint 13, updated Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-19
**Ticket:** #315
**Status:** Active — constrains copy authoring (#299, #300) and future client monologue UI implementation
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§5.2, §5.3)
---
## 1. Typography
### 1.1 Typeface
**Michroma** (Google Fonts, Regular 400) — no exceptions. Monologue renders through the same neural insert perception layer as all other text (Visual Grammar §5.4). There is no separate "inner voice font."
### 1.2 Size and Weight
All sizes at 1080p base resolution. Godot 4 handles DPI scaling.
| Variant | Size | Weight | Notes |
|---------|------|--------|-------|
| Standard | 13px | Regular 400 | Interior voice. Quieter than dialogue (16px). |
| Urgent | 13px | Regular 400 | Same size — urgency is communicated through opacity, color, and bloom, not typographic scale. |
**No bold, no italic, no size variation within a single line.** Monologue is the character's unformatted thought stream. Emphasis comes from the writing, not from styling.
### 1.3 Character-Differentiated Color
Monologue text color reflects the character's register. The detective thinks in cool tones (analytical, institutional). The smuggler thinks in warm tones (social, street-level). These colors are deliberately desaturated — monologue should never compete with D-033 entity colors for visual attention.
**Detective:**
| Variant | Color | Hex | Opacity |
|---------|-------|-----|---------|
| Standard | Cool grey-blue | `#d0d4e0` | 85% |
| Urgent | Bright cool blue | `#e0e8f8` | 100% |
**Smuggler:**
| Variant | Color | Hex | Opacity |
|---------|-------|-----|---------|
| Standard | Warm grey-cream | `#d8d0c4` | 85% |
| Urgent | Bright warm cream | `#f0e4d4` | 100% |
### 1.4 Urgent Bloom
Urgent monologue lines receive a subtle bloom pulse: 2px gaussian blur at 30% blend, pulsing between 20% and 40% over a 1.5-second cycle. The pulse begins on fade-in and continues through the hold duration. This is the only visual animation on monologue text.
The urgent recognition chime (D-067, UISounds bus) fires simultaneously with the bloom onset.
---
## 2. Positioning
### 2.1 Screen Anchor
Monologue occupies a **fixed screen position** — not entity-relative. The character's inner voice is not a speech bubble; it exists in the player's perceptual space, not the game world's physical space.
### 2.2 Placement
Monologue lines are anchored to the **lower-left quadrant** of the screen, above the dialogue box region.
| Property | Value | Notes |
|----------|-------|-------|
| Horizontal anchor | Left edge + 5% screen width margin | Left-aligned, clear of screen edge |
| Vertical anchor | 25% from screen bottom | Above dialogue box max height (20%). 5% gap separates monologue from dialogue top edge. |
| Max text width | 50% screen width | Prevents lines from spanning the full screen. At 1920px, this is 960px — comfortably fits 80 characters at 13px Michroma. |
### 2.3 Dialogue Box Relationship
The dialogue box (D-061) occupies the bottom 20% of the screen. Monologue sits directly above it with a 5% gap. This spatial separation is the core mechanic: the character thinks one thing (above, inner) while the NPC speaks another (below, outer).
When dialogue is **inactive**, monologue lines remain at the same vertical position — as if the dialogue box were present but invisible. The monologue region does not slide down to fill the gap. Consistent positioning trains the player to associate the lower-left quadrant with interior thought.
### 2.4 Z-Layer
**Z-layer 7** (D-049). Topmost rendering layer. Monologue is:
- Never occluded by game world elements (layers 04)
- Never affected by fog shader (layer 5)
- Rendered above the insert overlay (layer 6)
- Co-resident with dialogue box and HUD chrome on layer 7
Within layer 7, monologue text renders above the dialogue box but below modal UI (world menu, pause).
---
## 3. Stacking Rules
### 3.1 Maximum Visible Lines
**3 lines maximum.** This is a hard cap. If a 4th line arrives while 3 are displayed, the priority system (§4) determines which line yields.
### 3.2 Stack Direction
**Bottom-up.** The newest line appears at the bottom of the monologue region (closest to the dialogue box). Older lines shift upward. This keeps the freshest thought spatially adjacent to the dialogue it may contradict.
```
[oldest line — fading out] ← shifts up, fading
[middle line] ← shifts up
[newest line — fading in] ← appears here
─────────────────────────────────
[dialogue box] ← bottom 20% of screen
```
### 3.3 Shift Animation
When a new line enters and pushes older lines upward, the vertical shift takes **0.2 seconds** (ease-out). Lines do not teleport to their new position.
### 3.4 Line Spacing
**4px vertical gap** between stacked lines. At 13px font size, this gives 17px per line slot — compact but legible.
---
## 4. Priority System
### 4.1 Priority Tiers
Monologue lines belong to one of three tiers, in descending priority:
| Priority | Tier | Examples |
|----------|------|---------|
| 1 (highest) | **Observation** | Entity recognition, anomaly detection, item discovery, environmental deduction |
| 2 | **Atmosphere** | Zone commentary, ambient thought, character reflection, idle musing |
| 3 (lowest) | **Tutorial** | System hints, control reminders, first-time explanations |
### 4.2 Overflow Behavior
When a new line arrives and the display is at capacity (3 lines):
1. **If the new line's priority is higher than the lowest-priority displayed line:** The lowest-priority displayed line immediately begins its fade-out (compressed to 0.3s instead of normal 1.0s). The new line enters at the bottom after the evicted line clears.
2. **If the new line's priority is equal to or lower than all displayed lines:** The new line is **deferred** — queued and displayed when a slot opens naturally. Maximum queue depth: 5 lines. If the queue is full, the lowest-priority queued line is silently dropped.
3. **Same-priority collision:** Within the same tier, newer lines take precedence over older ones. An observation evicts an older observation before it evicts an atmosphere line.
### 4.3 Urgent Override
Urgent monologue lines (observation tier only) **always display immediately**, evicting the lowest-priority visible line regardless of tier match. If all 3 visible lines are observation-tier, the oldest observation line is evicted.
---
## 5. Fade Animation
### 5.1 Timing
Each line fades **independently** — lines do not synchronize their lifecycles.
| Phase | Duration | Easing |
|-------|----------|--------|
| Fade-in | 0.3s | Ease-out (quick arrival, not instant) |
| Hold | 4.0s | — |
| Fade-out | 1.0s | Ease-in (slow, contemplative departure) |
**Total visible duration:** 5.3 seconds per line.
### 5.2 Opacity Curve
- **Fade-in:** 0% → target opacity (85% standard, 100% urgent) over 0.3s
- **Hold:** Target opacity sustained for 4.0s
- **Fade-out:** Target opacity → 0% over 1.0s
### 5.3 Eviction Fade
When a line is evicted by a higher-priority line (§4.2), its fade-out is compressed from 1.0s to **0.3s**. This fast exit makes room for urgent content without feeling jarring.
### 5.4 Rapid Succession
If multiple monologue lines fire within 0.5 seconds of each other (e.g., multi-part observation), each line staggers by **0.15 seconds**. The second line begins its fade-in 0.15s after the first, the third 0.15s after the second. This prevents a wall of text appearing simultaneously while keeping the burst feeling connected.
---
## 6. Character Differentiation
### 6.1 Design Principle
The smuggler and detective occupy the same world, see the same events, but experience them through different cognitive registers. Monologue is where this divergence is most visible. Per D-043 ("functional warmth"), the smuggler's interior world is warmer — social awareness, practical assessment. The detective's is cooler — analytical distance, procedural framing.
This is expressed through **color temperature only**. Size, position, stacking, animation, and font are identical between characters. The difference is subtle and accumulative — not a jarring mode switch.
### 6.2 Color Summary
| Character | Standard | Urgent | Register |
|-----------|----------|--------|----------|
| Detective | `#d0d4e0` at 85% | `#e0e8f8` at 100% | Cool blue-grey. Institutional. Observational. |
| Smuggler | `#d8d0c4` at 85% | `#f0e4d4` at 100% | Warm cream-grey. Social. Practical. |
### 6.3 Implementation Note
The active character's `lattice_profile` (from `ObserverSnapshot`) determines which color set is used. `lattice_augmented` (detective) → cool palette. `lattice_baseline` (smuggler) → warm palette. No runtime switch within a session — character is selected at game start.
---
## 7. Line Length Constraint
### 7.1 Hard Limit
**80 characters per line, maximum.** This is a display constraint, not a guideline.
At 13px Michroma on a 1080p display with 50% max text width (960px), 80 characters fits comfortably with margin. Lines exceeding 80 characters will be truncated at the last word boundary before the limit — no mid-word breaks.
### 7.2 Copy Team Contract
All monologue content authored for The Settled Reach must respect this constraint:
- **Maximum:** 80 characters per line (including spaces and punctuation)
- **Target:** 4065 characters per line (comfortable reading rhythm)
- **Minimum:** No minimum, but lines under 20 characters should be rare — they waste visual space
Multi-line monologue thoughts (e.g., a two-part observation) should be authored as separate lines, each respecting the 80-character limit, delivered via the rapid succession stagger (§5.4).
### 7.3 Validation
Content tooling should flag lines exceeding 80 characters at authoring time, not at runtime. Runtime truncation is a fallback, not a workflow.
---
## 8. D-033 Cross-Reference — Color Clash Analysis
### 8.1 Entity Relationship Colors
| State | Hex | Saturation | Lightness |
|-------|-----|-----------|-----------|
| Unknown/Neutral | `#4a9ebb` | ~45% | ~52% |
| Known/Friendly | `#6bc9a6` | ~40% | ~60% |
| Person of Interest | `#e8c547` | ~75% | ~59% |
| Hostile/Dangerous | `#d45d5d` | ~55% | ~60% |
| Static objects | `#8b8ba0` | ~10% | ~58% |
### 8.2 Monologue Text Colors
| Variant | Hex | Saturation | Lightness |
|---------|-----|-----------|-----------|
| Detective standard | `#d0d4e0` | ~15% | ~85% |
| Detective urgent | `#e0e8f8` | ~38% | ~92% |
| Smuggler standard | `#d8d0c4` | ~12% | ~81% |
| Smuggler urgent | `#f0e4d4` | ~30% | ~89% |
### 8.3 Clash Assessment
**No clashes.** Monologue text occupies the high-lightness, low-saturation range (L: 8192%, S: 1238%). Entity colors occupy the moderate-lightness, moderate-to-high saturation range (L: 5260%, S: 1075%). These color spaces do not overlap.
The closest potential overlap is between static object grey (`#8b8ba0`, low saturation) and monologue text — but the lightness gap (58% vs 81%+) maintains clear separation, and monologue text on z-layer 7 never spatially overlaps with entity sprites on z-layer 3.
### 8.4 Warm Amber Adjacency
The smuggler's urgent text (`#f0e4d4`) and the Person of Interest amber (`#e8c547`) share a warm register. This is thematically appropriate — the smuggler's heightened awareness (urgent monologue) coincides with the visual system flagging someone as noteworthy. They are not visually confusable: the text is desaturated near-white cream, while amber is a saturated gold-yellow at much lower lightness.
---
## Appendix A — Decision Cross-References
| Decision | Relevance to this spec |
|----------|----------------------|
| D-016 | Monologue as core perception/atmosphere system. Defines function and character variance. |
| D-033 | Entity relationship colors. Cross-referenced in §8 for clash avoidance. |
| D-043 | "Functional warmth" art direction. Grounds character color differentiation (§6). |
| D-044 | Visual hierarchy (entity > object > structure). Monologue must not compete. |
| D-049 | Z-level rendering stack. Monologue is z-layer 7 (§2.4). |
| D-061 | Dialogue box layout. Monologue spatial relationship defined in §2.3. |
| D-066 | Dual-scale grid. Positioning uses screen-relative units, not tile units. |
| D-067 | Recognition chime fires at cognitive delay onset, coinciding with urgent monologue bloom. |
## Appendix B — Quick Reference for Copy Team
| Constraint | Value |
|-----------|-------|
| Max characters per line | **80** (hard limit) |
| Target characters per line | 4065 (comfortable) |
| Max visible lines | 3 |
| Line hold duration | 4.0 seconds |
| Total visible time | 5.3 seconds |
| Multi-line stagger | 0.15s between lines |
| Priority: observation > atmosphere > tutorial | Observation always displays; tutorial may be deferred |
+480
View File
@@ -0,0 +1,480 @@
# Monologue Voice Guide: Trait, Background, and Mood Modifiers
**Ticket:** #121
**Authors:** Paula (narrative framing), Mellanie (example lines — see Section notes)
**Gestalt:** validate mood → delivery mappings are mechanically coherent
**Date:** 2026-02-20
**Status:** Draft — narrative framing complete; Mellanie to supply example lines per section
**Cross-references:**
- [Monologue Content Architecture](monologue-content-architecture.md) — the authoring contract this document extends
- [Voice Patterns](voice-patterns.md) — sentence-level execution (contractions, punctuation, sentence length)
- [Dual Lens Authoring Guide](dual-lens-authoring-guide.md) — Chapter 7 (base voice registers)
- D-024: NPC triangle model — PersonalityTraits list (10 axes)
- D-035: tag taxonomy — mood enum (8+1 moods)
---
## Document Purpose
This document answers one question: **given that both characters have established voice registers, what happens to those registers when we vary personality traits, character backgrounds, and mood states?**
The monologue architecture defines *what* characters think about. The voice patterns spec defines *how* they write sentences. This document defines the *inflection layer* — how the same thought sounds different from a Cautious smuggler versus a Bold one, from a character with Guardian background versus Worker background, in an Anxious moment versus a Content one.
The goal is not separate scripts per trait/background/mood. The goal is a **transformation guide**: a set of rules that let Mellanie take a base line and render it correctly for any combination of modifiers, without writing from scratch.
---
## Chapter 1: Base Voice Registers — The Narrative Framing
The voice-patterns.md spec describes the mechanics of each character's voice. This chapter explains *why* those mechanics exist — the psychological and experiential logic that makes the voice real. Understanding the "why" allows you to make correct decisions when the spec doesn't cover an edge case.
### 1.1 The Smuggler: Concrete Cognition
The smuggler's voice is not the result of low intelligence or limited vocabulary. It's the result of two years of professional survival in a context where abstract thinking is dangerous.
When you're running contraband through a logistics hub, you can't afford to think in categories — you think in specifics. Not "this situation is getting risky" but "Voss changed the rotation and Kael's not answering his lattice." Abstract framing makes patterns invisible. Concrete framing catches the deviation.
The smuggler's internal voice is trained, not natural. She developed it. It served her. The short sentences and fragments aren't lack of complexity — they're the mental habit of someone who learned to think in real-time decision points. "Kael's late. Wrong. Move." Three thoughts, three beats, three potential action triggers. Abstract cognition is a liability she's edited out.
**The emotional truth beneath the voice:** She cares about people first. Operations second. Institutions never. The concrete voice maps onto this: she sees people, not systems. She notices Kael is distracted before she notices the manifest is wrong. The world is a network of relationships, and her monologue is live monitoring of that network.
**What this means for trait and mood modifiers:** When trait or mood modifiers inflect the smuggler's voice, they do so within this framework. A Bold smuggler is still concrete-first — she just acts on partial information faster. An Anxious smuggler is still relational — she just cycles through the network faster, checking and rechecking. The base is immovable. The modifiers inflect it.
### 1.2 The Detective: Analytical Cognitive Frame
The detective's voice is institutional training running as an operating system. He was taught to externalise his cognition — to turn observations into filed reports, to name patterns as data, to treat emotional reactions as information to be processed rather than feelings to be felt.
This is not coldness. It's self-protection. Investigators who let themselves feel first make bad decisions. The detective learned that lesson somewhere — we don't know where, but it's in the bone. The analytical frame is a tool he uses so well he's forgotten it's a tool. Except when Sera makes him forget.
The longer sentences and complete syntactic structures aren't pretension. They're habits of evidence: a complete sentence has a subject, verb, and object, which means it has an agent, an action, and a consequence. The detective's brain naturally produces complete sentences because incomplete sentences don't file well.
**The emotional truth beneath the voice:** He cares about doing the work correctly. Not because he's a rule-follower (he'd bend a rule if the evidence demanded it), but because he believes the correct process produces the correct result. When Sera introduces doubt into that belief, the whole framework shudders. His fear isn't being wrong about a fact — it's discovering that correct process produced an unjust outcome, and having no language for what comes next.
**What this means for trait and mood modifiers:** Detective trait modifiers inflate or compress the analytical frame. A Compassionate detective lets the personal break through the professional more easily. A Ruthless detective turns the analytical frame into a weapon — identifies leverage faster, doesn't dwell on moral weight. Mood modifiers thin or thicken the professional veneer. The base is the analyst. The modifiers determine how much of the person shows through.
---
## Chapter 2: Personality Trait Modifiers
D-024 defines 10 personality traits across 5 opposing pairs. These modify the *delivery* of monologue, not the *content*. The same base line should be writable in any trait version. The trait changes the sentence-level texture: hedging vs. assertion, relational vs. operational framing, question vs. declaration.
**Authoring note:** Traits come in pairs. An NPC (or player character) has values along the axis — a 3-point scale: Trait A (strong), Trait A (mild), Neutral, Trait B (mild), Trait B (strong). For monologue, we write for the strong expression of each trait. Mild expressions are interpolations that Mellanie can derive.
---
### 2.1 Cautious ↔ Bold
**The axis:** How much information does the character need before acting (or thinking forward to action)?
**Cautious — transformation logic:**
A Cautious character qualifies her observations before she acts on them. She notices things in the same order as a neutral character, but she checks her own conclusions. She hedges internally. She is not less intelligent — she is more aware that she might be wrong.
- Declarations become conditionals: "That's Kael's route" → "That looks like Kael's route"
- Observations become questions: "Something's off" → "Something might be off"
- Concern arrives earlier and stays longer (more re-checking)
- The voice is slower, more tentative
**How this inflects each character:**
- *Cautious smuggler:* More checking, more revisiting. Sees the same deviation, holds the conclusion longer before committing. Her concern accumulates quietly rather than crystallizing fast. Phrases like "probably," "maybe," "could be" appear more. She doesn't voice suspicion until she's sure — then she's very sure.
- *Cautious detective:* Qualifies his filings. "Unusual behavior" becomes "behavior that may deviate from baseline." He is extremely careful about premature conclusions. His analytical questions multiply before resolving. This character is hard to rattle because he expects to be uncertain.
**Bold — transformation logic:**
A Bold character acts on partial information. She names the conclusion before she has the evidence. She's often right — bold cognition built on competence. But she's also capable of committing to a wrong read and holding it too long.
- Questions become declarations: "Something might be off" → "Something's off"
- Hedges disappear: "That looks like Kael's route" → "That's Kael's route"
- Concern arrives fast and crystallizes immediately
- The voice is faster, more assertive, shorter latency between observation and conclusion
**How this inflects each character:**
- *Bold smuggler:* She names it. Fast. Doesn't wait for confirmation. Her operational thinking has a "move first, verify later" quality. Lines are shorter, more declarative, fewer qualifiers.
- *Bold detective:* His analytical sentences become more assertive. Fewer conditionals. He "flags" things as facts before he's confirmed them. This character is easier to manipulate through his own confidence.
**Mellanie — write 2-3 example rewrite pairs per character (base line → Cautious version, base line → Bold version). Reference the voice anchors from voice-patterns.md Chapter 7 as the base.**
---
### 2.2 Honest ↔ Deceptive
**The axis:** How accurately does the character represent observations — to themselves, in their own internal voice?
**Honest — transformation logic:**
An Honest character's monologue is self-correcting. She catches her own spin and revises it. She doesn't lie to herself to feel better. This makes her monologue have a characteristic pattern: initial reaction, then correction.
- Self-correction appears: "Kael's fine. — No. He's not fine."
- Less motivated reasoning: she acknowledges evidence she doesn't want to see
- The voice is slightly more uncomfortable with its own conclusions
- She doesn't minimize things that scare her
**Deceptive — transformation logic:**
A Deceptive character's internal voice shows motivated reasoning. She rationalizes what she wants to be true. She notices threatening evidence and then explains it away. This is not dishonest *about others* — it's dishonest with herself.
- Conclusions favor the preferred reading: "Kael's meeting someone I don't know. Probably work."
- Dismissal appears quickly after observation: "Could be nothing. Probably is nothing."
- She notices the tell and then chooses not to follow it
- The voice has a self-soothing quality, particularly under threat
**How this inflects each character:**
- *Honest smuggler / Honest detective:* Harder on themselves. The self-correction pattern creates vulnerability — they see things they don't want to see and say so.
- *Deceptive smuggler / Deceptive detective:* The internal voice becomes unreliable in a new way (D-016 establishes the unreliable narrator function — this makes it more pronounced). They rationalize the FRIEND contradiction longer. The player has to read against the grain.
**Mellanie — write 2-3 example rewrite pairs: same observation, Honest version vs. Deceptive version.**
---
### 2.3 Compassionate ↔ Ruthless
**The axis:** How much does concern for others' wellbeing inflect the character's internal assessment?
**Compassionate — transformation logic:**
A Compassionate character's first read of a situation is through the lens of what it means for people she cares about. She identifies the person most likely to be hurt before she identifies the operational implication.
- People-first sequencing: observation of person → emotional assessment → operational conclusion
- Concern is named: "Naia looks exhausted. Something's wrong at home."
- She personalizes abstract threats: not "ring security is compromised" but "Kael could get caught"
- Moral weight is felt quickly and stays present
**Ruthless — transformation logic:**
A Ruthless character assesses utility before empathy. She notices a threat and immediately calculates how to neutralize it, including through people. This is not cruelty — it's a habit of assessment that puts operational outcome first.
- Utility assessment appears early: "Kael's behavior is risky. If he cracks, he takes the route with him."
- Moral weight is noted and set aside: "He might not deserve this. Doesn't matter right now."
- People are resources first: "Maret's nervous. That's usable."
**How this inflects each character:**
- *Compassionate smuggler:* Most Naia-and-Kael-focused. Her FRIEND arc lines emphasize relationship cost over operational cost. She confronts Kael later because she keeps hoping he'll explain.
- *Ruthless smuggler:* Calculates exposure fast. Cuts contact with Kael earlier once he's flagged. Sees Naia's distress as a data point about Kael's reliability, not as a human problem.
- *Compassionate detective:* The Sera question hits harder and sooner. He identifies the personal cost before the institutional obligation.
- *Ruthless detective:* Uses Sera's knowledge instrumentally. The friendship is real, but usable.
**Mellanie — write 2 example rewrite pairs per character: the same FRIEND-arc moment in Compassionate vs. Ruthless voice.**
---
### 2.4 Curious ↔ Incurious
**The axis:** Does the character lean into questions they can't immediately answer, or redirect to what they know?
**Curious — transformation logic:**
A Curious character asks more internal questions, including questions she can't answer. She sits with open threads longer. She generates hypotheses and lets them hang.
- Questions multiply before resolving: "What was Kael doing there? Who was that? Is the route changing? Is Nils moving the window?"
- The voice has more speculative energy — she follows the thread
- She notices more at the margins: background details, things that don't connect yet
- Open-endedness is comfortable
**Incurious — transformation logic:**
An Incurious character categorizes and moves on. She notices, files, and redirects to the actionable. Open questions without operational payoff are dead weight.
- Questions resolve fast or don't appear: "That's wrong." Not "Is that wrong? Why?"
- The voice is more efficient, less ruminative
- She notices less at the margins — processes what's relevant
- Ambiguity is uncomfortable; she resolves it one way or another and moves
**Mellanie — write 1-2 example rewrite pairs: same observation, Curious vs. Incurious voice.**
---
### 2.5 Social ↔ Reclusive
**The axis:** How much does the character define situations through social relationships vs. environmental or operational facts?
**Social — transformation logic:**
A Social character's monologue is populated. She mentions people when she could just mention actions. She frames operations through the network.
- People are named when not necessary: "The route's clear. Kael cleared it."
- She reads absence: "Where's Renn today? He's always here at this hour."
- Mood-reads appear: "Voss is different today. Tired, or something else."
**Reclusive — transformation logic:**
A Reclusive character's monologue is depopulated. People appear when they're directly relevant. The environment and operation carry more weight.
- Operations are mentioned without actors: "Route's clear." Not "Kael cleared the route."
- Absence of people is comfortable: she doesn't notice it
- The environment gets more attention: she describes the space, the sounds, the texture
**Mellanie — write 1-2 example rewrite pairs: same moment, Social vs. Reclusive voice.**
---
## Chapter 3: Background-Dependent Phrasing
Character background (selected at game start) shapes three things in monologue: the idioms and references the character uses, the assumptions they make about institutions and authority, and how they frame the moral dimension of what's happening in Sova Transit District.
Three backgrounds for v0.1:
| Background | Core assumption | Relationship to institutions |
|-----------|----------------|------------------------------|
| **Guardian** | Institutions serve the powerful; collective protection requires network | Distrust of Commission; network solidarity as primary value |
| **Senator** | Systems can be worked; leverage and process are the tools | Works within institutions while managing them; political capital logic |
| **Worker** | Survival in tight margins; coworker loyalty is the only reliable thing | Neither trusts institutions nor fights them; accepts them as weather |
---
### 3.1 Guardian Background
**Narrative framing:** Someone with Guardian background has, at some point, been in a network that operated outside or against institutional authority — Guardians of Autonomy, community mutual aid, or simply lived experience of Commission overreach in their origin system. They think in terms of *who protects whom* and *who's watching*. They're not necessarily political; they may have absorbed these values experientially without ideological labels.
**How it inflects the smuggler:**
The ring's activities are, to a Guardian-background smuggler, a natural extension of the protective network logic she already believes in. She doesn't see herself as a criminal — she sees herself as part of a system that serves people the regulated system won't. This changes how she frames the ring's cargo: medical lattice components aren't contraband, they're access. The Commission's regulation is a monopoly problem, not a safety protection.
Her monologue references network solidarity: "We look after our own" is a value, not just a statement. When the network is threatened (Kael's situation), her response is to protect, not to report. The Commission is not a solution to her problems — it's the problem with different uniforms.
Idiom signals: phrases that imply collective or mutual protection ("we look after our own," "this keeps us safe," "not their business"), references to surveillance as adversarial ("they're watching the corridors closer"), reflexive Commission-skepticism ("another Commission inspection" carries more weight, more contempt).
**Mellanie — write 2 examples:** (a) Guardian-background smuggler entering The Terminal on a normal morning; (b) Guardian-background smuggler seeing the detective for the first time.
**How it inflects the detective:**
A Guardian-background detective is in a structurally uncomfortable position: she believes in a community's right to self-determination but is employed by the institution that overrides it. She may have unexamined tension between her institutional role and her network values. Her internal voice sometimes catches itself applying institutional logic and stops — not from disloyalty, but from the habit of checking whether what she's doing serves people or just process.
This creates a more nuanced reading of the ring: she notices earlier that it isn't weaponry, that the people running it aren't villains, that the moral picture is complicated. The analytical frame doesn't disappear — it processes the evidence correctly — but the conclusion "this is a crime" arrives with more friction than for a default-background detective.
Idiom signals: slight Commission-skepticism applied to her own role, instinct toward community protection over prosecution, "who does this actually hurt?" appears as an internal question earlier in the arc.
**Mellanie — write 2 examples:** (a) Guardian-background detective walking through The Terminal for the first time; (b) Guardian-background detective after a confrontation with Voss that didn't land.
---
### 3.2 Senator Background
**Narrative framing:** Senator background means exposure to Concord Assembly political culture — either through family, prior work, or a posting in an administrative center. This character thinks in terms of leverage, political capital, and institutional process as tools to be used. They believe in systems (not naively — they've seen how they work), they communicate strategically, and they think about consequences in terms of how they're recorded and remembered.
**How it inflects the smuggler:**
A Senator-background smuggler is an unusual animal. She ended up on Sova with this background for reasons that suggest a fall — political family, bad posting, something that deposited her in freight work. She still thinks in political terms. The ring is, to her, a structure with power dynamics that can be managed. She assesses Nils as a faction leader, Voss as a compromisable middle manager, Kael as an uncertain ally. She has more framework for navigating institutional pressure than the default smuggler.
Her monologue carries strategic calculation that the default smuggler doesn't have: "If Nils moves against us, who has leverage over Nils?" She names power structures. She's more comfortable with authority figures because she spent time being one or adjacent to one.
Idiom signals: political vocabulary deployed in operational context ("who holds the leverage here," "what does this cost us politically," "that's a favor spent"), more awareness of chain of command, comfort with complexity and multiple-move thinking.
**Mellanie — write 2 examples:** (a) Senator-background smuggler reading a tense moment between Voss and Nils; (b) Senator-background smuggler deciding whether to warn Kael about the detective.
**How it inflects the detective:**
A Senator-background detective is most at home in the Commission's formal institutional structure. He knows how to work the process because he's seen political process at close range. He's less likely to be frustrated by bureaucratic obstacles and more likely to use them as tools. He's also more aware of how investigations can be used politically — and more careful to distinguish between evidence that serves justice and evidence that serves someone's career.
This can work two ways: he's more sophisticated about when to file and when not to, and he's more aware that the ring is a political problem as well as a criminal one. The Syndic connections in the ring's supply chain are visible to him as political economy, not just logistics.
Idiom signals: references to political process ("that'll go on record," "who is this actually going to serve"), awareness of institutional optics, formal courtesy in internal voice (he holds standards in private that mirror public institutional expectations).
**Mellanie — write 2 examples:** (a) Senator-background detective receiving unhelpful cooperation from Maret (deflection, formal compliance); (b) Senator-background detective realizing Sera has evidence she hasn't reported.
---
### 3.3 Worker Background
**Narrative framing:** Worker background is the default — or close to it. Born and raised in the working logistics economy of a station or freight hub. Grew up with shift schedules, margin pressure, and coworker bonds as primary social fabric. Institutions are not enemies (the Guardian stance) or tools (the Senator stance) — they're weather. You work around them when you can, you deal with them when you can't, and you don't waste energy on ideology about them.
**How it inflects the smuggler:**
The Worker-background smuggler is the closest to the spec as written — she *is* the baseline. The ring isn't an ideological project; it's a response to economic conditions. The moral dimension is simple: this is what keeps people fed. She doesn't dress it up. She doesn't philosophize about Commission regulation. She just runs the job.
Her monologue about the ring's cargo is practical, not moral: "Medical components. That's for people who need lattice work they can't afford." She's not celebrating it. She's explaining it to herself, briefly, and moving on. She's the character most likely to be genuinely conflicted when the ring puts people she knows at risk — because she joined for community, and the community is now in danger.
Idiom signals: shift-economy references ("that's a week's pay gone"), physical work vocabulary, coworker solidarity language that's practical not sentimental ("you cover for your people"), no ideological framing.
**Mellanie — write 2 examples:** (a) Worker-background smuggler after a long shift where nothing went wrong (genuine relief in mundane form); (b) Worker-background smuggler finding out about Kael's corridor meeting.
**How it inflects the detective:**
A Worker-background detective is a less common institutional profile — someone who came up from the logistics world, or the adjacent working community of a station, and then entered Commission work. This creates the most dramatic read of the Sova Transit District: he *knows* what this world looks like from inside. The community's distrust of him isn't abstract — he grew up in a community that had this relationship with Commission investigators.
His analytical frame is the same, but its application produces a different emotional texture. He reads Voss not just as a shift supervisor but as someone managing his people in a system that gives them no margin. He reads the ring not just as a crime but as what people do when the legal option doesn't work. He's not excusing it — but he's explaining it to himself in ways a Senator-background detective wouldn't.
Idiom signals: working vocabulary that leaks through the institutional frame ("another form to fill out" with genuine frustration), more personal reads on community behavior, faster comprehension of coworker loyalty dynamics.
**Mellanie — write 2 examples:** (a) Worker-background detective entering The Terminal and recognizing it as a place he knows in his bones; (b) Worker-background detective having a moment of sympathy for Kael that he then has to file away.
---
## Chapter 4: Mood Modifiers
The 8+1 moods from D-035 (`neutral`, `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `focused`) are line-selection tags and delivery expectations. They work together: a line tagged `mood: anxious` is preferred when the character's current mood state is Anxious, and it is *authored to sound anxious*.
Mood modifiers are **not separate line pools.** They are a) selection weights that surface mood-appropriate lines, and b) authoring constraints on how lines in a given mood register should feel. The goal is not that the player is explicitly told "you're anxious now" — it's that anxious-mood lines surface when they should and sound anxious when they play.
### 4.1 The Core Principle: Mood Inflects Register
Each mood bends the character's voice toward a version of itself. The base register (Chapter 1) doesn't change. The mood is an adjective applied to that register, not a replacement for it.
| Mood | Smuggler inflection | Detective inflection |
|------|-------------------|---------------------|
| `neutral` | Baseline. Dry warmth. Measured. | Baseline. Professional. Structured. |
| `anxious` | Goes smaller and faster. More questions. Physical sensation surfaces. | Goes shorter and more personal. Contractions increase. Self-directs. |
| `frustrated` | Goes flat. Sarcasm. "Of course." | Sardonic understatement. Cold. |
| `content` | Warms, expands slightly. More humor. | Structured satisfaction. Humor surfaces. |
| `suspicious` | Narrows. More observation of specific people. Hypotheses pile up. | Files faster. More quantification. "Coincidence?" frequency increases. |
| `warm` | More name-drops. Affectionate observations. Longer people-reads. | First-name register expands beyond Sera. More personal disclosure. |
| `hostile` | Edges. Fewer words about the hostile object. Watchful. | Clinical distance increases. Dehumanizing label (surname-only). |
| `focused` | Strips down. Only the current task. Environmental and NPC data pruned ruthlessly. | Most structured voice. Near-mechanical. Labels and flags only. |
---
### 4.2 Mood Modifier Details
**`neutral` — baseline delivery**
Both characters at their natural resting state. Not happy, not stressed. Working. This is the voice that carries most of the game's content. Lines tagged `neutral` or untagged should default to this register.
Key authoring note: Neutral smuggler is NOT flat or bland — she has dry warmth and laconic humor. Neutral detective is NOT cold — he has professional engagement and occasional wry observation. "Neutral" means the voice is itself, not that the voice is suppressed.
**`anxious` — compression and acceleration**
The threat or uncertainty feels present. The character's cognitive bandwidth is partially occupied by monitoring something they can't resolve.
- *Smuggler:* Sentences get shorter. Questions pile up. Physical sensation surfaces ("Hands are cold." / "Stomach's tight."). Humor disappears. First names become clipped. The operational countdown appears as a comfort mechanism — counting what she can control.
- *Detective:* Contractions multiply. Complete sentences break up. Self-monitoring appears ("I'm too close to this."). The analytical questions become self-directed rather than case-directed. Physical sensation barely surfaces but does: "Need to focus."
Lines tagged `mood: anxious` should feel compressed. Not panicked — the characters are experienced — but alert in a way that presses on the surface of the text.
**`frustrated` — controlled deflation**
Something isn't working. The block is structural, not threatening.
- *Smuggler:* Flat declarations. Sarcastic understatement. "Of course." / "Great." Dark humor returns — she goes wry under frustration. Names get blamed: "Voss did it again." Swearing surfaces (mild — the character swears, but not obscenely).
- *Detective:* Sardonic compression. Dismissive shorthand: "Standard." meaning the opposite. The institutional frame holds but it fits uncomfortably. He doesn't swear; frustration surfaces as brittle patience.
Lines tagged `mood: frustrated` should feel like controlled flat. Both characters have learned not to waste anger — they spend it in specific, pointed ways.
**`content` — expansion and warmth**
Things are working. The current moment is manageable or even good.
- *Smuggler:* Slight expansion — still short, but more generous. More humor. First names carry warmth. Environmental description picks up (she actually notices the space when she's not scanning for threats). "Home sweet home" is a content line.
- *Detective:* More complete and elegant sentences. Humor surfaces without prompting. The analytical frame fits comfortably — it's doing its job. Fewer corrective self-directives.
Lines tagged `mood: content` are the hardest to write because they need to feel genuinely good without being saccharine. The character's voice remains itself; it just sits in it more easily.
**`suspicious` — narrowing and intensification**
Something's off. The character's attention is directing toward a specific object or pattern.
- *Smuggler:* More NPC-specific observation. Questions multiply around the specific person or thing. Hedges fall off — she trusts her read. Operational contingency thinking activates: "If that's what I think it is, then..."
- *Detective:* Filing rate increases. "Coincidence?" appears more. He cross-references against baseline more explicitly. The analytical questions become hypotheses rather than open threads.
Lines tagged `mood: suspicious` should feel *directed*. The suspicion has an object — even if the character can't name it yet. The voice narrows toward whatever is generating the suspicion.
**`warm` — relational softening**
The character is in a moment of genuine positive connection. This is not `content` (good situation) — this is specifically warm *toward someone*.
- *Smuggler:* More name-drops with positive color. People-reads are generous and long. She notices when someone seems well, not just when they seem off. Humor becomes inclusive rather than dry. She might express something like affection, obliquely.
- *Detective:* First-name register expands beyond Sera — he might first-name someone he normally surnames if the moment calls for it. Personal disclosure is slightly more available. The analytical frame softens: he observes without immediately filing.
Lines tagged `mood: warm` are most visible in the detective because they represent a departure from his baseline. For the smuggler, warmth is closer to her baseline; the lines should be a slightly more saturated version of her natural register.
**`hostile` — clinical or defensive reduction**
The relationship to the hostile object is adversarial. The character is managing threat.
- *Smuggler:* Less said, more watched. She goes quiet around the hostile object — mentions them tersely, observations are monitoring-reads rather than social-reads. "Nils walked past." Period. No color.
- *Detective:* Dehumanizing label returns even for NPCs who have previously graduated to first-name in his monologue. "Venn, S." instead of "Sera" signals the relationship has turned. Analytical lines about the hostile object are more clinical.
Lines tagged `mood: hostile` should feel like distance enforced deliberately. Neither character becomes cruel — cruelty requires emotional investment. The hostile register is managed withdrawal.
**`focused` — operational reduction**
The character is executing a task. Everything not relevant is stripped out.
- *Smuggler:* The operational countdown is at maximum. Times, routes, names in task-relevant order. Environmental texture disappears. People are reduced to their role in the current operation. She's not cold — she's locked in.
- *Detective:* The analytical frame at its most mechanical. Labels only. No editorial. "Terminal. Commission access. Logging in." He strips his voice to the minimum that keeps the investigation moving.
Lines tagged `mood: focused` should feel efficient to the point of austerity. For the smuggler especially, this is the mode that feels most competent — she's at her best when she's locked in.
---
### 4.3 Mood-as-Selection-Weight (Technical Note for Gestalt)
The mood tags on monologue lines function as selection weights in the engine:
1. The simulation produces a current `mood_state` for the player character (server-side, from the mood system in #323)
2. The line selection system applies a weight bonus to lines tagged with the matching mood
3. Lines tagged `mood: [neutral]` or untagged are always eligible; lines tagged with specific moods are preferred when the mood matches
**The same base line should NOT be re-authored per mood.** A smuggler entering The Terminal does not need 9 `enter_location` variants (one per mood). The system should:
- Select from the available `enter_location` pool
- Apply mood-weight bonuses to lines with the matching mood tag
- Ensure at least 1-2 lines per trigger/location have each major mood tagged
For FRIEND arc content specifically, mood-specific variants are most valuable at crisis beats (Phase 3-4 of the arc), where the emotional register shifts dramatically and the mood state is predictable. Authors should prioritize mood-tagging for:
- Post-contradiction observation lines (likely `suspicious` or `anxious`)
- Post-confrontation post-conversation lines (likely `warm``hostile` transition zone)
- Contaminated trust time_idle lines (likely `suspicious` + `warm` collision)
---
## Chapter 5: Modifier Combination Logic
Traits, backgrounds, and moods interact. Some combinations have emergent properties:
**High coherence combinations** (reinforce each other):
- Cautious + Guardian background: more second-guessing of institutional solutions, more checking of network alternatives
- Bold + Ruthless: decisive and instrumental — the fastest decision-maker, for better or worse
- Compassionate + Worker background: the character who most viscerally feels the human cost of what's happening to Kael and Naia
- Suspicious mood + Curious trait: questions multiply exponentially — the character follows every thread simultaneously
**High tension combinations** (create internal conflict):
- Honest + Deceptive situation (character who can't self-lie encountering a situation where she needs to): the monologue catches itself mid-rationalization and overrides it
- Ruthless + warm mood: the warmth is visible and slightly uncomfortable — the character senses she's letting her guard down, notes it
- Senator background + Worker background idioms (where the character's formation bleeds through the professional veneer): the occasional shift vocabulary surfaces in otherwise formal speech
**FRIEND arc special case:**
Trait and background modifiers should be considered especially carefully for lines in the FRIEND arc. The same arc beat — "Kael met someone I don't know" — produces dramatically different emotional readings depending on trait combination:
| Trait | Smuggler's first-move internal response to the Kael contradiction |
|-------|------------------------------------------------------------------|
| Cautious | Checks herself: "Maybe it's nothing. There could be a work reason." (sits with it longer) |
| Bold | Names it immediately: "That's a breach. Kael met someone unauthorized." |
| Compassionate | Worries about Kael first: "What's he gotten himself into?" |
| Ruthless | Calculates exposure: "How much does this contact know?" |
| Honest | Won't minimize: "He was there. That wasn't anyone we know." |
| Deceptive | Minimizes: "Kael's allowed his own contacts. It's fine." (not fine) |
These aren't separate arc paths — they're the same arc with different pacing and emotional texture. A Cautious smuggler reaches the confrontation later. A Bold one gets there faster and possibly too fast. The arc is the same. The voice is not.
---
## Appendix: Background Phrasing Quick Reference
For Mellanie — a vocabulary guide per background to use when writing example lines:
**Guardian background idioms:**
- "Looking out for your people" / "covering for each other" (protection network language)
- References to Commission as "them," surveillance as a threat
- Collective pronouns where others might use "I": "we don't report things like this"
- Suspicion of institutional solutions: "who does that actually serve?"
**Senator background idioms:**
- "On record" / "that goes on record" / "recorded as"
- Leverage vocabulary: "what does that cost us," "who holds the note on that"
- Chain-of-command awareness: "above Nils" / "below Voss on that"
- Process language: "formally," "through channels," "the filing on that says"
**Worker background idioms:**
- Shift-economy references: "that's a week's difference," "shift schedule says"
- Practical solidarity: "you cover your people," "that's what you do"
- Acceptance of conditions: "that's how the station runs" (not resigned — just realistic)
- Concrete physical references: "the smell of cargo lubricant," "my feet ache"
---
*Narrative framing complete as of 2026-02-20. Mellanie to supply example lines for all section prompts marked "Mellanie — write X examples." Gestalt to validate mood → delivery mappings in Chapter 4 for mechanical coherence with server-side mood system (#323).*
---
## Gestalt — Mood → Delivery Validation (2026-02-20)
**Status: RESOLVED — schema renamed Sprint 14 to match voice guide vocabulary.**
Schema and Rust `Mood` enum updated to use Chapter 4 names directly: `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`. Neutral mood = omit tag (untagged lines are always eligible). `analytical` and `conflicted` dropped (merged into `focused` and modeled as `suspicious`+`warm` collision respectively). `hostile` added. Authors can now use voice guide mood names directly in YAML files.
### Mood as selection weight — mechanical coherence
The selection model in Section 4.3 is mechanically coherent with how the pipeline works:
- Line pool query returns all eligible lines (pass Layers 1-3)
- Layer 4 (topic + mood) applies weights, not hard filters — correct
- Untagged lines remain eligible — correct
- Mood-tagged lines are preferred when mood matches — correct
**Trait modifier system (Chapters 2-3):** Mechanically coherent. Traits are authoring modifiers, not runtime filters. No engine-side trait tracking needed for monologue. The authoring guide is sufficient.
**FRIEND arc special case (Section 4.3):** The note about prioritizing mood-tagging for crisis beats is correct. The collision of `suspicious` + `warm` as the trust-contamination zone is the right call.
**Mood → delivery validation: PASS.** The delivery model is mechanically coherent. Vocabulary mismatch resolved. All mood system design approved.
+228
View File
@@ -0,0 +1,228 @@
# Sound Indicator Visual Design — Fog-Edge Pulse Specification
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #317
**Status:** Active — constrains client sound_indicator_renderer.gd implementation
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md), Decision D-018 (three-range sound model)
---
## 1. Purpose and Scope
Sound indicators are **complementary to audio, not a replacement for it.** They exist for players with audio off, in loud environments, or when audio is present but directionality is ambiguous. They should not be noticeable during routine exploration — only when something specific warrants attention.
The v0.1 implementation uses **directional arrows at the viewport edge** as the indicator shape (already implemented in `sound_indicator_renderer.gd`). This spec defines the full intended visual design including the pulse behavior described in the ticket, and notes where the current implementation differs from the spec.
**D-018 scope:** Sound indicators cover the **medium-range tier** (sounds outside LOS, within ~320 tiles). Close-range sounds (≤3 tiles, within LOS) are handled by positional 2D audio — no indicator needed. Within medium range, three sub-tiers (close-medium, standard, far-medium) differentiate visual intensity — see §7 for the full breakdown.
---
## 2. Colors
Per D-018 three-range sound model and D-033/D-048 color vocabulary:
| Sound category | Hex | Usage |
|----------------|-----|-------|
| Neutral | `#c8d0e0` | Footsteps, ambient movement, cargo handling, non-social activity |
| Voice | `#e8c547` | NPC conversation, speech, social activity |
| Danger | `#d45d5d` | Alarms, alerts, gunshots, explosions, threats |
These are the same as the insert chrome color (`#c8d0e0`), the Person of Interest amber (`#e8c547`), and the Hostile red (`#d45d5d`). The alignment is intentional — the sound indicator system is part of the insert overlay, and its color vocabulary maps directly to the entity relationship system. A voice indicator uses the same amber as a Person of Interest entity because *voices are people, and people are potentially interesting.*
---
## 3. Position — Fog Edge, Not Screen Edge
### 3.1 Principle
Sound indicators live at the **boundary of the player's visible cone** — where clear vision meets fog. Not at the physical screen border, not as a minimap overlay.
The current implementation projects indicators to the **viewport boundary** (approximately correct — viewport edge ≈ fog edge at the camera's field of view). This is sufficient for v0.1. The spec's intent is that indicators should feel like they're at the perceptual boundary, not tacked to the UI chrome.
`EDGE_INSET = 20.0px` in the current implementation provides the correct "just inside the boundary" feel.
### 3.2 Z-Layer
Sound indicators render on **z-layer 6** (insert overlay), not z-layer 5 (fog). They are insert data — the character's lattice is processing the sound, not the character's naked ears. They are unaffected by the fog shader.
The current implementation (`sound_indicator_renderer.gd`) renders as a `Node2D` draw call. This should be confirmed as rendering on z-layer 6 in the scene tree.
---
## 4. Shape — Arrow vs Pulse Arc
### 4.1 Current Implementation
The current `sound_indicator_renderer.gd` renders filled arrowhead triangles:
- Tip at the viewport boundary edge point
- Arrow points toward the sound source
- Size: 12px length, 7px half-width
- Filled polygon, not an outline
This is a valid v0.1 placeholder. It communicates direction clearly.
### 4.2 Target Design — Pulse Arc Segment
The intended final design is a **thin arc segment at the fog boundary** rather than a solid arrow. The arc reads as "sound reaching the edge of perception" rather than "here is an arrow pointing at something."
| Property | Value | Notes |
|----------|-------|-------|
| Shape | Arc segment (partial ring) | Centered on source direction, 40° sweep |
| Arc radius | 6px — visually thin | Not a thick ring |
| Position | Fog boundary | Radiates outward from boundary inward by 3px |
| Animation | Pulse outward and fade | Single pulse per event, see §5 |
**Arc sweep:** 40° centered on the direction to the sound source. Narrow enough to clearly point, wide enough to be visible at a glance. A line would be too thin; a semicircle too vague.
**For v0.1:** Continue using the current arrow implementation. The arc design is the target for when sprite art replaces colored rectangles — the arc will feel more natural against full art than a solid arrowhead.
---
## 5. Animation — Pulse Behavior
### 5.1 Current Implementation
The current implementation renders a static indicator (no pulse animation) that lives for `INDICATOR_LIFETIME = 3.5s` and fades over the last `FADE_DURATION = 0.6s`. The fade is a linear alpha ramp from full opacity to 0.
This is functional but does not communicate the sound event as a *moment* — it reads as a persistent marker rather than an alert.
### 5.2 Target Pulse Specification
The intended animation is a **single expanding pulse per sound event**, not a persistent marker:
| Phase | Duration | Behavior |
|-------|----------|----------|
| Onset | 0.00.2s | Indicator appears at full opacity, max brightness |
| Expand | 0.20.6s | Indicator expands outward by 34px (for arrow: scale 1.0 → 1.3) |
| Hold | 0.61.5s | Full opacity, static size |
| Fade | 1.52.5s | Linear alpha 100% → 0% |
**Total duration:** 2.5s (vs current 3.5s). The shorter duration prevents indicators from lingering as persistent clutter.
**For v0.1:** Implement the onset flash (full opacity on appear) and the fade. Skip the expand animation if performance is constrained — the expand is polish.
**Easing:** Onset is instant (no fade-in — sound events are sudden, their indicators should be too). Fade-out uses ease-in (slow start, accelerates to transparent). The sound indicator should feel like it vanishes rather than slowly becoming invisible.
### 5.3 Deduplication
Same-source deduplication is already implemented correctly: if an indicator exists at a tile position, new events reset its timer rather than stacking. This prevents a continuous conversation from spawning dozens of overlapping indicators.
---
## 6. Direction Encoding
### 6.1 Arrow Direction
The current implementation projects the indicator to the viewport boundary in the direction of the sound source from the player. The arrowhead tip points toward the sound source. This correctly encodes direction.
**Rule:** The indicator tip always points toward the source, not away from it. The player reads "the sound is in that direction."
### 6.2 Arc Direction (Target Design)
In the arc design, the arc is centered on the direction vector from player to sound source. The arc's midpoint lies on the line from player to source, at the fog boundary. The 40° sweep is centered on this midpoint. The arc opens toward the source (the open side of the arc faces the player, the midpoint faces the source).
---
## 7. Range Differentiation — Three Visual Levels
D-018 specifies three distance ranges. Sound indicators apply to medium range (outside LOS), but within medium there are visual levels based on proximity:
| Distance | Visual treatment | Alpha | Notes |
|----------|-----------------|-------|-------|
| Close-medium (35 tiles) | Full indicator, 90% alpha | 90% | Clear, noticeable |
| Standard medium (512 tiles) | Standard indicator, 70% alpha | 70% | Visible but not urgent |
| Far-medium (1220 tiles) | Smaller indicator, 45% alpha | 45% | Subtle, ambient |
**For the arrow implementation:** Scale the arrow by distance proxy — close-medium at `scale(1.0)`, far-medium at `scale(0.7)`. The size reduction plus alpha reduction creates a clear near-vs-far reading.
**Alpha cap at 90%:** Sound indicators should never be fully opaque. They are insert data, not a HUD alert. The 10% transparency gap maintains their insert-layer quality.
---
## 8. Suppression Rules — When Indicators Do NOT Appear
Sound indicators are suppressed in the following conditions:
| Condition | Rule |
|-----------|------|
| Sound source within player's LOS | Suppressed. The player can see/hear the source directly. |
| Sound source at close range (≤3 tiles) | Suppressed. Close range is handled by positional 2D audio. |
| Sound source outside 20-tile radius | Suppressed. Long-range sounds are insert notification territory (future sprint). |
| `insert_active == false` | Suppressed. Sound indicators are insert overlay elements (z-layer 6). |
| Danger-category sound, player already in dialogue | **Not suppressed.** Danger indicators break through dialogue focus. (See note.) |
**Dialogue suppression exception for Danger:** Per D-070 (confrontation as cognitive vulnerability), ambient sounds are muffled during confrontation/dialogue — but this is audio suppression, not visual suppression. If an alarm fires while the player is in dialogue, the danger indicator should still appear. The player may not hear the alarm (audio is dipped) but the insert catches it. This is the insert doing its job: processing data the character's conscious attention missed.
**Voice indicator suppression logic:** `event_type` containing "voice"/"speech"/"convers"/"talk" → Voice category. This is already implemented in `color_for_type()` in the current renderer. No change needed.
---
## 9. Current Implementation vs Spec — Delta Summary
| Aspect | Current (v0.1) | Target (spec) |
|--------|---------------|---------------|
| Shape | Filled arrowhead | Arc segment (40°, 6px radius) |
| Animation | Static + linear fade (3.5s total) | Pulse onset + fade (2.5s total) |
| Range differentiation | None — all indicators same size | Three levels: 3px scale + alpha |
| Z-layer | Needs confirmation | Z-layer 6 (insert overlay) |
| Direction | Correct — arrow points toward source | Same principle, different shape |
| Colors | Correct — uses Constants palette | Same |
| Deduplication | Correct — resets timer on re-trigger | Same |
| Insert-off suppression | Not yet implemented | Suppress when `insert_active == false` |
**v0.1 implementation priority:**
1. Confirm z-layer 6 placement (quick fix if wrong)
2. Add insert-off suppression
3. Add basic range alpha differentiation (single alpha pass by distance)
4. Keep arrow shape — replace with arc when sprite art arrives
---
## 10. Visual Design Rationale
**Why at the fog edge, not screen edge or minimap?**
Screen-edge indicators have no spatial relationship to the game world — they're purely navigational UI. The fog boundary is where the character's perception ends. A sound at the fog edge is a sound at the limit of what the character can process. Placing the indicator there is diegetically honest: the character's insert is flagging something at the edge of their awareness, not beyond it.
Minimap overlays require a minimap. We don't have one in v0.1. And minimap is meta-game — the indicator at the fog edge is in-world.
**Why single pulse, not persistent marker?**
Sound is a moment, not a state. A sound event happens at a point in time and then is over. A persistent marker would imply "there is still a sound here," which isn't necessarily true. The single pulse says "something happened in that direction." The player acts on it or doesn't.
**Why amber for voice, not a neutral sound color?**
Because voices are the most important medium-range sound in the game. NPCs talking to each other is signal. Footsteps are noise. Making voice indicators amber — the same as Person of Interest entity color — trains the player to associate amber with "social activity worth paying attention to." The color vocabulary reinforces the relationship system.
---
## Appendix A — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-018 | Three-range sound model — source for indicator tier assignment. |
| D-047 | Two-tier animation. Tier 2 NPC behaviors produce the sound events that trigger voice indicators. |
| D-048 | Insert overlay visual language — indicators are part of the insert, not the world. |
| D-049 | Z-level rendering stack — z-layer 6 for insert overlay. |
| D-059 | Fog shader. Sound pings in fog are separate from sound indicators (fog layer concentric rings vs insert edge arrows). |
| D-070 | Confrontation as cognitive vulnerability — danger indicators not suppressed during dialogue. |
## Appendix B — Quick Reference for Stig
| Property | Value |
|----------|-------|
| Z-layer | 6 (insert overlay) |
| Neutral color | `#c8d0e0` |
| Voice color | `#e8c547` |
| Danger color | `#d45d5d` |
| Max alpha | 90% |
| Total visible duration (target) | 2.5s |
| Total visible duration (current) | 3.5s |
| Fade duration | 1.0s ease-in |
| Deduplication | Reset timer on same-tile re-trigger |
| Range alpha levels | Close-medium 90% / standard 70% / far-medium 45% |
| Suppression: within LOS | Yes |
| Suppression: insert off | Yes |
| Suppression: danger + dialogue | No — danger breaks through |
+229
View File
@@ -0,0 +1,229 @@
# Tell Visual/Behavioral Expression — Specification
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #251
**Status:** Active — visual design input to server-side tell behavior (Dudley, future sprint); constrains monologue content (Mellanie)
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§6, animation tier system), [THE FRIEND Visual Treatment](the-friend-visual-treatment.md) (#318), Decisions D-024, D-047
---
## 1. What This Document Covers
The tell system (D-024 axis: Tell System) defines behavioral signals that NPCs exhibit when their underlying state creates tension with their public behavior. A nervous NPC shows nervousness. An angry NPC shows anger. These are not metatextual UI indicators — they are behavioral patterns in the top-down renderer.
**Araminta's scope:** How tells look at the tile level — what movement patterns, position choices, and timing behaviors constitute each tell category. This is the design input that Dudley uses to implement movement and pathfinding modifiers on the server.
**Dudley's scope:** Implementing these behaviors as simulation-side movement decisions, pathfinding priority changes, and activity state modifications.
**Mellanie's scope:** Writing the monologue lines that fire when the player observes a tell via `observe_npc` trigger. In v0.1, tell expression is primarily via **monologue text** — the server emits a monologue trigger when a tell is active and the player observes the NPC.
**The tell is not visible as a UI element.** There is no icon, no indicator, no animation label. The tell manifests as a behavioral pattern that the player may or may not notice, may or may not interpret correctly. The monologue (if it fires) provides the character's interpretation.
---
## 2. Tell Categories and Animation Tier Mapping
D-024 defines the tell system axis on the NPC model. This spec identifies 5 tell categories derived from the axis. Each maps to one or more Tier 2 behavioral expressions (D-047 animation tier system). The mapping is based on what the tell would look like from a top-down view on a character operating in a public space.
| Tell category | Internal state | Primary Tier 2 behavior | Secondary behavior | Notes |
|---------------|---------------|------------------------|-------------------|-------|
| **Nervous** | Stress above tolerance threshold, concealment at risk | Movement hesitation + route checking | Proximity avoidance to specific zones | Most common for characters with active secrets |
| **Angry** | High stress, contested relationship, tolerance reached | Accelerated/direct movement | Short dwell times | Anger in the Commonwealth is internalized — no outburst in public |
| **Friendly** (suppressed) | Wanting to interact but constrained | Lingering near character / zone | Approach-and-withdraw pattern | Friendly tell occurs when NPC wants contact but can't initiate |
| **Guarded** | Protective of information or person | Proximity positioning | Route shielding | NPC places themselves between player and something/someone |
| **Routine deviation** | Normal routine interrupted by higher priority | Unexpected location, unexpected timing | Unusual activity for current day phase | The broadest tell — anything outside the established pattern |
---
## 3. Tier 2 Behavioral Vocabulary
These are the Tier 2 animation states that serve as tell expressions. Each behavior is observable in the top-down renderer and ambiguous in isolation — the player sees *what* but not *why*.
### 3.1 Movement Hesitation
**Appearance:** NPC stops at a tile boundary for 0.52.0 seconds before entering a room, doorway, or open space. The pause is distinct from the NPC's Idle animation — it is directional (NPC is facing the destination, not standing neutrally). No heading change during the pause.
**Ambiguity:** Break? Waiting for someone? Checking the space? Nervous? All are plausible.
**Server implementation note:** A `PathRequest` with a brief pre-entry wait injected before the final move into the destination tile. Duration variable by stress level (higher stress = longer hesitation, up to 2.0s).
**Which tells use it:**
- **Nervous:** Primary expression. NPC hesitates before entering a space they associate with risk.
- **Guarded:** Secondary expression. NPC hesitates before committing to a route past a target zone.
### 3.2 Route Deviation
**Appearance:** NPC takes a longer path between two points than the direct route. The direct route passes by a certain person, object, or zone; the NPC routes around it. To a player who doesn't know the direct route, this looks like normal navigation. To a player who has mapped the space, this looks wrong.
**Ambiguity:** Corridor blocked? Just habit? Avoiding someone? Picking something up along the way?
**Server implementation note:** Pathfinding with an avoidance weight on specific tiles associated with the avoided entity/zone. The alternate route is valid — just longer than necessary. The NPC arrives at their destination; they just took a detour.
**Which tells use it:**
- **Nervous:** NPCs nervous about a person route around that person.
- **Guarded:** NPCs protecting a location route to block others from approaching it via the most direct path.
- **Routine deviation:** Primary expression. Route is different from the established pattern for this NPC at this day phase.
### 3.3 Lingering
**Appearance:** NPC arrives at a location but does not begin a Tier 1 activity. They stand in the area, possibly using Idle animation, for longer than transit time would explain. The NPC's presence in the location appears unmotivated — they have not started working, eating, or talking.
**Ambiguity:** Waiting for someone? Thinking? Watching the door? On a break they didn't plan?
**Server implementation note:** After completing `PathRequest` to a destination, inject an unmotivated `Idle` activity state before assigning the next `DailyRoutine` activity. Duration variable by tell intensity. The NPC is physically present but not executing their scheduled behavior.
**Which tells use it:**
- **Friendly (suppressed):** Primary expression. NPC has reason to want contact with the player but cannot initiate. They linger near the player's likely path.
- **Nervous:** Secondary expression. NPC lingers near an exit or junction (choosing a direction).
- **Guarded:** Primary expression. NPC lingers near the person or thing they are protecting — present without clear activity.
### 3.4 Approach-and-Withdraw
**Appearance:** NPC moves toward a character or location, stops within ~3 tiles, pauses, then moves away without initiating interaction. This is a two-step Tier 2 behavior — approach (pathfinding toward target), hesitation (brief stop), withdrawal (pathfinding to alternate destination). The complete sequence takes 48 seconds.
**Ambiguity:** Changed their mind? Forgot something? Lost nerve? Saw something that made them reconsider?
**Server implementation note:** Two sequential `PathRequest` instances with an unmotivated `Idle` state (12s) between them. First request targets a tile near the goal entity/zone. Second request targets a tile away from it.
**Which tells use it:**
- **Friendly (suppressed):** Primary expression. The NPC wanted to speak with the player but couldn't bring themselves to do it.
### 3.5 Grouping (Proximity Positioning)
**Appearance:** NPC persistently positions themselves near a specific entity or zone without a Tier 1 activity justifying the proximity. Unlike lingering (which is about staying in one spot), grouping behavior involves movement that tracks a moving target — the NPC adjusts their position as the other entity moves.
**Ambiguity:** Friends? Colleagues? Monitoring? Protecting?
**Server implementation note:** Pathfinding goal shifts dynamically based on target entity position — maintain ~23 tile proximity without entering interaction range. This is distinct from escorting (which is intentional) or conversation (which has its own state).
**Which tells use it:**
- **Guarded:** Primary expression. NPC positions themselves between the player and a protected entity.
- **Nervous:** Secondary expression. NPC unconsciously stays near someone they trust when stressed.
### 3.6 Avoidance
**Appearance:** NPC reacts to the player's (or another entity's) presence by increasing distance. When the player enters a zone, the NPC finds a reason to be in a different part of it, or exits. This is distinguishable from normal zone movement because the timing correlates with the player's arrival — but correlation is not confirmation.
**Ambiguity:** Coincidental? Habit? Assigned to another area? Afraid?
**Server implementation note:** On player entry to zone, if tell is active, NPC's current `ActivityTarget` is replaced with one on the far side of the zone or in an adjacent zone. The replacement looks like a routine update — there is no visual signal of the change. Timing threshold: avoidance triggers within 3 ticks of player zone entry.
**Which tells use it:**
- **Nervous:** Secondary expression. NPC avoids the person they are nervous about.
- **Guarded:** Secondary expression. NPC moves away from the player to avoid revealing the protected entity by their own proximity.
---
## 4. Tell Category → Behavior Matrix
| Tell category | Movement hesitation | Route deviation | Lingering | Approach/withdraw | Grouping | Avoidance |
|---------------|---------------------|----------------|-----------|-------------------|---------|-----------|
| **Nervous** | ●● Primary | ● Secondary | ● Secondary | — | ● Secondary | ● Secondary |
| **Angry** | — | — | — | — | — | ● Primary |
| **Friendly (suppressed)** | — | — | ●● Primary | ●● Primary | — | — |
| **Guarded** | ● Secondary | ● Secondary | ●● Primary | — | ●● Primary | ● Secondary |
| **Routine deviation** | — | ●● Primary | ●● Primary | — | — | — |
`●●` = primary expression, `●` = secondary expression, `—` = not used.
**Angry tell note:** Anger in the Commonwealth is internalized in public spaces. An angry NPC does not have an outburst. They move faster (shorter dwell times, quicker route execution), speak shorter sentences (Mellanie's domain), and avoid the person they are angry with if they can manage it. Avoidance is the primary behavioral tell. There is no raised fist or stamped foot. The station is a workplace; people here manage.
---
## 5. Tell Intensity — Scaling the Visual Weight
Tell behaviors scale in intensity based on the NPC's current stress level or trigger severity. The intensity affects duration and frequency, not the type of behavior.
| Intensity level | Server trigger | Hesitation duration | Linger duration | Route deviation |
|-----------------|---------------|---------------------|----------------|-----------------|
| Low | StressAboveThreshold barely met | 0.5s | 35s | Minor (+10% path length) |
| Medium | Stress significantly elevated | 1.0s | 510s | Moderate (+2030% path length) |
| High | DuringActivity / NearSpecificEntity triggered | 1.52.0s | 1015s | Major (+50%+ path length) |
**High-intensity tells are rare.** If every NPC is visibly nervous all the time, the signal degrades. Tells are most powerful when they occur against a background of normal behavior. The player must earn the observation by paying attention.
---
## 6. Tell Recognition — Monologue as the Interpretation Layer
In v0.1, tell expression in the renderer is the behavior. The monologue is the character's interpretation.
**The player sees:** An NPC hesitating before entering the cargo office.
**The character thinks (monologue):** *"She stopped before going in. Like she needed to decide something."* (Observation tier monologue, `observe_npc` trigger, tell active for Nervous category)
The monologue fires when:
1. The player is observing the NPC (cursor hover / Observe verb)
2. A tell is active on that NPC
3. The character's knowledge state is sufficient to notice (basic observation — no special knowledge required for surface-level tell recognition)
The monologue does NOT confirm the tell's meaning. It describes what was seen, in the character's voice, without resolving the ambiguity. The detective's monologue and the smuggler's monologue for the same tell will interpret it through different cognitive registers (Mellanie's domain).
---
## 7. THE FRIEND's Tell Trajectory
### 7.1 Kael Davan (Smuggler's FRIEND)
**Pre-discovery:** Kael exhibits routine deviation tell — he has a regular dock schedule but occasionally takes longer routes. In Phase 1 and Phase 2, this is unnoticed or attributed to route preference. The tell is present from session start.
**Contradiction seen:** Kael in restricted corridor meeting. The player observes the meeting itself — this is not a tell but a direct observation. After the meeting, Kael's nervous tell activates at higher intensity (movement hesitation, avoidance of the player). The behaviors the player may have noticed but ignored before now carry new meaning.
**Post-discovery:** Kael's tell pattern becomes legible. The approach-and-withdraw the player may have seen once (Kael walking toward the dock, pausing, turning) now reads as Kael wanting to say something but being unable to. The player interprets the same behavior through a new frame.
### 7.2 Sera Venn (Detective's FRIEND)
**Pre-discovery:** Sera exhibits avoidance tell around Torek Lintar. She routes around him, leaves when he enters The Last Shift. This is observable from the start — but without context, the player reads it as social preference.
**Contradiction understood:** The player makes the connection: Sera's avoidance of Torek correlates with Kael's manifest discrepancies. Sera knows something and is avoiding the person who would use that information.
**Post-discovery:** Sera's guarded tell (proximity positioning near Naia, avoidance of Torek and the detective) becomes legible as protective behavior. The detective character has a trained observation skill — they should notice this. The monologue should reflect recognition ("*She's not avoiding him. She's keeping herself between him and something.*").
---
## 8. Scope Boundaries
**In scope (Araminta / this spec):**
- What tells look like as tile-level movement patterns
- Mapping of tell categories to Tier 2 behaviors
- Intensity scaling guidance
**In scope (Dudley / server implementation):**
- Implementation of movement hesitation via PathRequest wait injection
- Route deviation via pathfinding avoidance weights
- Lingering via unmotivated Idle activity state
- Approach-and-withdraw via two sequential PathRequests with pause
- Grouping via dynamic proximity pathfinding target
- Avoidance via zone-entry reactive ActivityTarget replacement
- TellSystem component and TellTrigger matching (`StressAboveThreshold`, `NearSpecificEntity`, `DuringActivity`, `TimeOfDay`, `Always`)
**In scope (Mellanie / monologue content):**
- Monologue lines for each tell category, per character (`observe_npc` trigger + tell active)
- Maintaining ambiguity in the monologue — describing behavior without confirming intent
- Character-differentiated interpretation (detective reads analytically; smuggler reads socially)
---
## Appendix A — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-024 | NPC generation model. Tell system axis definition. 5 tell categories. TellTrigger model. |
| D-047 | Two-tier animation system. All tell behaviors are Tier 2 expressions. |
| D-033 | Entity color. No tell modifies entity color — tells are behavioral, not chromatic. |
| D-016 | Internal monologue as perception bridge. Monologue fires on observed tell. |
| D-044 | Visual hierarchy. Tells are behavioral, not decorative — no overlays, no icons. |
## Appendix B — Quick Reference for Dudley
| Tier 2 behavior | Server mechanism | Trigger |
|----------------|-----------------|---------|
| Movement hesitation | Pre-entry wait in PathRequest | StressAboveThreshold, NearSpecificEntity |
| Route deviation | Pathfinding avoidance weight on specific tiles | StressAboveThreshold, DuringActivity |
| Lingering | Unmotivated Idle between PathRequest and DailyRoutine activity | NearSpecificEntity, Always (for guarded) |
| Approach-and-withdraw | Two sequential PathRequests + Idle (12s) between | NearSpecificEntity (friendly suppressed) |
| Grouping / proximity positioning | Dynamic pathfinding target tracking entity | NearSpecificEntity (guarded) |
| Avoidance | Zone-entry reactive ActivityTarget replacement | NearSpecificEntity, StressAboveThreshold |
**Intensity scaling:** Hesitation duration 0.5s / 1.0s / 1.52.0s. Linger duration 35s / 510s / 1015s. Route deviation +10% / +2030% / +50%+ path length. Intensity driven by stress value distance above threshold.
+247
View File
@@ -0,0 +1,247 @@
# Text Display Hierarchy — Visual Specification
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #316
**Status:** Active — extends monologue display spec (#315); constrains client #122 implementation
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§5), [Monologue Display Spec](monologue-display-spec.md) (#315)
---
## 1. Overview — Four Content Pipelines
The game has four distinct text channels, each with different visual identity, screen position, z-layer, and content source. They must be immediately distinguishable from each other at a glance — not through decoration but through consistent placement, size, and color rules.
| Pipeline | Source | Screen position | Z-layer | Content register |
|----------|--------|----------------|---------|-----------------|
| **Dialogue** | NPC speech + player response options | Bottom of screen | 7 | External — what is said aloud |
| **Internal monologue** | Player character's inner voice | Lower-left, above dialogue | 7 | Internal — what the character thinks |
| **Observation / overheard** | Perceived NPC speech, passive panel | Bottom of screen, passive mode | 7 | Filtered external — what the player overhears |
| **Environmental text** | Signage, terminals, news tickers | World layer, in-scene | 24 | Diegetic — part of the physical world |
The dialogue and observation pipelines share the same screen container (bottom panel) but in different modes. Internal monologue occupies the lower-left quadrant. Environmental text lives in the world, not in any HUD layer.
---
## 2. Pipeline 1 — Dialogue
### 2.1 Layout
Per D-061 and D-076:
| Property | Value | Notes |
|----------|-------|-------|
| Screen position | Bottom center | Centered horizontally, anchored to bottom edge |
| Max height | 20% of screen height | 216px at 1080p |
| Max width | 640px | Per D-076. Grid-aligned (20 × 32px tile). Centered. |
| Layout direction | NPC speech on top, response options below | Left-aligned within the box |
| Max visible response options | 3 | Locked options invisible — D-062 |
| Portraits | None | NPC is on screen. Portrait is redundant. |
### 2.2 Typography
| Element | Size | Hex | Opacity | Weight |
|---------|------|-----|---------|--------|
| NPC speech | 16px Michroma | `#e8eaf0` | 100% | Regular 400 |
| NPC speaker name | 14px Michroma | D-033 color of NPC | 90% | Regular 400 |
| Player response option | 14px Michroma | `#c0c8d8` | 90% | Regular 400 |
| Confrontation option | 15px Michroma, italic | `#e0e8f0` | 100% | Regular 400 |
**Speaker name treatment:** The NPC's name renders in their current D-033 relationship color — teal, green, amber, or red. This is the one place where the D-033 color system bleeds into the text layer and it is intentional: the player sees "Kael" in green and "Sera" in amber and reads that immediately.
**Confrontation options** are italicized and 1px larger than standard response options. They express the character's internal voice speaking aloud — they should feel heavier than regular dialogue choices. No bold. Italic alone signals the weight.
### 2.3 Box Framing
The dialogue box has minimal framing — not a heavy panel, not floating text:
- Thin 1px border at `#333340` (standard outline color) at 60% opacity
- Background: `#0e1218` at 75% opacity (matches Terminal zone ambient, cool neutral dark)
- No rounded corners — geometric, insert-styled
- Box is full max-width (640px) even when content is shorter — consistent spatial expectation
### 2.4 Passive Mode (Overheard Conversation — D-078)
When the player is overhearing an NPC-to-NPC conversation, the same box renders in **passive mode**:
- No response options rendered
- Header row: `[NPC_A] → [NPC_B]` in their respective D-033 colors, at 13px, 70% opacity
- Overheard speech line at 16px, but at 70% opacity (vs 100% for direct dialogue)
- 1px border at `#333340` at **40% opacity** (dimmed vs interactive 60%) — visual signal that this is passive
- Occluded words rendered as `...` in `#555566` (fog blob grey) — the player sees the gap
Walking out of earshot dismisses the panel naturally (no close button, same as direct dialogue).
### 2.5 Walk-Away Fade
When the player walks away (WASD during dialogue), the dialogue box fades over **300ms** (D-064). No close button. No "dismiss" verb. Walking away is the action.
---
## 3. Pipeline 2 — Internal Monologue
Per the full specification in [Monologue Display Spec](monologue-display-spec.md) (#315). This section summarizes the key positioning rules for hierarchy legibility.
### 3.1 Position Summary
| Property | Value |
|----------|-------|
| Horizontal anchor | Left edge + 5% screen width margin |
| Vertical anchor | 25% from screen bottom (above dialogue box + 5% gap) |
| Max text width | 50% screen width (960px at 1920px) |
| Z-layer | 7 |
| Font | 13px Michroma |
| Stack direction | Bottom-up (newest line at bottom) |
| Max visible lines | 3 |
### 3.2 Relationship to Dialogue Box
Monologue floats **above** the dialogue box, always. When dialogue is inactive, monologue remains anchored at 25% from bottom — it does not slide down into the vacated space. Consistent lower-left positioning trains the player to read the lower-left as inner thought.
### 3.3 Character Colors
| Character | Standard | Urgent |
|-----------|----------|--------|
| Detective | `#d0d4e0` at 85% | `#e0e8f8` at 100% |
| Smuggler | `#d8d0c4` at 85% | `#f0e4d4` at 100% |
Full fade/timing specification is in the Monologue Display Spec. These values are restated here for hierarchy reference only.
---
## 4. Pipeline 3 — Observation / Overheard
The observation pipeline has two sub-modes:
**Overheard NPC-to-NPC conversation:** Rendered in the passive dialogue panel (see §2.4). Same screen container, different visual mode (dimmed border, no response options, header speaker attribution, stochastic word-drop via `...`).
**Non-conversation observation text:** When the player perceives something worth narrating — an NPC's behavior, environmental detail, non-verbal event — this routes through the **internal monologue system** via the `observe_npc`, `observe_anomaly`, or `witness_interaction` triggers. These render as standard monologue lines (§3) per the Monologue Display Spec. They are NOT a separate visual element.
**Design note:** Observation and monologue share the same rendering pipeline because from the character's POV, observation IS internal monologue. The character notices something → the character thinks about it → the same text appears in the same position. The authoring side distinguishes them via trigger type; the rendering side does not.
---
## 5. Pipeline 4 — Environmental Text
Environmental text is part of the **world layer**, not the HUD. It renders as if physically present in the scene — on surfaces, above terminals, in scrolling tickers.
### 5.1 Z-Layer Assignment
| Sub-type | Z-layer | Notes |
|----------|---------|-------|
| Floor signage (painted markings) | 01 | On the floor, walked over |
| Wall signage | 2 (with furniture/objects) | On object layer, y-sorted |
| Terminal ambient display | 4 (overhead layer) | Above entities. Semi-transparent. |
| News ticker | 4 (overhead layer) | Same as terminal ambient |
Environmental text is in the world, not in the HUD layers (67). It **must not occlude entities**: anything on z-layer 4 renders at semi-transparent (~70% opacity per the overhead occlusion rule, D-049 §4.4). If an entity passes behind a sign, the sign's opacity means the entity remains partially visible.
### 5.2 Typography
All environmental text uses **Michroma** — no exceptions. The fiction is that all text is mediated through the character's neural insert perception layer. Even handwritten signs are rendered in Michroma at appropriate size/opacity.
| Sub-type | Size | Hex | Opacity | Notes |
|----------|------|-----|---------|-------|
| Zone signage (short) | 11px | `#8899aa` | 70% | 13 words, spatial label |
| Terminal ambient ID | 11px | `#8899aa` | 60% | Equipment identifier, visible at range |
| Terminal active (readable) | 13px | `#a8b8c8` | 85% | When player is adjacent and interacts |
| News ticker | 10px | `#8899aa` | 55% | Scrolling. Ambient, not blocking. |
| Informal signage | 11px | `#9aa890` | 65% | Slightly warmer/greener hue for social zones |
The environmental text color range (`#8899aa` to `#9aa890`) sits clearly below entity saturation — muted blue-greys and grey-greens. They are legible without competing with entities.
### 5.3 Two-State Terminals
Terminals have two visual states:
**Ambient state (player at range):**
- Terminal displays an ambient identifier — short text, equipment type or designation
- 11px Michroma at `#8899aa`, 60% opacity
- Rendered on z-layer 4 (overhead) above the terminal sprite
- Visible from up to ~4 visual tiles (sufficient to plan approach)
**Active state (player adjacent, Observe/Interact verb triggered):**
- Full terminal text readable
- 13px Michroma at `#a8b8c8`, 85% opacity
- Displayed in a small floating text block anchored above the terminal sprite
- Max width: 200px. Text wraps. Capped at ~12 lines of content.
- Same z-layer 4, but increased opacity signals active state
- Dismissed when player moves away (same as dialogue walk-away — no close button)
### 5.4 Bilingual Treatment (D-036)
The Krenn System has two linguistic registers:
**Concordat Standard** — the colonial lingua franca. Neutral, bureaucratic, formal. Written in normal Michroma weight.
**Krenn vernacular** — the local dialect. Compact, consonant-heavy, social. Rendered in the same Michroma but at slightly lower opacity (60% vs 70%) and, when design calls for it, in the informal signage color (`#9aa890` greener hue). This subtle warmth distinguishes the local voice from the colonial layer.
| Context | Language | Color | Opacity |
|---------|----------|-------|---------|
| Formal facility signage | Concordat Standard | `#8899aa` | 70% |
| Equipment identifiers | Concordat Standard | `#8899aa` | 60% |
| Social zone informal signs | Krenn vernacular | `#9aa890` | 65% |
| Bar menu, worker notices | Krenn vernacular | `#9aa890` | 65% |
| Mixed audience (official but social) | Concordat Standard primary, Krenn secondary at 50% opacity | Both | Stacked, smaller |
| News ticker | Concordat Standard | `#8899aa` | 55% |
**Where both languages appear together:** Formal signage targeting mixed audiences renders the primary language at standard opacity, with a smaller Krenn vernacular translation at 50% opacity below it. The two-layer approach communicates the bilingual reality of the district without requiring two UI elements — it is one sign with two registers.
### 5.5 News Tickers
News tickers are ambient environmental text elements attached to specific terminal or screen fixtures in the scene. They are **not** HUD overlays.
| Property | Value |
|----------|-------|
| Position | Anchored above the emitting fixture sprite |
| Z-layer | 4 |
| Width | Width of the fixture sprite (typically 64128px) |
| Font size | 10px Michroma |
| Color | `#8899aa` at 55% opacity |
| Scroll behavior | Right-to-left. Speed: ~30px/second. Loops. |
| Pause on player proximity | When player within 2 visual tiles, scroll pauses. |
| Language | Concordat Standard |
News tickers are ambient information — not intended to be read at a glance. The player has to choose to stand near one to catch the scrolling text. This is intentional: news is environmental texture, not tutorial.
---
## 6. Hierarchy Clarity Rules
These are the rules that keep the four pipelines visually distinct without active management:
1. **Position is identity.** Lower-left = inner thought. Bottom center = speech (active or overheard). In-world = physical reality. Players learn this in 510 minutes and stop reading position consciously.
2. **Opacity descends from HUD to world.** HUD text (monologue, dialogue): 85100%. Environmental text: 5585%. The world is legible but subordinate to what the character is actively processing.
3. **Size descends from speech to environment.** Dialogue speech: 16px. Monologue: 13px. Environmental text: 1013px. The most important active channel is always the largest.
4. **Color temperature signals register.** Cool grey-blue (`#c0c8d8` and variants) = system/HUD/player-facing. Warm cream variants = character voice (smuggler). Muted blue-grey (`#8899aa`) = world/environment.
5. **D-033 colors in text only for speaker names.** NPC name in dialogue rendered in their relationship color. No other text element uses D-033 colors — those are reserved for entity sprites.
---
## Appendix A — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-016 | Internal monologue as core perception system. Source for monologue pipeline. |
| D-036 | Sova Transit District + Krenn vernacular. Bilingual treatment basis (§5.4). |
| D-049 | Z-level rendering stack. Z-layer assignments for all four pipelines. |
| D-061 | Dialogue box layout. Source for §2. |
| D-062 | Invisible locked dialogue — no locked options visible. |
| D-064 | Walk-away — 300ms dialogue fade, WASD dismissal. |
| D-076 | Dialogue max-width = 640px. |
| D-078 | Overheard NPC conversation — passive dialogue panel with occlusion filter. |
## Appendix B — Quick Reference for Stig
| Pipeline | Position | Z-layer | Font size | Primary hex |
|----------|----------|---------|-----------|------------|
| Dialogue — NPC | Bottom center, 640px wide | 7 | 16px | `#e8eaf0` |
| Dialogue — player response | Bottom center, below NPC | 7 | 14px | `#c0c8d8` |
| Dialogue — passive header | Bottom center, header row | 7 | 13px | D-033 colors |
| Monologue — standard | Lower-left, 25% from bottom | 7 | 13px | Per character |
| Environmental — signage | World / object layer | 24 | 11px | `#8899aa` |
| Environmental — terminal active | World / overhead layer | 4 | 13px | `#a8b8c8` |
| Environmental — news ticker | World / overhead layer | 4 | 10px | `#8899aa` |
+209
View File
@@ -0,0 +1,209 @@
# THE FRIEND — Visual Treatment Specification
**Version:** v0.1 (Sprint 14)
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-20
**Ticket:** #318
**Status:** Active — visual design input to server and copy team; constrains #251 (tell visual expression)
**Foundation:** [Entity Color System](entity-color-system.md) (#304), [Visual Grammar v0.1](visual-grammar-v01.md), Decisions D-034, D-033, D-047, D-027
---
## 1. Design Principle
Per D-034: **"Phase 1 identical to other friendly NPCs. Earned visual detail only."**
THE FRIEND does not receive special visual marking. There is no halo, no highlight, no indicator that says "this NPC is important." The player's emotional investment in THE FRIEND is built entirely through story and interaction — accumulated time, dialogue, observed routine. Visual differentiation accrues through narrative, not through marking.
This is non-negotiable. If the game tells the player "this NPC matters," it removes the uncertainty that makes the eventual contradiction devastating. The detective should be conflicted not because the game flagged Sera as special, but because *they made her special through their own attention*.
The wow moment (D-027 success criterion #3: "player names an NPC they felt conflicted about") depends entirely on the visual system having been honest. THE FRIEND looked exactly like any other known/friendly NPC throughout. That's why the color shift lands.
---
## 2. The Two FRIENDs
**Smuggler's FRIEND: Kael Davan** — dock worker, ring member, the smuggler's closest colleague. Contradiction: secret meeting with unknown contact in restricted corridor (trying to exit the ring to protect his partner Naia).
**Detective's FRIEND: Sera Venn** — Commission field tech, bar regular, the detective's social anchor. Contradiction: avoids Torek Lintar while sitting on unreported evidence about Kael's manifest discrepancies (protecting her friend Naia).
Both FRIENDs follow the same visual pattern. The spec applies to both. Where character-specific detail is needed, it is noted.
---
## 3. Phase 1 — Before Relationship Builds (Identical to Any NPC)
### 3.1 Visual State
Phase 1 begins when the player first encounters THE FRIEND. It ends when the player has had 3+ meaningful interactions that build trust.
| Property | Value | Notes |
|----------|-------|-------|
| Entity color | `#6bc9a6` (Known/Friendly green) | Standard D-033 Known state |
| Entity size | 24×32px art, 64×64 canvas | Standard entity size |
| Outline | 2px `#333340` | Standard entity outline |
| Animation tier | Tier 1 | Public daily activities — readable |
| Silhouette feature | One identifying feature | See §3.2 |
| Insert bloom | Soft halo at D-033 green, 23px gaussian | Standard insert treatment |
THE FRIEND is green. Exactly like any other known/friendly NPC. The player has no way to distinguish them from Lera (bar owner), from a dock colleague, from any other Known relationship.
### 3.2 Identifying Silhouette Feature
Each named NPC has one identifying silhouette feature (D-044) that allows recognition by shape before color is processed. These features are design-level decisions — they are built into the NPC sprite and remain consistent throughout all phases.
| NPC | Silhouette feature | Notes |
|-----|-------------------|-------|
| Kael Davan | Vest ridge | The dock-worker vest creates a shoulder-width silhouette difference from generic workers |
| Sera Venn | Uniform collar | Commission field tech uniform collar — distinguishes her from bar-regulars in plainclothes |
| Lera Sessik | Apron shape | Bar owner's apron creates distinctive hip-width silhouette |
**The silhouette feature is NOT a marker.** It is simply what makes Kael look like Kael and not like a generic dock worker. The player identifies him by shape, same as they identify any person in a crowd by how they carry themselves.
In Phase 1, the player will not yet know who Kael is. They see a green entity with a vest ridge. After one or two interactions, they associate vest-ridge = Kael. The identification system builds through play, not through a label.
---
## 4. Phase 2 — Relationship Building (3+ Interactions, Trust Accruing)
### 4.1 Visual State
Phase 2 is entered after the player has had sufficient meaningful interactions with THE FRIEND that trust is accruing (per the server's trust progression system, #324). The player now has a genuine social relationship — not just an acquaintance, but someone they've spent time with.
**Visual changes in Phase 2: none to the entity itself.**
The entity remains green. The silhouette remains the same. No new markers appear.
### 4.2 What "Earned Visual Detail" Means
"Earned visual detail" does not mean the entity sprite changes. It means the player has built up contextual knowledge that makes THE FRIEND *visually meaningful* even without special marking.
The player now knows:
- Where Kael usually is at what time of day (routine familiarity)
- What Kael's silhouette looks like from across a room (pattern recognition)
- Which zone Kael belongs to (environmental anchoring)
This contextual knowledge is itself a form of visual differentiation — not rendered, but real. When the player sees a green vest-ridge entity at the docking terminal at 0800, they don't need a marker to know it's Kael. They've learned it.
### 4.3 Object Layer Detail (D-052)
Per D-052, each NPC has a "favorite color" expressed through personal objects — not entity sprites. By Phase 2, the player may have observed Kael's personal space and noted object-layer details: his particular mug color, the color of his dock-worker ID lanyard, the worn cargo blanket he keeps near his work station.
These are NOT visual markers. They are world texture. But they are the kind of visual detail that makes a person a *person* rather than an entity. The player remembers the mug color. When they see the mug on the table in the maintenance corridor, they know Kael was here.
This is earned visual recognition through accumulated observation — exactly what the principle intends.
---
## 5. Phase 3 — Contradiction Discovered (PersonOfInterest Transition)
### 5.1 The Moment
The contradiction is discovered through player action — observing Kael in the restricted corridor meeting, finding Sera avoiding Torek in a way that doesn't add up. The discovery is not scripted with a cutscene. The player sees it happen in the normal top-down view.
At the moment of contradiction discovery, THE FRIEND's relationship state transitions from `Known/Friendly` to `PersonOfInterest` on the server's knowledge graph. This triggers the D-033 color change.
### 5.2 Color Transition Specification
| Property | Value | Notes |
|----------|-------|-------|
| From color | `#6bc9a6` (Known/Friendly green) | The color the player has seen for hours |
| To color | `#e8c547` (PersonOfInterest amber) | |
| Transition duration | 0.5 seconds | Standard D-033 fade duration |
| Transition type | Linear color lerp | Same as all relationship transitions |
| When triggered | On server's `PersonOfInterest` state delivery | Client receives updated `RelationshipState` in snapshot |
**0.5 seconds is the right duration here.** It is long enough to be perceptible — the player watches green become amber, they don't blink and miss it. It is short enough to feel immediate rather than gradual. This is the one relationship color change the game has been building toward. It must land.
### 5.3 Staging Requirement
This is THE FRIEND's first color change. It must be the first relationship color change the player has seen in the session.
The opening 2025 minutes of gameplay must not trigger any other NPC's relationship state change. No other NPC should transition between any two D-033 states before THE FRIEND's green-to-amber shift. This is a level design and scripting constraint, not a rendering constraint — it is satisfied by game design, not by visual code.
**Why this matters:** The player must have already learned what green means. They must associate `#6bc9a6` with "trusted person I know" before amber can mean "this trusted person has complicated things." Without the reference point, the color shift is just a color shift.
### 5.4 Post-Transition Visual State
Once PersonOfInterest amber is active, THE FRIEND renders with:
| Property | Value | Notes |
|----------|-------|-------|
| Entity color | `#e8c547` | Amber |
| Insert halo | 23px gaussian bloom, amber | Standard insert treatment for amber |
| Outline | 2px `#333340` | Unchanged |
| Silhouette feature | Unchanged | Vest ridge / uniform collar — identity persists |
No additional markers. No special framing. The player's relationship to this entity has changed; the entity itself has not changed. The amber is the signal, not a badge.
---
## 6. Animation Tier Transition
### 6.1 Before Contradiction Discovery — Tier 1
Until the contradiction is discovered, THE FRIEND operates in **Tier 1 animation**: public daily activities, instantly readable, clearly motivated. The player can observe Kael working cargo and understand "he is working." They can observe Sera at the bar and understand "she is relaxing after shift."
Tier 1 behavior is readable by design. This is important: the player needs to feel like they *know* Kael before knowing him becomes complicated.
### 6.2 The Transition Point
**THE FRIEND enters Tier 2 animation when the player has seen the contradiction.**
Not before. Not based on a timer or tick count. The trigger is epistemic — it is not when Kael has the secret meeting (that always happens), but when the player observes it. The contradiction exists in the simulation from session start; the visual weight of it is unlocked by the player's discovery.
Before discovery: Kael walks, works, drinks at Lera's. All Tier 1. Clearly motivated.
After discovery: Kael still walks and works. But now when he pauses near a doorway, the player reads it differently. The behaviors themselves haven't changed — Kael always paused near doorways sometimes. The player's interpretive frame has changed.
**This is the Tier 2 mechanic expressed perfectly:** the animation tier boundary is invisible. The player experiences the shift subjectively, not through a visual mode change.
### 6.3 Behaviors That Become Legible as Tier 2
Once the player has seen the contradiction, previously Tier 1-readable Kael behaviors become Tier 2-legible. The same animation serves both interpretations:
| Behavior | Pre-discovery reading | Post-discovery reading |
|----------|----------------------|----------------------|
| Pausing near corridor B-7 | Break / distracted | Checking if the coast is clear |
| Looking around | Habit / awareness | Watching for the player |
| Lingering near a crate | Moving cargo / waiting | Exchanging something |
| Taking an alternate path | Shortest route was blocked | Avoiding someone |
The simulation may or may not actually be running the Tier 2 intent behind these behaviors — that's Dudley's domain. What matters for the visual spec is that the player's interpretive state changes the meaning of any Tier 2 behavior they observe.
### 6.4 Monologue Frequency
Per D-063: post-confrontation, monologue frequency spikes and available topics narrow. This is not a visual change on the entity but is part of the player's experience of THE FRIEND post-discovery. The character's inner voice reacts. This is specified in the copy team's monologue authoring contracts — reference here for completeness.
---
## 7. Cross-Reference: Entity Color System
THE FRIEND's color transitions are fully governed by the entity color system ([Entity Color System spec](entity-color-system.md), #304). The visual system does not need to know about THE FRIEND specifically — it responds to the relationship state the server delivers.
The visual spec obligation is: ensure the staging conditions are met so that THE FRIEND's green-to-amber is the first relationship color change. After that, the color system handles everything automatically.
---
## Appendix A — Phased Summary Table
| Phase | Trigger | Entity color | Animation tier | Visual changes |
|-------|---------|-------------|----------------|----------------|
| Phase 1: Acquaintance | First visible | `#6bc9a6` green | Tier 1 | None. Identical to any Known NPC. |
| Phase 2: Trust building | 3+ meaningful interactions | `#6bc9a6` green | Tier 1 | None on entity. Player's contextual knowledge accumulates. |
| Phase 3a: Contradiction seen | Player observes contradiction | Transitioning | Tier 1 still | 0.5s green → amber (`#e8c547`) fade |
| Phase 3b: Post-discovery | Amber state locked | `#e8c547` amber | Tier 2 behaviors now legible | Amber sustained. No additional markers. |
---
## Appendix B — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-027 | Vertical slice success criteria. #3: "player names an NPC they felt conflicted about" — this spec serves that criterion. |
| D-033 | Entity color = relationship to player. Source of truth for all color values in this spec. |
| D-034 | THE FRIEND production NPC pattern. Source for phase structure and character profiles. |
| D-044 | Visual hierarchy. Entity always dominant. Silhouette feature as recognition signal. |
| D-047 | Two-tier animation system. Tier 1/Tier 2 boundary, invisible to player. |
| D-052 | Character favorite colors — object-layer identification. Phase 2 earned visual detail. |
| D-063 | Confrontation text styling — italic first-person options, 1.5s monologue beat. Post-discovery monologue behavior (not visual, but contextually related). |
+1 -1
View File
@@ -234,7 +234,7 @@ All sizes are in pixels at 1080p (1920×1080) base resolution. Godot 4 handles D
| Text role | Size | Opacity | Color | Z-layer | Notes |
|-----------|------|---------|-------|---------|-------|
| Dialogue — NPC speech | 16px | 100% | `#e8eaf0` | 7 | Max width ~70% screen width. Left-aligned. |
| Dialogue — NPC speech | 16px | 100% | `#e8eaf0` | 7 | Max width 640px (~33% at 1080p, per D-076). Left-aligned. |
| Dialogue — player response | 14px | 90% | `#c0c8d8` | 7 | Below NPC speech. Up to 3 options visible. |
| Monologue (standard) | 13px | 85% | `#d0d4e0` | 7 | Floats above dialogue box. Inner voice — slightly dimmer than dialogue. |
| Monologue (urgent) | 13px | 100% | `#e0e8f8` | 7 | Same size, full opacity, slight bloom pulse. Urgent chime accompanies. |
+1
View File
@@ -19,4 +19,5 @@ Personal notes and random thoughts. Not acted upon unless explicitly instructed.
- Hook event JSON includes: hook_event_name, notification_type, session_id, cwd, permission_mode
- Gap: hook event data may not include prompt content (tool call details, AskUser question text, multiple choice options) — investigate what's in the full event payload
- Architecture: extend peon-ping's send_notification pattern to push to a remote service, add a response channel back to the terminal
- NPC-to-NPC conversations should be informed by contextual world state: weather changes, local news, local economics, inter-system news, inter-system economics. Also idle chit-chat about personal preferences like favorite brands and colors
+55
View File
@@ -0,0 +1,55 @@
# Sprint 15: React — Client Tasks
**Goal:** The player acts and the world reacts — follow and examine mechanics connect the player to the living NPC simulation; the dialogue system's first two access layers open up information gating; server-side monologue events fire in context; and the personality/tell system completes the NPC data model.
**Branch:** `client`
**Agents:** Stig (UI/rendering dev), Tyre (architect), Hoshe (QA)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #71 | Tilemap rendering system | — |
| #72 | Entity sprite system | — |
| #73 | Input capture system | — |
| #74 | Basic UI framework | — |
| #117 | Smooth camera movement | #116 (done) |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/perception.md` — D-015 (camera locked to character, fixed-north for v0.1), D-019 (top-down confirmed, ~15-20° from vertical "the angle", sprite art convention not camera setting), D-033 (entity color = relationship to player), D-049 (8-layer z-stack), D-059 (fog shader five layers)
- `decisions/architecture.md` — D-020 (Godot client = pure renderer, no game logic in GDScript), D-066 (dual-scale grid: 0.5m simulation, 1m visual, 64x64px visual tile), D-010 (deterministic simulation — client renders what server sends)
- `decisions/perception.md` — D-043 (functional warmth art style), D-044 (entity > object > structure visual hierarchy), D-046 (three-reference lighting: Darkwood/BR2049/Hopper), D-056 (insert-styled cursor states)
## Notes
- **#71 Tilemap rendering system:** `client/scripts/rendering/tile_renderer.gd` exists and is referenced in `world_renderer.gd` as `$FogGroup/FloorTiles` (a TileMapLayer node). The current implementation may be a stub — this ticket must deliver: TileMap node with atlas management, multi-layer support for floor/walls/objects matching D-049 z-stack layers 0 (FloorTiles), 1 (FloorObjects), 2 (furniture in YSortGroup). Tile size is `Constants.TILE_SIZE` (32px visual, per D-066 dual-scale grid where 1 visual tile = 1m = 64x64 source, scaled to 32px runtime). Wall rendering follows D-019 amendment: Option B for structural walls (visible top + face), Option A (boundary lines) for interior partitions. Atlas must accept tiles from `ObserverSnapshot.visible_tiles` (format: `[{x, y, z, type}]`). `world_renderer.gd` already calls `tile_renderer.update_tiles(GameState.visible_tiles)` — ensure `update_tiles()` is the correct entry point.
- **#72 Entity sprite system:** `client/scripts/rendering/entity_renderer.gd` is substantially implemented — it handles D-033 color derivation (Phase 1 defaults by kind, Phase 2 from RelationshipState), position lerp with `LERP_SPEED = 12.0`, and 0.5s color fade for relationship transitions (#521). This ticket must validate and complete: ensure all entity kinds from `ObserverSnapshot.entities` render correctly, including the `kind.variant` field dispatching to correct sprite/shape, the 24x32 entity footprint within 64x64 visual tiles (D-044), and y-sort ordering within `YSortGroup`. If placeholder shapes (ColorRect) are used, that is correct for v0.1 per D-014. Integration check: `world_renderer.gd` drives entity updates through the same `update_from_state()` path — confirm entity renderer is wired there. Follow-mode UI state (#241, server) will need a new field on `GameState` — stub a `follow_target_id: int = -1` for when the server ticket lands.
- **#73 Input capture system:** `client/scripts/autoloads/input_mapper.gd` is implemented with WASD movement (D-054 mouse-relative), stance toggles, Interact, and semantic action dispatch. `InputMapper.Action` enum covers: `MOVE_*` (8 directions), `INTERACT`, `USE_PERCEPTION_MODE`, `OPEN_MENU`, `PAUSE`/`UNPAUSE`, `TOGGLE_STANCE_*`, `SET_FACING`. This ticket must validate the full pipeline: physical key → `Action` enum → `input_queue` → message serialized and sent to server. Verify: movement throttle per stance (Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s) is active, facing angle updates every frame, `InputMapper` correctly suppresses movement when `GameState.dialogue_active == true` (D-061 walk-away via WASD). If any semantic actions are stubbed out or missing from the protocol send path, complete them. The `Follow` verb (#241 server) will arrive via `nearby_interactions` — confirm the client can dispatch an `Interact` action with a specific `response_id` corresponding to Follow.
- **#74 Basic UI framework:** `main.gd` reveals the current HUD structure — `$UILayer/HUD`, `$UILayer/MonologueDisplay`, `$InsertOverlay/InteractionList`, `$InsertOverlay/DialogueBox`, `$UILayer/StanceIndicator`, and more are already wired. This ticket must ensure the HUD structural layout is complete and stable: monologue display area (top of screen or floating, z-layer 7 per D-049), placeholder area for insert/minimap (D-013, not yet implemented), stance indicator visible, and the overall scene hierarchy matches D-049's 8-layer z-stack. The `$InsertOverlay` is z-layer 6 (insert overlay), `$UILayer` is z-layer 7 (UI/monologue). Confirm `GameState.insert_active` controls visibility of z-layer 6 elements per D-056/D-057 OQ-07 resolution. This is a completion + validation ticket — identify gaps in the existing HUD structure rather than building from scratch.
- **#117 Smooth camera movement:** Camera lock to character is done (#116) — `camera` in `main.gd` is a `Camera2D`. `_camera_anchored` and `_teleport_in_progress` flags exist for init sequencing. This ticket adds interpolated camera tracking: instead of snapping `camera.position` to `GameState.player_position` each frame, use exponential smoothing (same pattern as `entity_renderer.gd`'s `LERP_SPEED`). Configurable smoothing: expose a constant or project setting for the smoothing factor. Edge cases to handle — camera must snap immediately on teleport (the `_teleport_in_progress` flag already exists for exactly this), and must not smooth during initial camera anchor (`_camera_anchored` flag). Camera is always fixed-north per D-015 v0.1 scope — no rotation logic.
## Dependency Chain
```
#71 (Tilemap rendering) ─────────────────────────────────────────────┐
#72 (Entity sprite system) ──────────────────────────────────────────┤→ integrated in world_renderer.gd
#73 (Input capture system) → feeds server #241 (Follow), #242 (Examine)│
#74 (Basic UI framework) ────────────────────────────────────────────┘
#117 (Smooth camera) → standalone, parallel track
```
## PR Workflow
When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): sprint 15 react — client deliverables" --description "body" --base main --head client
```
+54
View File
@@ -0,0 +1,54 @@
# Sprint 15: React — Copy Tasks
**Goal:** The player acts and the world reacts — follow and examine mechanics connect the player to the living NPC simulation; the dialogue system's first two access layers open up information gating; server-side monologue events fire in context; and the personality/tell system completes the NPC data model.
**Branch:** `copy`
**Agents:** Mellanie (lead author), Paula (narrative design), Gestalt (systems/content architecture)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #299 | Opening hook content — smuggler first 5 minutes | all blockers done |
| #300 | Opening hook content — detective first 5 minutes | all blockers done |
| #169 | Layer 1: Access tier filtering (content for dialogue system) | — |
| #170 | Layer 2: Relationship history (content for dialogue system) | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/content.md` — D-016 (internal monologue functions: perception bridge, atmosphere, diegetic tutorial, unreliable narrator), D-024 (10-axis NPC model; personality traits, tell system), D-028 (dialogue 4-layer architecture: access tiers → relationship history → trust gossip → unprompted disclosure), D-032 (separate monologue pools per character — hard partition), D-034 (THE FRIEND pattern: Kael Davan / Sera Venn), D-035 (converged tag taxonomy — 6 structural + 3 selection tags; 9 mood states post Sprint 14 amendment), D-036 (Sova Transit District setting, Krenn System naming conventions), D-075 (dialogue filtering: KnowledgeConfidence gates TrustTier, not AccessTier)
- `decisions/perception.md` — D-060 (cognitive delay 0.6s / 0.3s), D-061 (unified dialogue log, bottom screen, max 20% height, max 640px wide), D-062 (invisible locked options — no lock icons), D-063 (confrontation: same box, different weight — italicized first-person voice), D-064 (walk-away: three-phase consequences, KG records incompleteness), D-078 (overheard NPC conversation — passive panel with occlusion filter; monologue reacts via `witness_interaction` trigger)
## Notes
- **#299 Opening hook — smuggler first 5 minutes:** All blockers are done: Dual Lens Authoring Guide (#261), character voice speech patterns (#310), knowledge state vocabulary (#309), monologue display visual spec (#315). This is the single most important content deliverable in the game — it blocks all playtest feedback and defines first impressions. Deliver 10-15 tightly sequenced monologue lines structured as a diegetic tutorial arc: (1) station hum as opening sensation, (2) spatial orientation — NPCs already moving, fog boundary visible, (3) first sight of Kael (warm, trusted — D-033 green entity), (4) news ticker glance (optional environmental text moment), (5) first fog-edge sound ping noticed (teaches sound channel). Voice: smuggler is working-class pragmatic, warm but watchful, compact consonant-heavy Krenn naming in references (D-036). Prerequisite tags (`knowledge_state` gates) must be `null` for this sequence — these lines fire on first encounter, before any knowledge graph state exists. Use `trigger: enter_location` for spatial lines, `trigger: observe_npc` for Kael sighting. File structure: `monologue-smuggler.yaml` per D-032. This ticket blocks #330 (diegetic tutorial lines), so prioritize it early.
- **#300 Opening hook — detective first 5 minutes:** Same blockers done, same urgency. Different emotional register: detective arrives into established rhythm (mid-morning, D-036 Sova atmosphere). The detective is an institutional outsider with analytical lattice — sharper, more detached voice. Tutorial pathway differs: detective's insert HUD is denser (D-048), first NPC contact mediated by institutional insert overlay (Authority access relationship by default). 10-15 lines structured as: (1) institutional orientation — commission kiosk visible, cool-lit corridors (D-046), (2) analytical lattice flagging ambient data (teaches insert channel), (3) first sight of Sera Venn (THE FRIEND, D-034 — currently Unknown/Neutral teal per D-033), (4) cargo manifest anomaly observation (sets detective motivation). Voice: analytical, institutional, controlled. Krenn naming in observations (D-036). File structure: `monologue-detective.yaml` per D-032.
- **#169 Layer 1: Access tier filtering:** D-028 Layer 1 is the foundational gate on the dialogue system. Every dialogue line in the content files must carry an `access` tag (list): values are `public`, `insider`, `authority`, `peer`, `hostile`. This ticket's content deliverable is: (a) audit all existing line pool files under `content/` for correct `access` tagging — flag any lines missing the tag, (b) author the Layer 1 filter logic design spec if one does not exist (how does `RelationshipState` map to `access` tier? see D-075: AccessTier is gated by `RelationshipState` only, not `KnowledgeConfidence`), (c) ensure the existing Kael and Sera line pools (authored in earlier sprints) have correct `access` tags on every line. The dialogue selection pipeline (`server/src/simulation/dialogue.rs`, #305 done) already implements Layer 1 filtering — this ticket is the content-side completion: all lines properly tagged, authoring guide updated for Line Layer 1 rules, Gestalt to confirm tag schema is consistent with D-035 structural tags. Cross-reference: the detective gets Authority access naturally from institutional role; the smuggler gets Insider/Peer access from social network — no archetype tag needed on the pipeline (D-075 emergent design).
- **#170 Layer 2: Relationship history:** D-028 Layer 2 modifies greeting and topic selection based on the interaction log per NPC pair. Content deliverable: (a) define the situation tags that encode relationship history context — D-035 lists 13 situations for v0.1, with `greeting` added in Sprint 8 amendment as the 14th; specify which situations encode first_meeting, established, tense, post_confrontation, post_walkaway states, (b) author `situation: greeting` variants for Kael and Sera line pools — first meeting line (situation: greeting, access: [public]) vs repeat visit line (situation: greeting, access: [peer]) should differ meaningfully, (c) document how the engine maps `InteractionMemory` (#325, done) interaction log to Layer 2 situation tags — Gestalt to write or verify this mapping in the authoring guide. The dialogue selection pipeline already calls Layer 2 filtering; this ticket completes the content to exercise it. Result: Kael greets the smuggler differently on first vs third encounter. Sera greets the detective differently before and after the detective has spoken to her colleagues.
## Dependency Chain
```
#299 (Smuggler opening hook) ──┐
#330 (Diegetic tutorial lines — deferred sprint 16)
#300 (Detective opening hook) ─┘
#169 (Layer 1: Access tier filtering) → must complete before Layer 3 work (#171, sprint 16)
#170 (Layer 2: Relationship history) → must complete before Layer 3 work (#171, sprint 16)
#299 and #300 are parallel. #169 and #170 are parallel.
```
## PR Workflow
When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(copy): sprint 15 react — opening hooks + dialogue layer 1-2" --description "body" --base main --head copy
```
+100
View File
@@ -0,0 +1,100 @@
# Sprint 15: React — Joint Coordination
**Goal:** The player acts and the world reacts — follow and examine mechanics connect the player to the living NPC simulation; the dialogue system's first two access layers open up information gating; server-side monologue events fire in context; and the personality/tell system completes the NPC data model.
**Agents:** All teams — coordination reference
---
## Pre-Sprint Decisions
No blocking open questions identified for this sprint. All decisions required by Sprint 15 tickets are confirmed.
| Decision | Status | Required by |
|----------|--------|-------------|
| D-024 (NPC 10-axis model, tell system) | Confirmed | #90, #92 |
| D-028 (dialogue 4-layer architecture) | Confirmed | #169, #170 |
| D-035 (tag taxonomy, 9 moods post S14 amendment) | Confirmed | #119, #169, #170 |
| D-075 (confidence gates trust tier, not access tier) | Confirmed | #169 |
| D-016 (monologue triggers) | Confirmed | #119 |
| D-010 (deterministic simulation) | Confirmed | #92, #340 |
| D-015 (camera locked, fixed-north v0.1) | Confirmed | #117 |
| D-019 (top-down, "the angle") | Confirmed | #71 |
| D-049 (8-layer z-stack) | Confirmed | #71, #74 |
---
## Cross-Team Dependencies
```
Server #119 (monologue event generation)
→ content pool selector reads D-035 trigger tags authored by copy team
→ fires on observe_npc, hear_sound, observe_anomaly, witness_interaction, post_conversation
→ output appears in ObserverSnapshot.current_monologue (existing field, GameState v5)
→ client #74 (UI framework) must display it via MonologueDisplay
Server #241 (follow mechanic)
→ emits follow_target_id in ObserverSnapshot
→ client #73 (input capture) must dispatch Interact→Follow verb via nearby_interactions
→ client #72 (entity sprite) can highlight follow target entity
Server #90 (personality & tell system)
→ tell state on ObserverSnapshot entity field
→ client #72 may render tell state (stub field now, visual in sprint 16)
Copy #169 (Layer 1 access tier tagging)
→ requires server dialogue.rs (#305, done) to be running Layer 1 filter
→ copy team audits content files; server team does not need to change code
Copy #299/#300 (opening hooks)
→ requires MonologueDisplay (#122, done) and monologue event triggers (#119, this sprint)
→ content ready before engine = acceptable; engine ready before content = also acceptable
→ they are parallel workstreams that meet at playtest
```
---
## Sprint Completion Proof
Sprint 15 is done when all of the following are observable:
1. **Follow mechanic end-to-end:** Player right-clicks an NPC and selects Follow. The server tracks proximity and LOS. Observation events fire while following. NPC suspicion accumulates if the player is within 2 tiles for 60+ ticks. The follow ends naturally when the NPC enters deep fog or detects the player. No client crash. Server logs emit follow-state transitions.
2. **Monologue fires on observation events:** When the player enters a location, observes an NPC, or hears a sound at medium range, a contextually tagged monologue line appears in the MonologueDisplay. Line selection uses D-035 trigger tags. The cooldown (300 ticks) prevents spam. `observe_anomaly` fires when a routine deviation is detected (#243).
3. **Routine deviation detected:** An NPC breaks their routine (wrong location for day phase, or absent from expected location). The server emits a `RoutineDeviationEvent`. This feeds the monologue trigger above (observe_anomaly).
4. **NPC generation pipeline produces valid NPCs:** `generate_npc()` takes a role, outputs a fully-seeded ECS entity with all 10 axes populated. Constraint validation passes. Personality traits (2-3) and tell state are present.
5. **Layer 1 access filtering active:** The dialogue system filters lines by `RelationshipState``access` tier. A player with Unknown relationship sees only `public`-tagged lines. A player with Known/Friendly relationship also sees `insider`/`peer` lines. The filter is invisible — no locked indicators (D-062).
6. **Layer 2 relationship history active:** Kael (or Sera) greets the player differently on first vs subsequent encounters. The `greeting` situation tag drives this. `InteractionMemory` from Sprint 14 (#325) is the data source.
7. **Tilemap + entity + input + UI all render cleanly:** Game boots, tiles render from snapshot data, entity sprites move with lerp, input sends semantic actions to server, HUD displays monologue and stance. No regressions on Sprint 14 functionality (fog, dialogue box, NPC-to-NPC conversation panel).
8. **Smooth camera movement:** Camera tracks player position with interpolated smoothing. No jarring snaps during movement. Snap-on-teleport still works. Camera stays fixed-north (D-015).
9. **Opening hooks authored:** Both `monologue-smuggler.yaml` and `monologue-detective.yaml` contain the 10-15 opening sequence lines. Lines carry correct D-035 structural tags: character partition, trigger type, situation, prerequisite null.
---
## Test Plan Alignment (D-030)
Sprint 15 is in the integration-and-behavior phase. Testing priorities:
- **Server unit tests (Hoshe):** `PersonalityTraits` derivation (correct tell state from axis values), `RoutineDeviationEvent` emission timing, `ToleranceThreshold` breach at correct stress level, `SpatialIndex` naive impl correctness (entities_in_range, entities_at, update)
- **Integration test (Hoshe):** Follow mechanic end-to-end — simulate player issuing Follow verb, advance N ticks with NPC moving, verify observation event frequency increase, verify suspicion accumulation rate
- **Content validation (CI):** Cross-reference check on new monologue files — character partition, required tags present, trigger enum values valid (extends existing `content-cross-reference` CI check, #464)
- **Client regression (Hoshe):** Verify all Sprint 14 integration proofs still pass after #71/#72/#73/#74 changes — fog renders, dialogue box appears, NPC-to-NPC conversation panel shows
---
## Carry-over Risk
No Sprint 14 carry-overs. Sprint 14 was 100% complete (22/22 done).
The highest carry-over risks in Sprint 15:
- **#299/#300 (opening hooks):** Content authoring on the critical path — Mellanie leads, Paula consults. If these slip, they carry to sprint 16 with no downstream blocking (the system will run without them, using time_idle and enter_location monologue from existing pools).
- **#169/#170 (dialogue layers 1-2):** These are content-side auditing and authoring tasks. The pipeline code (#305) is done. Carry-over has no server code impact.
- **#241 (follow mechanic):** Depends on interaction dispatcher (#240, done) but adds new system logic. Medium complexity. Carry-over defers NPC suspicion mechanics but does not block other sprint tickets.
+66
View File
@@ -0,0 +1,66 @@
# Sprint 15: React — Server Tasks
**Goal:** The player acts and the world reacts — follow and examine mechanics connect the player to the living NPC simulation; the dialogue system's first two access layers open up information gating; server-side monologue events fire in context; and the personality/tell system completes the NPC data model.
**Branch:** `server`
**Agents:** Dudley (simulation dev), Tyre (architect), Hoshe (QA)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #119 | Monologue event generation — server | #239 (done) |
| #92 | NPC generation pipeline | #86 (done) |
| #90 | Personality & tell system | #89 (cancelled — blocker resolved) |
| #241 | Follow mechanic | #240 (done) |
| #243 | Routine deviation detection | #88 (done) |
| #105 | Tolerance threshold triggers | — |
| #340 | Define SpatialIndex trait with naive Vec implementation | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/perception.md` — D-011 (fog of perception universal to all entities), D-016 (internal monologue as perception bridge), D-060 (cognitive delay), D-067 (recognition chime onset)
- `decisions/content.md` — D-024 (10-axis NPC generation model + tell system), D-028 (dialogue 4-layer architecture), D-035 (converged tag taxonomy, 9 mood states), D-075 (dialogue filtering: confidence gates trust tier, not access tier)
- `decisions/architecture.md` — D-010 (information boundaries first-class), D-026 (simulation tiers), D-041 (knowledge graph: KnowsOf / KnowsDetails confidence levels)
## Notes
- **#119 Monologue event generation — server:** `server/src/simulation/monologue.rs` already handles `enter_location` and `time_idle` triggers. This ticket extends the system to fire on `observe_npc`, `hear_sound`, `observe_anomaly`, `witness_interaction`, and `post_conversation` triggers — all enumerated in D-035 tag taxonomy. The `ObservationEvent` struct from `server/src/simulation/interaction.rs` (completed in #239) is the input source. Each trigger must emit a `MonologueEvent` into `MonologueBuffer` with context tags (`location`, `situation`, `character_state`) so the content pool selector can match against D-035 structural tags. Respect the `COOLDOWN_TICKS = 300` anti-spam guard already in the file. The `witness_interaction` trigger (D-078) fires after overheard NPC-to-NPC conversation is displayed — coordinate with the passive dialogue system in `server/src/simulation/conversation.rs` and `server/src/simulation/listening.rs`.
- **#92 NPC generation pipeline:** Core NPC ECS components (Want, Secret, Relationships, Tolerance, Routine, Contentment) are done in `server/src/npc/mod.rs` (#86). This ticket wires procedural generation: takes a role definition as input, seeds all 10 axes using the sim RNG (`server/src/simulation/rng.rs`), applies constraint validation, and spawns a fully-populated NPC entity. Use the `SimRng` resource for all randomness — determinism is non-negotiable (D-010 principle 4). Constraint validation must catch: relationship graph cycles, tolerance values that would immediately trigger, routines that conflict spatially. Output: a function `generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng)` or equivalent ECS command. Personality traits (2-3 per NPC) must be populated — they feed #90.
- **#90 Personality & tell system:** The blocker (#89, Information inventory) was cancelled; the knowledge graph (D-041, completed in Sprint 12) supersedes it. `PersonalityTraits` component holds 2-3 traits drawn from a trait set. `TellSystem` covers 5 categories: `nervous`, `angry`, `friendly`, `guarded`, `routine_deviation`. Tell state is derived each tick from NPC axis values — not authored per NPC. Derivation rules: `Secret + low Tolerance → nervous`, `low Contentment + Hostile mood → angry`, `high Contentment + Friendly relationship → friendly`, `high Secret weight → guarded`, deviation from routine → `routine_deviation`. `NpcMood` is already available from `server/src/npc/mood.rs` (#323). The `mood.rs` header comment explicitly flags `Tell system (#337, deferred to Sprint 15) → reads MoodState`. The v0.1 renderer for tells is **monologue text**, not visual animation — emit tell state into `ObserverSnapshot` as a field on the entity's tell status; the client reads it for future use. `server/src/npc/` is the correct home for new components.
- **#241 Follow mechanic:** Player uses Interact verb on an NPC to designate a follow target. The interaction dispatcher in `server/src/simulation/interaction.rs` (#240, done) must be extended with a `Follow` verb. Server tracks: target NPC entity ID, current distance (tile-based), LOS state (from the existing shadowcasting system in `server/src/perception/`). Observation events (`observe_npc` trigger) fire at double frequency while following. NPC suspicion increases via `ToleranceThreshold` stress if the player maintains close proximity + LOS for sustained ticks. Define "too close too long" as a configurable threshold (start: within 2 tiles for 60+ ticks). Follow ends when: target enters deep fog (LOS lost for N ticks), target detects player (suspicion threshold crossed, feeds `NpcPlayerAwareness`), or player issues a different action. Emit follow-state events into `ObserverSnapshot` so the client can show follow-mode UI state.
- **#243 Routine deviation detection:** `server/src/npc/routine.rs` tracks `ActivityState` (set when NPC arrives at routine destination) and `DailyRoutine` (schedule of phase → location). Compare the NPC's current `TilePosition` and `ActivityState` against what `DailyRoutine` specifies for the current `DayPhase`. Emit `RoutineDeviationEvent` when: NPC is in wrong location for phase, NPC is absent from expected location (has been out of expected zone for N ticks), NPC is performing wrong activity. Absence detection covers the case where the player expects an NPC at a known location and they are not there. `RoutineDeviationEvent` feeds the observation event generator (#239, done), which will route it to `observe_anomaly` monologue triggers (#119, this sprint). This is the primary detective mechanic per D-027 criterion 4.
- **#105 Tolerance threshold triggers:** `ToleranceThreshold` component exists (part of NPC data model). This ticket adds the monitoring system: each tick, check all Active-tier NPCs' tolerance against accumulated stress. When stress exceeds threshold, emit a `ToleranceBreachEvent` and apply behavioral state changes (mood shift, potential confrontation-initiation, avoidance behavior). Threshold value varies per NPC seed — do not hardcode. This unblocks #250 (Triangle escalation system) in a later sprint, which needs tolerance breach events as input. Integrate with `server/src/npc/mood.rs` — a tolerance breach should push mood toward `Hostile` or `Anxious` per the FSM.
- **#340 SpatialIndex trait:** XS effort task. Define a trait in `server/src/` (suggest `server/src/simulation/spatial.rs`) with three methods: `entities_in_range(position, radius)`, `entities_at(position)`, `update(entity_id, position)`. Implement a naive `Vec`-backed backend (`NaiveSpatialIndex`). This is called from the follow mechanic (#241) for proximity queries and from the NPC vision system (#115, deferred). The trait abstraction means a grid/quadtree can replace the naive impl later without touching callers. Register as a Bevy resource.
## Dependency Chain
```
#340 (SpatialIndex) ─────────────────────────────────┐
#92 (NPC generation pipeline) → #90 (Personality & tell) → future: #337 (tell state derivation)
#243 (Routine deviation detection) ──────────────────┐
#119 (Monologue event generation) ← also feeds from #241 (Follow mechanic)
#241 (Follow mechanic) → feeds NPC suspicion → future: #115 (NPC vision)
#105 (Tolerance threshold triggers) → future: #250 (Triangle escalation)
```
## PR Workflow
When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): sprint 15 react — server deliverables" --description "body" --base main --head server
```
+1 -1
View File
@@ -1,5 +1,5 @@
name: The Settled Reach
version: 0.1.13
version: 0.1.14
repository: settled-reach
codename: commonwealth
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.12"
version = "0.1.13"
dependencies = [
"bevy_app",
"bevy_ecs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "settled-reach-server"
version = "0.1.13"
version = "0.1.14"
edition = "2021"
[dependencies]
+11 -7
View File
@@ -348,7 +348,8 @@ fn run_dialogue(index: &LinePoolIndex, args: &Args) {
"situation",
"arrival, shift_start, shift_end, shift_transition, bar_evening, \
night_shift, investigation, confrontation, social, alone, \
emergency, routine, observation",
emergency, routine, observation, greeting, first_meeting, \
repeated_visit",
)
})
.collect()
@@ -596,6 +597,9 @@ fn situation_str(s: &Situation) -> &'static str {
Situation::Emergency => "emergency",
Situation::Routine => "routine",
Situation::Observation => "observation",
Situation::Greeting => "greeting",
Situation::FirstMeeting => "first_meeting",
Situation::RepeatedVisit => "repeated_visit",
}
}
@@ -629,13 +633,13 @@ fn topic_str(t: &Topic) -> &'static str {
fn mood_str(m: &Mood) -> &'static str {
match m {
Mood::Fond => "fond",
Mood::Comfortable => "comfortable",
Mood::Worried => "worried",
Mood::Anxious => "anxious",
Mood::Frustrated => "frustrated",
Mood::Content => "content",
Mood::Suspicious => "suspicious",
Mood::Analytical => "analytical",
Mood::Conflicted => "conflicted",
Mood::Concerned => "concerned",
Mood::Warm => "warm",
Mood::Hostile => "hostile",
Mood::Relieved => "relieved",
Mood::Focused => "focused",
}
}
+6
View File
@@ -300,6 +300,8 @@ mod tests {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
sound_events: vec![],
rng_seed: None,
}
@@ -361,6 +363,8 @@ mod tests {
line_id: "line_test".into(),
text: "Welcome to the docks.".into(),
speaker_entity_id: 100,
speaker_color_index: 0,
speaker_name: "Dock Worker".into(),
});
let text = format_snapshot_text(&snap);
assert!(text.contains("Dialogue: [npc:100] \"Welcome to the docks.\""));
@@ -425,6 +429,8 @@ mod tests {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
sound_events: vec![],
rng_seed: None,
};
+21 -2
View File
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 11;
pub const PROTOCOL_VERSION: u8 = 12;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
@@ -31,10 +31,11 @@ pub const PROTOCOL_VERSION: u8 = 11;
/// v10 adds: sound_events (#124, D-038 server sound event pipeline),
/// rng_seed (#527, deterministic replay — completes WRONG button loop).
/// v11 adds: zone_id on VisibleTile (#523, D-077 OQ-09 resolution + D-073 crossfade).
/// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 11.
/// Protocol version for forward compatibility. Current: 12.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -88,6 +89,15 @@ pub struct ObserverSnapshot {
/// Empty when no sounds are in range.
#[serde(default)]
pub sound_events: Vec<crate::simulation::sound::SoundEvent>,
/// Overheard NPC-to-NPC conversation lines this tick (#247, D-078).
/// Each event carries pre-occluded text — client renders verbatim.
/// Empty when no conversations are overheard.
#[serde(default)]
pub conversation_events: Vec<crate::simulation::conversation::ConversationEvent>,
/// Conversations that ended this tick (#247, D-078).
/// Client dismisses the passive dialogue panel for these pairs.
#[serde(default)]
pub conversation_ended: Vec<crate::simulation::conversation::ConversationEndEvent>,
/// RNG seed active at this tick for deterministic replay (#527).
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
/// None when the RNG resource is unavailable (should not occur in practice).
@@ -348,6 +358,9 @@ pub enum PlayerAction {
ToggleStanceUp,
/// Move one step down the stance ladder (toward Crouch) per D-053
ToggleStanceDown,
/// Update player facing without movement (D-054). Client sends when
/// the player turns in place (e.g. mouse aim, turn keys).
SetFacing { facing: String },
/// Teleport player to the Gauntlet hub spawn point (#491).
/// Clears dialogue, monologue, and interaction buffers.
/// Rejected with a log warning on non-Gauntlet maps.
@@ -490,6 +503,12 @@ pub struct DialogueResponseEvent {
pub text: String,
/// Wire-format entity identifier of the speaking NPC
pub speaker_entity_id: u64,
/// Color index (0-7) for the speaker's dialogue box header.
#[serde(default)]
pub speaker_color_index: u8,
/// Display name of the speaker (real name if known to player, else role label).
#[serde(default)]
pub speaker_name: String,
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
+39 -18
View File
@@ -79,6 +79,9 @@ impl FromStr for TrustTier {
}
/// D-028 Layer 2: Situation context — when this line can fire.
///
/// 14 v0.1 values: 13 original + Greeting added Sprint 8 (D-035 amendment)
/// for PC dialogue pools initial contact lines.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Situation {
Arrival,
@@ -94,6 +97,12 @@ pub enum Situation {
Emergency,
Routine,
Observation,
/// Added Sprint 8 (D-035 amendment): PC dialogue initial contact lines.
Greeting,
/// First player-NPC interaction — interaction_count == 0 (#325, D-028 Layer 2).
FirstMeeting,
/// Player has talked to this NPC 3+ times — interaction_count >= 3 (#325, D-028 Layer 2).
RepeatedVisit,
}
impl FromStr for Situation {
@@ -113,6 +122,9 @@ impl FromStr for Situation {
"emergency" => Ok(Self::Emergency),
"routine" => Ok(Self::Routine),
"observation" => Ok(Self::Observation),
"greeting" => Ok(Self::Greeting),
"first_meeting" => Ok(Self::FirstMeeting),
"repeated_visit" => Ok(Self::RepeatedVisit),
_ => Err(ParseEnumError {
kind: "Situation",
value: s.to_string(),
@@ -157,30 +169,36 @@ impl FromStr for Topic {
}
/// D-028 Layer 4: Mood tag — influences weighted selection.
///
/// 8 v0.1 values aligned to voice guide vocabulary (Sprint 14 rename).
/// D-035 amendment (Sprint 8): `Focused` added as 9th variant.
/// Neutral mood is represented by omitting the mood tag (untagged = baseline).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Mood {
Fond,
Comfortable,
Worried,
Anxious,
Frustrated,
Content,
Suspicious,
Analytical,
Conflicted,
Concerned,
Warm,
Hostile,
Relieved,
/// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal
/// and maintenance corridors. Maps from NpcMood::Focused.
Focused,
}
impl FromStr for Mood {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"fond" => Ok(Self::Fond),
"comfortable" => Ok(Self::Comfortable),
"worried" => Ok(Self::Worried),
"anxious" => Ok(Self::Anxious),
"frustrated" => Ok(Self::Frustrated),
"content" => Ok(Self::Content),
"suspicious" => Ok(Self::Suspicious),
"analytical" => Ok(Self::Analytical),
"conflicted" => Ok(Self::Conflicted),
"concerned" => Ok(Self::Concerned),
"warm" => Ok(Self::Warm),
"hostile" => Ok(Self::Hostile),
"relieved" => Ok(Self::Relieved),
"focused" => Ok(Self::Focused),
_ => Err(ParseEnumError {
kind: "Mood",
value: s.to_string(),
@@ -664,6 +682,9 @@ mod tests {
"emergency",
"routine",
"observation",
"greeting", // Sprint 8 amendment (D-035)
"first_meeting",
"repeated_visit",
];
for v in values {
assert!(
@@ -696,14 +717,14 @@ mod tests {
#[test]
fn mood_parse_all_values() {
let values = [
"fond",
"comfortable",
"worried",
"anxious",
"frustrated",
"content",
"suspicious",
"analytical",
"conflicted",
"concerned",
"warm",
"hostile",
"relieved",
"focused",
];
for v in values {
assert!(v.parse::<Mood>().is_ok(), "Failed to parse mood: {}", v);
+6
View File
@@ -111,6 +111,12 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// Mark NPC as interactable for proximity-based verb detection (#413)
entity_commands.insert(Interactable);
// Interaction history — drives Layer 2 situation activation (#325, D-028)
entity_commands.insert(npc::interaction::InteractionMemory::default());
// Mood state — drives Layer 4 dialogue selection and monologue tone (#323)
entity_commands.insert(npc::mood::MoodState::default());
// Axis 1: Want
if let Some(want) = &profile.want {
if let Some(kind) = parse_want_kind(&want.primary) {
+5
View File
@@ -131,6 +131,11 @@ fn main() {
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
// Content root is at the repo root, one level up from server/
app.insert_resource(settled_reach_server::content::ContentConfig {
content_root: std::path::PathBuf::from("../content"),
hot_reload: false,
});
app.add_plugins(settled_reach_server::content::ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
+179
View File
@@ -0,0 +1,179 @@
//! Interaction tracking component — ticket #325.
//!
//! `InteractionMemory` is a per-NPC component tracking the player's interaction
//! history with that NPC. Drives D-028 Layer 2 situation activation:
//! - `interaction_count == 0` → `Situation::FirstMeeting`
//! - `interaction_count >= 3` → `Situation::RepeatedVisit`
//!
//! Populated by `process_talk_interaction` in `dialogue.rs` each time a talk
//! line is selected. Walk-away and confrontation events appended to
//! `notable_events` for fast per-pair access (complements the KnowledgeGraph).
//!
//! No HashMap. No floats. Deterministic (no random access to notable_events).
use bevy_ecs::prelude::*;
/// Notable event kinds recorded per player-NPC interaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractionEventKind {
/// Player walked away during active dialogue (D-064).
WalkAway,
/// Player delivered a confrontation (D-063).
Confrontation,
}
/// A single notable event in an interaction history.
#[derive(Debug, Clone)]
pub struct InteractionEvent {
/// Simulation tick the event occurred.
pub tick: u64,
/// The kind of event.
pub kind: InteractionEventKind,
}
/// Per-NPC interaction history with the player (#325, D-028 Layer 2).
///
/// Spawned on every NPC entity. Drives situation derivation for Layer 2
/// dialogue selection: `first_meeting` (count == 0), `repeated_visit`
/// (count >= 3). `notable_events` stores walk-aways and confrontations for
/// fast lookup without a full KnowledgeGraph query.
#[derive(Component, Debug, Default)]
pub struct InteractionMemory {
/// Total number of completed Talk interactions with the player.
/// Incremented each time a dialogue line is selected in `process_talk_interaction`.
pub interaction_count: u32,
/// Tick of the most recent completed Talk interaction.
/// Used for trust decay baseline (D-028 trust progression, #324).
pub last_interaction_tick: u64,
/// Notable events: walk-aways and confrontations.
/// Bounded by `MAX_NOTABLE_EVENTS` — oldest entries dropped when full.
pub notable_events: std::collections::VecDeque<InteractionEvent>,
}
/// Maximum number of notable events retained per NPC pair.
pub const MAX_NOTABLE_EVENTS: usize = 16;
impl InteractionMemory {
/// Record a completed Talk interaction.
///
/// Increments `interaction_count` and stamps `last_interaction_tick`.
pub fn record_talk(&mut self, tick: u64) {
self.interaction_count = self.interaction_count.saturating_add(1);
self.last_interaction_tick = tick;
}
/// Append a notable event, dropping the oldest if at capacity.
pub fn push_event(&mut self, event: InteractionEvent) {
if self.notable_events.len() >= MAX_NOTABLE_EVENTS {
self.notable_events.pop_front();
}
self.notable_events.push_back(event);
}
/// Returns `true` if this is the first meeting (count == 0).
pub fn is_first_meeting(&self) -> bool {
self.interaction_count == 0
}
/// Returns `true` if this qualifies as a repeated visit (count >= 3).
pub fn is_repeated_visit(&self) -> bool {
self.interaction_count >= 3
}
/// Count notable events of a given kind.
pub fn count_events(&self, kind: InteractionEventKind) -> usize {
self.notable_events.iter().filter(|e| e.kind == kind).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_first_meeting() {
let mem = InteractionMemory::default();
assert!(mem.is_first_meeting());
assert!(!mem.is_repeated_visit());
}
#[test]
fn record_talk_increments_count() {
let mut mem = InteractionMemory::default();
mem.record_talk(10);
assert_eq!(mem.interaction_count, 1);
assert_eq!(mem.last_interaction_tick, 10);
assert!(!mem.is_first_meeting());
}
#[test]
fn repeated_visit_threshold_at_three() {
let mut mem = InteractionMemory::default();
assert!(!mem.is_repeated_visit());
mem.record_talk(10);
mem.record_talk(20);
assert!(!mem.is_repeated_visit());
mem.record_talk(30);
assert!(mem.is_repeated_visit());
}
#[test]
fn push_event_appends() {
let mut mem = InteractionMemory::default();
mem.push_event(InteractionEvent {
tick: 5,
kind: InteractionEventKind::WalkAway,
});
assert_eq!(mem.notable_events.len(), 1);
assert_eq!(mem.notable_events[0].kind, InteractionEventKind::WalkAway);
}
#[test]
fn push_event_drops_oldest_when_full() {
let mut mem = InteractionMemory::default();
for i in 0..MAX_NOTABLE_EVENTS {
mem.push_event(InteractionEvent {
tick: i as u64,
kind: InteractionEventKind::WalkAway,
});
}
assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS);
// Pushing one more should drop the oldest (tick=0)
mem.push_event(InteractionEvent {
tick: 99,
kind: InteractionEventKind::Confrontation,
});
assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS);
assert_eq!(mem.notable_events[0].tick, 1); // tick=0 dropped
assert_eq!(mem.notable_events.back().unwrap().tick, 99);
}
#[test]
fn count_events_filters_by_kind() {
let mut mem = InteractionMemory::default();
mem.push_event(InteractionEvent {
tick: 1,
kind: InteractionEventKind::WalkAway,
});
mem.push_event(InteractionEvent {
tick: 2,
kind: InteractionEventKind::Confrontation,
});
mem.push_event(InteractionEvent {
tick: 3,
kind: InteractionEventKind::WalkAway,
});
assert_eq!(mem.count_events(InteractionEventKind::WalkAway), 2);
assert_eq!(mem.count_events(InteractionEventKind::Confrontation), 1);
}
#[test]
fn record_talk_saturates_on_overflow() {
let mut mem = InteractionMemory {
interaction_count: u32::MAX,
..Default::default()
};
mem.record_talk(1);
assert_eq!(mem.interaction_count, u32::MAX); // saturating_add
}
}
+21 -2
View File
@@ -2,6 +2,8 @@
// Implements D-024: 10-axis NPC model + CombatCapability component
// Background tier state machines for schedule, mood, relationships, job
pub mod interaction;
pub mod mood;
pub mod relationships;
pub mod routine;
@@ -21,11 +23,28 @@ pub struct NpcPlugin;
impl Plugin for NpcPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<relationships::RelationshipGraph>()
.init_resource::<relationships::TrustEventQueue>()
.init_resource::<routine::PreviousDayPhase>()
.add_systems(
Update,
routine::check_phase_transition
.before(crate::simulation::pathfinding::compute_paths),
(
routine::check_phase_transition
.before(crate::simulation::pathfinding::compute_paths),
mood::update_mood
.after(routine::check_phase_transition)
.before(crate::simulation::dialogue::process_talk_interaction),
relationships::update_trust
.after(crate::simulation::dialogue::process_talk_interaction)
.after(crate::simulation::dialogue::process_walk_away)
.after(crate::simulation::dialogue::process_confrontation_response)
.before(crate::simulation::time::advance_tick),
relationships::update_relationship_dynamics
.after(relationships::update_trust)
.before(crate::simulation::time::advance_tick),
routine::enter_activity
.after(crate::simulation::movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
),
);
tracing::debug!("NpcPlugin initialized");
+739
View File
@@ -0,0 +1,739 @@
//! NPC mood state machine (#323).
//!
//! Implements the 8-state NPC mood FSM (D-024 MoodState axis, D-035 taxonomy).
//! Mood is derived each tick from simulation inputs (stress, time of day,
//! recent interactions) and drives Layer 4 dialogue selection and monologue tone.
//!
//! All state transitions are deterministic — integer arithmetic only (D-010).
//! No floats. No HashMap.
//!
//! ## Integration points
//! - `ToleranceThreshold.current_stress` → primary mood driver
//! - `SimulationTime.day_phase()` → Evening phase adds Frustrated pressure
//! - `InteractionMemory` (Sprint 14, #325) → will set warm_active flag
//! - `CurrentMood` (dialogue.rs) → synced each tick for Layer 4 selection
//! - Monologue trigger system → reads NpcMood for tone selection (D-016, future)
//! - Tell system (#337, deferred to Sprint 15) → reads MoodState
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::content::line_pool::Mood as ContentMood;
use crate::npc::interaction::InteractionMemory;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::dialogue::CurrentMood;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::{DayPhase, SimulationTime};
// ---------------------------------------------------------------------------
// NpcMood enum
// ---------------------------------------------------------------------------
/// NPC simulation mood — 8-state FSM (D-024, D-035 converged taxonomy).
///
/// Driven by `ToleranceThreshold` stress, time of day, and interaction events.
/// Maps to `content::line_pool::Mood` for Layer 4 dialogue tag matching.
///
/// Copy team references this enum when scripting mood conditions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum NpcMood {
/// Default state: no notable stressors, no recent positive events.
#[default]
Neutral,
/// Elevated stress approaching threshold — heightened wariness.
Anxious,
/// Late-shift fatigue or repeated minor irritations.
Frustrated,
/// Low stress, positive recent context — settled and cooperative.
Content,
/// Observing unusual or off-script behavior — targeted wariness.
/// Not reachable from `derive_mood()` — set externally by observation pipeline.
Suspicious,
/// Recent positive player interaction within memory window.
Warm,
/// Stress at or above threshold — confrontational or withdrawn.
Hostile,
/// Actively engaged in a scheduled activity — task-focused.
/// Not reachable from `derive_mood()` — set externally by activity scheduler (#101).
Focused,
}
// ---------------------------------------------------------------------------
// MoodState component
// ---------------------------------------------------------------------------
/// Per-NPC mood component — wraps NpcMood for ECS queries.
///
/// Updated each tick by `update_mood` for Active-tier NPCs.
/// Read by: dialogue Layer 4 (via CurrentMood sync), tell system (#337),
/// monologue tone selection (D-016, future scope).
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
pub struct MoodState {
pub mood: NpcMood,
/// Tick when mood last changed — guards against thrashing in tests.
pub changed_tick: u64,
}
// ---------------------------------------------------------------------------
// Mood mapping: NpcMood → content::line_pool::Mood
// ---------------------------------------------------------------------------
/// Map NPC simulation mood to the content dialogue tag.
///
/// Bridges the simulation FSM (NpcMood) with the dialogue line pool system
/// (content::line_pool::Mood). The mapping is intentionally lossy in some
/// directions — multiple simulation moods map to the same content tag when
/// the distinction matters for behavior but not for line selection.
pub fn mood_to_content_mood(mood: NpcMood) -> ContentMood {
match mood {
NpcMood::Neutral => ContentMood::Content,
NpcMood::Anxious => ContentMood::Anxious,
NpcMood::Frustrated => ContentMood::Frustrated,
NpcMood::Content => ContentMood::Relieved,
NpcMood::Suspicious => ContentMood::Suspicious,
NpcMood::Warm => ContentMood::Warm,
NpcMood::Hostile => ContentMood::Hostile,
NpcMood::Focused => ContentMood::Focused,
}
}
// ---------------------------------------------------------------------------
// Mood derivation (pure, testable)
// ---------------------------------------------------------------------------
/// Stress fraction threshold for Anxious: 60% of tolerance threshold.
///
/// Uses integer multiplication to avoid division:
/// Anxious when `current_stress * 100 >= threshold * ANXIOUS_STRESS_NUMERATOR`
/// Equivalent to: `current_stress >= threshold * 0.60`
const ANXIOUS_STRESS_NUMERATOR: i16 = 60;
/// Stress level below which an NPC is considered Content (no notable pressure).
const CONTENT_STRESS_CEILING: i16 = 20;
/// Minimum stress for Evening → Frustrated (avoids Frustrated at zero stress).
const FRUSTRATED_STRESS_FLOOR: i16 = 10;
/// Ticks within which a completed Talk interaction keeps the Warm mood active.
/// 300 ticks = 30 game-minutes (D-031: 10 ticks/minute).
pub const WARM_INTERACTION_WINDOW_TICKS: u64 = 300;
/// Derive NPC mood from simulation inputs.
///
/// Priority ordering (high to low):
/// 1. Hostile — stress at or above threshold
/// 2. Anxious — stress at 60% of threshold or above
/// 3. Warm — recent positive player interaction
/// 4. Frustrated — Evening phase with non-trivial stress
/// 5. Content — very low stress (< CONTENT_STRESS_CEILING)
/// 6. Neutral — everything else
///
/// Inputs are all integer or enum — no floats (D-010 determinism).
///
/// `warm_active`: set by InteractionMemory (#325, Sprint 14) when a positive
/// interaction occurred within the memory window. Placeholder `false` until
/// #325 is wired.
pub fn derive_mood(
current_stress: i16,
threshold: i16,
phase: DayPhase,
warm_active: bool,
) -> NpcMood {
// 1. Hostile: at or above threshold
if current_stress >= threshold {
return NpcMood::Hostile;
}
// 2. Anxious: above 60% of threshold.
// Guard: skip if threshold == 0 (divide-by-zero equivalent — entity
// has no tolerance and is already Hostile from rule 1).
if threshold > 0
&& (current_stress as i32) * 100 >= (threshold as i32) * (ANXIOUS_STRESS_NUMERATOR as i32)
{
return NpcMood::Anxious;
}
// 3. Warm: recent positive interaction (priority over Frustrated/Content)
if warm_active {
return NpcMood::Warm;
}
// 4. Frustrated: Evening phase with non-trivial stress
if phase == DayPhase::Evening && current_stress >= FRUSTRATED_STRESS_FLOOR {
return NpcMood::Frustrated;
}
// 5. Content: very low stress
if current_stress < CONTENT_STRESS_CEILING {
return NpcMood::Content;
}
// 6. Neutral: moderate stress, no special conditions
NpcMood::Neutral
}
// ---------------------------------------------------------------------------
// System: update_mood
// ---------------------------------------------------------------------------
/// System: update NpcMood and sync CurrentMood for Active-tier NPCs.
///
/// Reads `ToleranceThreshold` stress and `SimulationTime` day phase to derive
/// the new mood. Updates `MoodState` when mood changes (records changed_tick).
/// Syncs `CurrentMood` (used by dialogue Layer 4) every tick regardless of
/// whether MoodState changed.
///
/// Scoped to `ActiveSim` — Background-tier NPCs retain their last mood state
/// (D-026). This is intentional: background NPCs simulate passage of time via
/// last-known state, not per-tick derivation.
///
pub fn update_mood(
time: Res<SimulationTime>,
mut query: Query<
(
&mut MoodState,
Option<&mut CurrentMood>,
Option<&ToleranceThreshold>,
Option<&InteractionMemory>,
),
(With<Npc>, With<ActiveSim>),
>,
) {
let phase = time.day_phase();
let tick = time.tick;
for (mut mood_state, current_mood_opt, tolerance_opt, interaction_mem_opt) in query.iter_mut() {
let (stress, threshold) = tolerance_opt
.map(|t| (t.current_stress, t.threshold))
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
// Warm: recent positive player interaction within memory window (#325)
let warm_active = interaction_mem_opt
.map(|mem| {
mem.interaction_count > 0
&& tick.saturating_sub(mem.last_interaction_tick)
< WARM_INTERACTION_WINDOW_TICKS
})
.unwrap_or(false);
let new_mood = derive_mood(stress, threshold, phase, warm_active);
if mood_state.mood != new_mood {
mood_state.mood = new_mood;
mood_state.changed_tick = tick;
}
// Sync CurrentMood for dialogue pipeline — always, not just on change.
// CurrentMood drives Layer 4 scoring; it must reflect current simulation
// state even if MoodState itself didn't change this tick.
if let Some(mut current_mood) = current_mood_opt {
current_mood.0 = mood_to_content_mood(new_mood);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::dialogue::CurrentMood;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::{DayPhase, SimulationTime};
use bevy_ecs::world::World;
// --- derive_mood unit tests ---
#[test]
fn mood_hostile_when_stress_equals_threshold() {
assert_eq!(
derive_mood(50, 50, DayPhase::Morning, false),
NpcMood::Hostile
);
}
#[test]
fn mood_hostile_when_stress_above_threshold() {
assert_eq!(
derive_mood(80, 50, DayPhase::Morning, false),
NpcMood::Hostile
);
}
#[test]
fn mood_anxious_at_60_percent_threshold() {
// 60% of threshold=100 is 60. stress=60 → Anxious.
assert_eq!(
derive_mood(60, 100, DayPhase::Morning, false),
NpcMood::Anxious
);
}
#[test]
fn mood_anxious_boundary_above() {
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000).
assert_eq!(
derive_mood(30, 50, DayPhase::Morning, false),
NpcMood::Anxious
);
}
#[test]
fn mood_not_anxious_just_below_boundary() {
// threshold=50: 60% = 30. stress=29 → not Anxious (29*100=2900 < 3000).
// stress=29 < 20 is false, so → Neutral.
assert_eq!(
derive_mood(29, 50, DayPhase::Morning, false),
NpcMood::Neutral
);
}
#[test]
fn mood_warm_when_positive_interaction() {
assert_eq!(
derive_mood(0, 50, DayPhase::Morning, true),
NpcMood::Warm
);
}
#[test]
fn mood_frustrated_when_evening_with_stress() {
// stress=25 (not hostile/anxious), Evening phase → Frustrated
assert_eq!(
derive_mood(25, 50, DayPhase::Evening, false),
NpcMood::Frustrated
);
}
#[test]
fn mood_not_frustrated_in_morning() {
assert_eq!(
derive_mood(25, 50, DayPhase::Morning, false),
NpcMood::Neutral
);
}
#[test]
fn mood_not_frustrated_when_stress_below_floor() {
// stress=5 < FRUSTRATED_STRESS_FLOOR=10 → Content (stress < 20)
assert_eq!(
derive_mood(5, 50, DayPhase::Evening, false),
NpcMood::Content
);
}
#[test]
fn mood_content_when_low_stress() {
assert_eq!(
derive_mood(15, 50, DayPhase::Morning, false),
NpcMood::Content
);
}
#[test]
fn mood_content_boundary_at_19() {
// stress=19 < CONTENT_STRESS_CEILING=20 → Content
assert_eq!(
derive_mood(19, 50, DayPhase::Morning, false),
NpcMood::Content
);
}
#[test]
fn mood_neutral_otherwise() {
// stress=25, not anxious (25*100=2500 < 50*60=3000), not Warm, morning, not Content
// Wait: 25*100=2500, 50*60=3000 → not Anxious. 25 >= 20 → not Content. Morning → not Frustrated. → Neutral
assert_eq!(
derive_mood(25, 50, DayPhase::Morning, false),
NpcMood::Neutral
);
}
#[test]
fn mood_priority_hostile_over_anxious_at_threshold() {
// At exactly threshold → Hostile, not Anxious
assert_eq!(
derive_mood(50, 50, DayPhase::Morning, false),
NpcMood::Hostile
);
}
#[test]
fn mood_priority_hostile_over_frustrated_evening() {
assert_eq!(
derive_mood(50, 50, DayPhase::Evening, false),
NpcMood::Hostile
);
}
#[test]
fn mood_priority_anxious_over_warm() {
// Anxious takes priority over Warm interaction
assert_eq!(
derive_mood(60, 100, DayPhase::Morning, true),
NpcMood::Anxious
);
}
#[test]
fn mood_priority_warm_over_frustrated() {
// Warm takes priority over Frustrated (checked before Evening test)
assert_eq!(
derive_mood(25, 50, DayPhase::Evening, true),
NpcMood::Warm
);
}
#[test]
fn mood_zero_threshold_is_hostile() {
// stress=0, threshold=0: 0 >= 0 → Hostile
assert_eq!(
derive_mood(0, 0, DayPhase::Morning, false),
NpcMood::Hostile
);
}
#[test]
fn mood_content_zero_stress_moderate_threshold() {
// stress=0, threshold=50: not hostile, not anxious (threshold > 0, 0*100=0 < 50*60=3000),
// not warm, not evening, stress < 20 → Content
assert_eq!(
derive_mood(0, 50, DayPhase::Morning, false),
NpcMood::Content
);
}
// --- mood_to_content_mood mapping coverage ---
#[test]
fn mood_mapping_covers_all_variants() {
for mood in [
NpcMood::Neutral,
NpcMood::Anxious,
NpcMood::Frustrated,
NpcMood::Content,
NpcMood::Suspicious,
NpcMood::Warm,
NpcMood::Hostile,
NpcMood::Focused,
] {
let _ = mood_to_content_mood(mood); // must not panic
}
}
#[test]
fn mood_mapping_anxious_is_anxious() {
assert_eq!(mood_to_content_mood(NpcMood::Anxious), ContentMood::Anxious);
}
#[test]
fn mood_mapping_warm_is_warm() {
assert_eq!(mood_to_content_mood(NpcMood::Warm), ContentMood::Warm);
}
#[test]
fn mood_mapping_suspicious_is_suspicious() {
assert_eq!(
mood_to_content_mood(NpcMood::Suspicious),
ContentMood::Suspicious
);
}
#[test]
fn mood_mapping_focused_is_focused() {
assert_eq!(mood_to_content_mood(NpcMood::Focused), ContentMood::Focused);
}
// --- update_mood system integration tests ---
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world
}
#[test]
fn update_mood_sets_hostile_when_stress_at_threshold() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
MoodState::default(),
CurrentMood::default(),
ToleranceThreshold {
current_stress: 50,
threshold: 50,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world);
let mood_state = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood_state.mood, NpcMood::Hostile);
let current_mood = world.get::<CurrentMood>(npc).unwrap();
assert_eq!(current_mood.0, ContentMood::Hostile);
}
#[test]
fn update_mood_defaults_to_content_without_tolerance() {
let mut world = setup_world();
// No ToleranceThreshold → defaults (stress=0, threshold=50) → Content
let npc = world
.spawn((
Npc,
ActiveSim,
MoodState::default(),
CurrentMood::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world);
let mood_state = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood_state.mood, NpcMood::Content);
}
#[test]
fn update_mood_records_changed_tick_on_transition() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 42;
let npc = world
.spawn((
Npc,
ActiveSim,
// Start Warm, will transition to Hostile
MoodState {
mood: NpcMood::Warm,
changed_tick: 0,
},
CurrentMood::default(),
ToleranceThreshold {
current_stress: 50,
threshold: 50,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world);
let mood_state = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood_state.mood, NpcMood::Hostile);
assert_eq!(mood_state.changed_tick, 42);
}
#[test]
fn update_mood_does_not_update_changed_tick_when_unchanged() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 42;
let npc = world
.spawn((
Npc,
ActiveSim,
// Already Content; no tolerance → will derive Content again
MoodState {
mood: NpcMood::Content,
changed_tick: 5,
},
CurrentMood::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world);
let mood_state = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood_state.mood, NpcMood::Content);
assert_eq!(mood_state.changed_tick, 5); // unchanged
}
#[test]
fn update_mood_skips_background_npcs() {
let mut world = setup_world();
// BackgroundSim NPC — must not be updated
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState {
mood: NpcMood::Warm,
changed_tick: 0,
},
CurrentMood::default(),
ToleranceThreshold {
current_stress: 50,
threshold: 50, // Would → Hostile if processed
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world);
let mood_state = world.get::<MoodState>(npc).unwrap();
// Must remain Warm — not processed because BackgroundSim, not ActiveSim
assert_eq!(mood_state.mood, NpcMood::Warm);
}
#[test]
fn update_mood_syncs_current_mood_when_present() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
MoodState::default(),
CurrentMood::default(), // Starts at Content
ToleranceThreshold {
current_stress: 70,
threshold: 100, // → Anxious (70% of 100)
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world);
let current_mood = world.get::<CurrentMood>(npc).unwrap();
// Anxious maps to Anxious
assert_eq!(current_mood.0, ContentMood::Anxious);
}
#[test]
fn update_mood_works_without_current_mood() {
let mut world = setup_world();
// NPC without CurrentMood — system must not panic
let npc = world
.spawn((
Npc,
ActiveSim,
MoodState::default(),
// No CurrentMood
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world); // must not panic
let mood_state = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood_state.mood, NpcMood::Content);
}
// -- Additional QA coverage (Hoshe, Sprint 14) --------------------------
#[test]
fn derive_mood_negative_stress_is_content() {
// i16 stress can be negative (e.g. buffs reducing stress below zero).
// Negative stress is well below CONTENT_STRESS_CEILING (20) → Content.
// Note: `current_stress * 100` in the Anxious check can overflow i16 for extreme
// values (stress < -327 or > 327 at threshold=50). Realistic game values stay small.
assert_eq!(
derive_mood(-10, 50, DayPhase::Morning, false),
NpcMood::Content,
"Negative stress not hostile/anxious, morning, stress<20 → Content"
);
assert_eq!(
derive_mood(-50, 50, DayPhase::Evening, false),
NpcMood::Content,
"Negative stress in Evening: stress < FRUSTRATED_STRESS_FLOOR (10) → Content not Frustrated"
);
}
#[test]
fn derive_mood_cannot_return_suspicious_or_focused() {
// Suspicious and Focused are valid NpcMood states but are NOT reachable
// from derive_mood(). They must be set externally by other systems
// (e.g., observation pipeline for Suspicious, activity scheduler for Focused).
// This test documents the invariant: derive_mood never emits these states.
use std::collections::HashSet;
let phases = [DayPhase::Morning, DayPhase::Afternoon, DayPhase::Evening, DayPhase::Night];
let stresses: &[i16] = &[-50, -1, 0, 1, 19, 20, 29, 30, 49, 50, 51, 100];
let thresholds: &[i16] = &[0, 1, 50, 100];
let warm_flags = [false, true];
let mut observed = HashSet::new();
for &phase in &phases {
for &stress in stresses {
for &threshold in thresholds {
for warm in warm_flags {
let m = derive_mood(stress, threshold, phase, warm);
observed.insert(format!("{:?}", m));
}
}
}
}
assert!(
!observed.contains("Suspicious"),
"derive_mood should never return Suspicious — set by observation pipeline"
);
assert!(
!observed.contains("Focused"),
"derive_mood should never return Focused — set by activity scheduler (#101)"
);
}
#[test]
fn update_mood_multiple_npcs_independent() {
let mut world = setup_world();
let calm = world
.spawn((
Npc,
ActiveSim,
MoodState::default(),
CurrentMood::default(),
ToleranceThreshold {
current_stress: 5,
threshold: 50,
},
))
.id();
let stressed = world
.spawn((
Npc,
ActiveSim,
MoodState::default(),
CurrentMood::default(),
ToleranceThreshold {
current_stress: 50,
threshold: 50,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_mood);
schedule.run(&mut world);
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
assert_eq!(
world.get::<MoodState>(stressed).unwrap().mood,
NpcMood::Hostile
);
}
}
+542 -4
View File
@@ -1,18 +1,90 @@
//! Global relationship graph resource (D-024).
//! Global relationship graph resource (D-024) and trust progression (#324).
//!
//! Tracks how entities feel about each other. Separate from KnowledgeGraph
//! (what entities know) — this is what entities feel.
//! BTreeMap with tuple key (subject, target) for deterministic iteration
//! and efficient prefix queries via range().
//!
//! Trust progression: interaction events (talk, walk-away, confrontation)
//! adjust the per-edge `trust: i8` value via the `update_trust` system.
//! Trust maps to D-028 TrustTier via `relationship_to_trust()` in dialogue.rs.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::knowledge::types::StableId;
use crate::knowledge::EntityRegistry;
use crate::simulation::time::SimulationTime;
use super::{RelationshipEvent, RelationshipKind};
// ---------------------------------------------------------------------------
// Trust event types (#324)
// ---------------------------------------------------------------------------
/// Trust delta for a completed Talk interaction: NPC warms to the player.
pub const TALK_TRUST_DELTA: i8 = 1;
/// Trust delta when the player walks away mid-dialogue: NPC feels slighted.
pub const WALK_AWAY_TRUST_DELTA: i8 = -1;
/// Trust delta when the player delivers a confrontation: NPC feels threatened.
pub const CONFRONTATION_TRUST_DELTA: i8 = -2;
/// Events that modify trust on the RelationshipGraph.
///
/// Produced by dialogue systems, consumed by `update_trust` each tick.
/// Direction: always (NPC → player), tracking how the NPC feels about
/// the player after an interaction.
#[derive(Debug, Clone)]
pub enum TrustEvent {
/// Player completed a Talk exchange with an NPC.
TalkCompleted {
npc: Entity,
player: Entity,
},
/// Player walked away during active dialogue (D-064).
WalkAway {
npc: Entity,
player: Entity,
},
/// Player delivered a confrontation (D-063).
ConfrontationDelivered {
npc: Entity,
player: Entity,
},
}
/// Resource: queue of pending trust events.
/// Drained once per tick by the `update_trust` system.
#[derive(Resource, Default)]
pub struct TrustEventQueue {
events: Vec<TrustEvent>,
}
impl TrustEventQueue {
/// Push a trust event into the queue.
pub fn push(&mut self, event: TrustEvent) {
self.events.push(event);
}
/// Drain all pending events.
pub fn drain(&mut self) -> Vec<TrustEvent> {
std::mem::take(&mut self.events)
}
/// Number of pending events.
pub fn len(&self) -> usize {
self.events.len()
}
/// Whether the queue is empty.
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
/// Edge in the relationship graph. Directed: A's feelings about B.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipEdge {
@@ -69,8 +141,8 @@ impl RelationshipGraph {
}
/// Get all entities who have feelings about a target.
/// Full scan — use for event detection, not per-tick queries.
pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
self.edges
.iter()
.filter(|((_, t), _)| t == target)
@@ -98,6 +170,137 @@ impl RelationshipGraph {
pub fn is_empty(&self) -> bool {
self.edges.is_empty()
}
/// Iterate over all edges mutably (for decay system).
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut RelationshipEdge> {
self.edges.values_mut()
}
/// Get or create an edge between subject and target.
///
/// If no edge exists, inserts a default Colleague edge with trust 0.
/// Returns a mutable reference for direct field modification.
pub fn ensure_edge(
&mut self,
subject: StableId,
target: StableId,
tick: u64,
) -> &mut RelationshipEdge {
self.edges
.entry((subject, target))
.or_insert_with(|| RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 0,
history: vec![],
last_interaction_tick: tick,
})
}
}
// ---------------------------------------------------------------------------
// System: update_trust (#324)
// ---------------------------------------------------------------------------
/// Drain pending trust events and apply deltas to the RelationshipGraph.
///
/// Each event adjusts the NPC→player trust edge. If no edge exists,
/// one is created with default Colleague kind and trust 0 before applying
/// the delta. Trust is clamped to [-10, +10] per D-010.
///
/// System ordering: after dialogue systems (which emit the events),
/// before advance_tick.
pub fn update_trust(
mut queue: ResMut<TrustEventQueue>,
mut graph: ResMut<RelationshipGraph>,
registry: Res<EntityRegistry>,
time: Res<SimulationTime>,
) {
for event in queue.drain() {
let (npc, player, delta) = match event {
TrustEvent::TalkCompleted { npc, player } => (npc, player, TALK_TRUST_DELTA),
TrustEvent::WalkAway { npc, player } => (npc, player, WALK_AWAY_TRUST_DELTA),
TrustEvent::ConfrontationDelivered { npc, player } => {
(npc, player, CONFRONTATION_TRUST_DELTA)
}
};
let Some(npc_sid) = registry.to_stable(npc) else {
tracing::warn!("Trust event for unregistered NPC {:?}", npc);
continue;
};
let Some(player_sid) = registry.to_stable(player) else {
tracing::warn!("Trust event for unregistered player {:?}", player);
continue;
};
let edge = graph.ensure_edge(npc_sid, player_sid, time.tick);
edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10);
edge.last_interaction_tick = time.tick;
tracing::debug!(
npc = npc_sid.0,
player = player_sid.0,
delta,
new_trust = edge.trust,
"Trust updated"
);
}
}
// ---------------------------------------------------------------------------
// System: update_relationship_dynamics (#103)
// ---------------------------------------------------------------------------
/// Ticks between decay evaluations — 1 game-minute (D-031: 10 ticks/minute).
const DECAY_INTERVAL_TICKS: u64 = 10;
/// Ticks without interaction before trust decay begins — 1 game-hour
/// (10 ticks/minute × 60 minutes = 600 ticks).
const DECAY_INACTIVITY_THRESHOLD_TICKS: u64 = 600;
/// Passive trust decay applied per decay interval.
/// Trust drifts toward 0 at 1 point per hour of inactivity.
const DECAY_DELTA: i8 = 1;
/// Apply passive trust decay to NPC-NPC relationships (#103, D-024).
///
/// Runs once per game-minute (every 10 ticks). For each relationship edge
/// inactive for more than one game-hour, decays trust 1 point toward 0.
/// Positive trust decreases; negative trust increases; zero trust is stable.
///
/// This creates the social texture over time: NPCs who haven't interacted
/// recently drift back to neutral, making active relationship maintenance
/// meaningful. Blocks #249 (player-action social propagation, Sprint 15).
///
/// System ordering: after update_trust, before advance_tick.
pub fn update_relationship_dynamics(time: Res<SimulationTime>, mut graph: ResMut<RelationshipGraph>) {
// Lightweight: evaluate once per game-minute
if time.tick % DECAY_INTERVAL_TICKS != 0 {
return;
}
for edge in graph.values_mut() {
let ticks_since = time.tick.saturating_sub(edge.last_interaction_tick);
if ticks_since < DECAY_INACTIVITY_THRESHOLD_TICKS {
continue; // Recent interaction — no decay
}
let old_trust = edge.trust;
edge.trust = match edge.trust.cmp(&0) {
std::cmp::Ordering::Greater => (edge.trust - DECAY_DELTA).max(0),
std::cmp::Ordering::Less => (edge.trust + DECAY_DELTA).min(0),
std::cmp::Ordering::Equal => 0,
};
if edge.trust != old_trust {
tracing::trace!(
old_trust,
new_trust = edge.trust,
ticks_inactive = ticks_since,
"NPC relationship trust decayed toward neutral"
);
}
}
}
#[cfg(test)]
@@ -171,7 +374,7 @@ mod tests {
make_edge(RelationshipKind::Family, 8),
);
let knowers = graph.who_knows(&target);
let knowers = graph.who_knows_full_scan(&target);
assert_eq!(knowers.len(), 3);
}
@@ -230,4 +433,339 @@ mod tests {
assert_eq!(*keys[1], (StableId(2), StableId(3)));
assert_eq!(*keys[2], (StableId(3), StableId(1)));
}
// -- ensure_edge tests (#324) -------------------------------------------
#[test]
fn ensure_edge_creates_default_when_missing() {
let mut graph = RelationshipGraph::new();
let a = StableId(1);
let b = StableId(2);
let edge = graph.ensure_edge(a, b, 100);
assert_eq!(edge.kind, RelationshipKind::Colleague);
assert_eq!(edge.trust, 0);
assert_eq!(edge.last_interaction_tick, 100);
assert_eq!(graph.edge_count(), 1);
}
#[test]
fn ensure_edge_returns_existing_edge() {
let mut graph = RelationshipGraph::new();
let a = StableId(1);
let b = StableId(2);
graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 7));
let edge = graph.ensure_edge(a, b, 200);
// Should return existing edge, not overwrite
assert_eq!(edge.kind, RelationshipKind::Friend);
assert_eq!(edge.trust, 7);
assert_eq!(graph.edge_count(), 1);
}
// -- TrustEventQueue tests (#324) ----------------------------------------
#[test]
fn trust_queue_push_and_drain() {
let mut world = bevy_ecs::world::World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let mut queue = TrustEventQueue::default();
assert!(queue.is_empty());
queue.push(TrustEvent::TalkCompleted {
npc: e1,
player: e2,
});
assert_eq!(queue.len(), 1);
let events = queue.drain();
assert_eq!(events.len(), 1);
assert!(queue.is_empty());
}
// -- update_trust system tests (#324) ------------------------------------
fn setup_trust_world() -> bevy_ecs::world::World {
let mut world = bevy_ecs::world::World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<EntityRegistry>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<TrustEventQueue>();
world
}
#[test]
fn talk_completed_increments_trust() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.trust, TALK_TRUST_DELTA);
}
#[test]
fn walk_away_decrements_trust() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::WalkAway { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.trust, WALK_AWAY_TRUST_DELTA);
}
#[test]
fn confrontation_decrements_trust_more() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::ConfrontationDelivered { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.trust, CONFRONTATION_TRUST_DELTA);
}
#[test]
fn multiple_talks_accumulate_trust() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 5 talk events
for _ in 0..5 {
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
}
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.trust, 5); // 5 * TALK_TRUST_DELTA(1)
}
#[test]
fn trust_clamps_at_positive_ten() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 15 talk events — should clamp at 10
for _ in 0..15 {
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
}
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.trust, 10);
}
#[test]
fn trust_clamps_at_negative_ten() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 8 confrontation events — 8 * -2 = -16, should clamp at -10
for _ in 0..8 {
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::ConfrontationDelivered { npc, player });
}
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.trust, -10);
}
#[test]
fn mixed_events_net_correctly() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// 3 talks (+3) then 1 walk-away (-1) then 1 confrontation (-2) = net 0
let mut queue = world.resource_mut::<TrustEventQueue>();
queue.push(TrustEvent::TalkCompleted { npc, player });
queue.push(TrustEvent::TalkCompleted { npc, player });
queue.push(TrustEvent::TalkCompleted { npc, player });
queue.push(TrustEvent::WalkAway { npc, player });
queue.push(TrustEvent::ConfrontationDelivered { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.trust, 0);
}
#[test]
fn update_trust_updates_last_interaction_tick() {
let mut world = setup_trust_world();
world.resource_mut::<SimulationTime>().tick = 42;
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let registry = world.resource::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.last_interaction_tick, 42);
}
#[test]
fn update_trust_preserves_existing_edge_kind() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Pre-populate with a Friend edge at trust 5
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_sid,
player_sid,
make_edge(RelationshipKind::Friend, 5),
);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
assert_eq!(edge.kind, RelationshipKind::Friend); // Kind preserved
assert_eq!(edge.trust, 6); // 5 + 1
}
#[test]
fn unregistered_entity_event_is_skipped() {
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
// Only register npc, not player
world.resource_mut::<EntityRegistry>().register(npc);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world); // Should not panic
let graph = world.resource::<RelationshipGraph>();
assert!(graph.is_empty(), "no edge should be created for unregistered entity");
}
}
+417 -3
View File
@@ -1,16 +1,43 @@
//! Daily routine system (#88).
//! Daily routine system (#88, #101).
//!
//! Detects day-phase transitions (D-031) and issues PathRequests for NPCs
//! whose DailyRoutine has a location for the new phase.
//! whose DailyRoutine has a location for the new phase. Tracks NPC activity
//! state when they arrive at their routine destination (#101).
//!
//! Pipeline: phase transition → PathRequest → pathfinder → path_follow →
//! NPC arrives → enter_activity sets ActivityState.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::npc::{DailyRoutine, Npc};
use crate::simulation::movement::TilePosition;
use crate::simulation::pathfinding::PathRequest;
use crate::simulation::pathfinding::{ComputedPath, PathRequest};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::{DayPhase, SimulationTime};
// ---------------------------------------------------------------------------
// ActivityState component (#101)
// ---------------------------------------------------------------------------
/// Tracks the activity an NPC is currently performing at their routine location.
///
/// Set by `enter_activity` when an NPC:
/// 1. Has no active `ComputedPath` or `PathRequest` (finished walking)
/// 2. Is at the location specified by their `DailyRoutine` for the current phase
///
/// Cleared on phase transitions (replaced with new activity or removed).
/// Feeds `TellTrigger::DuringActivity` and D-028 Layer 2 situation matching.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct ActivityState {
/// Activity name from `RoutineEntry.activity` (e.g., "Work", "Bar", "Sleep").
pub activity: String,
/// The day phase this activity belongs to.
pub phase: DayPhase,
/// Tick when the NPC arrived and started this activity.
pub started_tick: u64,
}
/// Resource tracking the previous day phase for transition detection.
#[derive(Resource, Debug, Clone)]
pub struct PreviousDayPhase {
@@ -57,6 +84,9 @@ pub fn check_phase_transition(
previous.day = current_day;
for (entity, current_pos, routine) in npcs.iter() {
// Clear stale activity on phase transition — will be re-evaluated by enter_activity
commands.entity(entity).remove::<ActivityState>();
if let Some(expected_location) = routine.expected_location(current_phase) {
if *current_pos != expected_location {
commands.entity(entity).insert(PathRequest {
@@ -73,6 +103,76 @@ pub fn check_phase_transition(
}
}
// ---------------------------------------------------------------------------
// System: enter_activity (#101)
// ---------------------------------------------------------------------------
/// Set ActivityState when an NPC has arrived at their routine destination.
///
/// Runs after movement validation. Checks NPCs that:
/// - Have a DailyRoutine and ActiveSim tier
/// - Are NOT currently pathfinding (no ComputedPath or PathRequest)
/// - Are at the location specified for the current day phase
/// - Don't already have the correct ActivityState for the current phase
///
/// When conditions are met, inserts an ActivityState component. When an NPC
/// has a stale activity from a previous phase and isn't at the new phase's
/// destination, the stale activity is removed.
///
/// System ordering: after validate_movement, before compute_observer_snapshot.
pub fn enter_activity(
mut commands: Commands,
time: Res<SimulationTime>,
npcs: Query<
(
Entity,
&TilePosition,
&DailyRoutine,
Option<&ActivityState>,
),
(
With<Npc>,
With<ActiveSim>,
Without<ComputedPath>,
Without<PathRequest>,
),
>,
) {
let current_phase = time.day_phase();
for (entity, pos, routine, activity_opt) in npcs.iter() {
// Already performing the correct activity for this phase
if let Some(activity) = activity_opt {
if activity.phase == current_phase {
continue;
}
}
// Check if at routine destination for current phase
if let Some(entry) = routine.entry_for_phase(current_phase) {
if *pos == entry.location {
commands.entity(entity).insert(ActivityState {
activity: entry.activity.clone(),
phase: current_phase,
started_tick: time.tick,
});
tracing::trace!(
"Entity {:?}: entered activity '{}' for {:?}",
entity,
entry.activity,
current_phase,
);
} else {
// Not at destination yet — remove stale activity
commands.entity(entity).remove::<ActivityState>();
}
} else {
// No routine entry for this phase — remove stale activity
commands.entity(entity).remove::<ActivityState>();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,4 +343,318 @@ mod tests {
let request = world.get::<PathRequest>(entity).unwrap();
assert_eq!(request.goal, morning_loc);
}
// -- enter_activity tests (#101) ------------------------------------------
#[test]
fn npc_at_routine_destination_gets_activity_state() {
let mut world = setup_world();
// Time = Afternoon
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc, // Already at afternoon destination
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
let state = world.get::<ActivityState>(entity).unwrap();
assert_eq!(state.activity, "Work");
assert_eq!(state.phase, DayPhase::Afternoon);
assert_eq!(state.started_tick, MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE);
}
#[test]
fn npc_not_at_destination_no_activity_state() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let entity = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0), // NOT at afternoon location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(10, 10, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(world.get::<ActivityState>(entity).is_none());
}
#[test]
fn npc_with_computed_path_excluded() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc, // At destination but still has a path
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ComputedPath {
steps: vec![],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ActivityState>(entity).is_none(),
"NPC with ComputedPath should not get ActivityState"
);
}
#[test]
fn npc_with_path_request_excluded() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
PathRequest { goal: loc },
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ActivityState>(entity).is_none(),
"NPC with PathRequest should not get ActivityState"
);
}
#[test]
fn existing_activity_same_phase_not_overwritten() {
let mut world = setup_world();
let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = tick + 100;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ActivityState {
activity: "Work".into(),
phase: DayPhase::Afternoon,
started_tick: tick, // Set earlier
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
let state = world.get::<ActivityState>(entity).unwrap();
assert_eq!(
state.started_tick, tick,
"started_tick should be preserved, not updated"
);
}
#[test]
fn stale_activity_replaced_on_phase_change() {
let mut world = setup_world();
// Time = Evening (after Afternoon)
let evening_tick = 2 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = evening_tick;
let evening_loc = TilePosition::new(20, 20, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
evening_loc, // Already at evening location
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(10, 10, 0),
activity: "Work".into(),
},
RoutineEntry {
phase: DayPhase::Evening,
location: evening_loc,
activity: "Bar".into(),
},
],
description: "Test".into(),
},
// Stale activity from previous phase
ActivityState {
activity: "Work".into(),
phase: DayPhase::Afternoon,
started_tick: 1000,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
let state = world.get::<ActivityState>(entity).unwrap();
assert_eq!(state.activity, "Bar");
assert_eq!(state.phase, DayPhase::Evening);
assert_eq!(state.started_tick, evening_tick);
}
#[test]
fn no_routine_for_phase_clears_stale_activity() {
let mut world = setup_world();
// Time = Night
let night_tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = night_tick;
let entity = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(10, 10, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Evening,
location: TilePosition::new(10, 10, 0),
activity: "Bar".into(),
}],
description: "Test".into(),
},
// Stale activity from Evening, no Night entry
ActivityState {
activity: "Bar".into(),
phase: DayPhase::Evening,
started_tick: 1000,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ActivityState>(entity).is_none(),
"Stale activity should be cleared when no routine entry for current phase"
);
}
#[test]
fn phase_transition_clears_activity_state() {
let mut world = setup_world();
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc,
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: loc,
activity: "Work".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(20, 20, 0),
activity: "Lunch".into(),
},
],
description: "Test".into(),
},
ActivityState {
activity: "Work".into(),
phase: DayPhase::Morning,
started_tick: 0,
},
))
.id();
// Trigger phase transition to Afternoon
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
schedule.run(&mut world);
world.flush();
// ActivityState should be cleared by phase transition
assert!(
world.get::<ActivityState>(entity).is_none(),
"Phase transition should clear ActivityState"
);
// PathRequest should be set for the new phase location
assert!(world.get::<PathRequest>(entity).is_some());
}
}
+11
View File
@@ -17,6 +17,7 @@ use crate::perception::cognitive_delay::CognitiveDelay;
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
@@ -78,6 +79,7 @@ pub fn compute_observer_snapshot(
Option<&CognitiveDelay>,
Option<&mut DialogueResponseBuffer>,
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
),
With<PlayerCharacter>,
>,
@@ -105,6 +107,7 @@ pub fn compute_observer_snapshot(
cognitive_delay_opt,
mut dialogue_response_opt,
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
@@ -191,6 +194,12 @@ pub fn compute_observer_snapshot(
.map(|buf| buf.take())
.unwrap_or_default();
// Drain NPC-to-NPC conversation events (#247, D-078)
let (conversation_events, conversation_ended) = conversation_buffer_opt
.as_mut()
.map(|buf| (buf.take_events(), buf.take_ended()))
.unwrap_or_default();
// Collect sound events audible to the observer (D-038, #124).
// Filter by D-018 range: only events the player can hear based on distance.
let sound_events = if let Some(ref queue) = sound_queue {
@@ -251,6 +260,8 @@ pub fn compute_observer_snapshot(
dialogue_response,
blocked_entities,
scan_events,
conversation_events,
conversation_ended,
sound_events,
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
});
File diff suppressed because it is too large Load Diff
+103 -17
View File
@@ -21,11 +21,14 @@ use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::rng::SimRng;
@@ -68,7 +71,7 @@ pub struct CurrentMood(pub Mood);
impl Default for CurrentMood {
fn default() -> Self {
Self(Mood::Comfortable)
Self(Mood::Content)
}
}
@@ -346,6 +349,7 @@ pub fn process_talk_interaction(
registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
mut player_query: Query<
(
Entity,
@@ -357,7 +361,13 @@ pub fn process_talk_interaction(
),
With<PlayerCharacter>,
>,
npc_query: Query<(&DialogueProfile, Option<&CurrentMood>)>,
mut npc_query: Query<(
&DialogueProfile,
Option<&CurrentMood>,
Option<&mut InteractionMemory>,
Option<&NpcName>,
Option<&NpcColorIndex>,
)>,
) {
let Some(line_pool) = line_pool else {
return;
@@ -377,8 +387,10 @@ pub fn process_talk_interaction(
let target = talk_request.target;
// Look up NPC dialogue profile and mood
let Ok((profile, mood_opt)) = npc_query.get(target) else {
// Look up NPC dialogue profile, mood, interaction history, name, and color (#325)
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) =
npc_query.get_mut(target)
else {
tracing::debug!(
"Talk target {:?} has no DialogueProfile — cannot select dialogue",
target
@@ -397,7 +409,16 @@ pub fn process_talk_interaction(
let access_tiers = available_access_tiers(relationship);
// Layer 2: Derive active situations from game state
let situations = derive_situations(time.day_phase(), relationship);
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
@@ -467,10 +488,27 @@ pub fn process_talk_interaction(
return;
};
// Resolve speaker display name: use real name if player KG has "name"
// attribute for the target, otherwise fall back to role label.
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);
@@ -498,6 +536,17 @@ pub fn process_talk_interaction(
started_tick: time.tick,
});
// Trust progression (#324): successful talk warms the NPC
trust_queue.push(TrustEvent::TalkCompleted {
npc: target,
player: player_entity,
});
// Interaction tracking (#325): record completed talk
if let Some(ref mut mem) = interaction_mem_opt {
mem.record_talk(time.tick);
}
tracing::debug!(
"Dialogue selected: id={}, speaker={}, location={}, role={}",
line.id,
@@ -537,8 +586,10 @@ pub fn process_talk_interaction(
pub fn process_walk_away(
mut commands: Commands,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
time: Res<SimulationTime>,
query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With<PlayerCharacter>>,
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
) {
let Ok((player_entity, active_dialogue_opt, _walk_away)) = query.single() else {
return;
@@ -570,6 +621,20 @@ pub fn process_walk_away(
},
});
// Trust progression (#324): walk-away reduces NPC trust
trust_queue.push(TrustEvent::WalkAway {
npc: target,
player: player_entity,
});
// Interaction tracking (#325): record notable walk-away event
if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) {
mem.push_event(InteractionEvent {
tick: time.tick,
kind: InteractionEventKind::WalkAway,
});
}
tracing::debug!(
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
target {:?} Tier2 animation + routine deviation",
@@ -616,6 +681,7 @@ pub fn process_confrontation_response(
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut rng: ResMut<crate::simulation::rng::SimRng>,
mut trust_queue: ResMut<TrustEventQueue>,
mut query: Query<
(
Entity,
@@ -626,6 +692,7 @@ pub fn process_confrontation_response(
),
With<PlayerCharacter>,
>,
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
) {
let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) =
query.single_mut()
@@ -670,10 +737,24 @@ pub fn process_confrontation_response(
});
monologue_state.last_fired_tick = time.tick;
// Trust progression (#324): confrontation significantly reduces NPC trust
trust_queue.push(TrustEvent::ConfrontationDelivered {
npc: target,
player: player_entity,
});
// Interaction tracking (#325): record confrontation notable event
if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) {
mem.push_event(InteractionEvent {
tick: time.tick,
kind: InteractionEventKind::Confrontation,
});
}
tracing::info!(
tick = time.tick,
monologue_id = id,
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike"
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike + trust penalty"
);
// Clean up marker
@@ -891,14 +972,14 @@ mod tests {
#[test]
fn score_mood_match_adds_three() {
let line = make_line("moody", &[], &[Mood::Worried]);
assert_eq!(score_line(&line, Some(Mood::Worried), &[]), 4); // 1 base + 3 mood
let line = make_line("moody", &[], &[Mood::Anxious]);
assert_eq!(score_line(&line, Some(Mood::Anxious), &[]), 4); // 1 base + 3 mood
}
#[test]
fn score_mood_mismatch_stays_base() {
let line = make_line("moody", &[], &[Mood::Worried]);
assert_eq!(score_line(&line, Some(Mood::Fond), &[]), 1);
let line = make_line("moody", &[], &[Mood::Anxious]);
assert_eq!(score_line(&line, Some(Mood::Warm), &[]), 1);
}
#[test]
@@ -987,7 +1068,7 @@ mod tests {
fn select_deterministic_with_same_seed() {
let line_a = make_line("a", &[], &[]);
let line_b = make_line("b", &[Topic::Cargo], &[]);
let line_c = make_line("c", &[], &[Mood::Worried]);
let line_c = make_line("c", &[], &[Mood::Anxious]);
let candidates = vec![&line_a, &line_b, &line_c];
let cooldown = DialogueCooldownTracker::default();
@@ -1003,7 +1084,7 @@ mod tests {
fn select_favors_higher_scored_lines() {
// Line with matching mood gets +3, so should be selected more often
let neutral = make_line("neutral", &[], &[]);
let matched = make_line("matched", &[], &[Mood::Worried]);
let matched = make_line("matched", &[], &[Mood::Anxious]);
let candidates = vec![&neutral, &matched];
let cooldown = DialogueCooldownTracker::default();
@@ -1012,7 +1093,7 @@ mod tests {
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed);
if let Some(line) = select_dialogue_line(
&candidates,
Some(Mood::Worried),
Some(Mood::Anxious),
&[],
&cooldown,
0,
@@ -1039,6 +1120,7 @@ mod tests {
world.insert_resource(SimRng::new(42));
world.init_resource::<EntityRegistry>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<TrustEventQueue>();
world
}
@@ -1077,7 +1159,7 @@ mod tests {
trust: TrustTier::Surface,
situation: vec![Situation::NightShift],
topic: vec![Topic::Routine],
mood: vec![Mood::Comfortable],
mood: vec![Mood::Content],
tags: vec![],
knowledge_grant: None,
},
@@ -1089,7 +1171,7 @@ mod tests {
trust: TrustTier::Real,
situation: vec![Situation::Investigation],
topic: vec![Topic::Cargo, Topic::Investigation],
mood: vec![Mood::Conflicted],
mood: vec![Mood::Suspicious],
tags: vec![],
knowledge_grant: None,
},
@@ -1122,7 +1204,7 @@ mod tests {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
CurrentMood(Mood::Comfortable),
CurrentMood(Mood::Content),
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
@@ -1414,7 +1496,7 @@ mod tests {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
CurrentMood(Mood::Comfortable),
CurrentMood(Mood::Content),
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
@@ -1708,6 +1790,7 @@ mod tests {
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
@@ -1758,6 +1841,7 @@ mod tests {
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
@@ -1797,6 +1881,7 @@ mod tests {
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
@@ -1836,6 +1921,7 @@ mod tests {
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
+22 -1
View File
@@ -2,7 +2,7 @@
// Timestamped player input events for deterministic simulation (D-010 principle 4)
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
use crate::bridge::types::{PlayerAction, PlayerInput};
use crate::bridge::types::{FacingDirection, PlayerAction, PlayerInput};
use crate::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::simulation::inventory::{
@@ -237,6 +237,27 @@ pub fn process_player_input(
tracing::debug!("WalkAway: marker set on player");
}
}
PlayerAction::SetFacing { ref facing } => {
let dir = match facing.as_str() {
"North" => Some(FacingDirection::North),
"Northeast" => Some(FacingDirection::Northeast),
"East" => Some(FacingDirection::East),
"Southeast" => Some(FacingDirection::Southeast),
"South" => Some(FacingDirection::South),
"Southwest" => Some(FacingDirection::Southwest),
"West" => Some(FacingDirection::West),
"Northwest" => Some(FacingDirection::Northwest),
_ => {
tracing::warn!("SetFacing: unknown direction {:?}", facing);
None
}
};
if let Some(dir) = dir {
if let Ok((entity, _, _, _)) = player_query.single_mut() {
commands.entity(entity).insert(Facing(dir));
}
}
}
PlayerAction::TeleportToHub => {
handle_teleport_to_hub(&mut player_query, &mut commands);
}
+4
View File
@@ -5,6 +5,7 @@ use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod contraband;
pub mod conversation;
pub mod dialogue;
pub mod input;
pub mod interaction;
@@ -48,6 +49,9 @@ impl Plugin for SimulationPlugin {
contraband::check_contraband_scan
.after(movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
conversation::run_npc_conversations
.after(movement::validate_movement)
.before(sound::collect_sound_events),
sound::collect_sound_events
.after(movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
File diff suppressed because it is too large Load Diff
+97
View File
@@ -38,6 +38,8 @@
#[cfg(feature = "gauntlet")]
pub mod constants;
#[cfg(feature = "gauntlet")]
pub mod invariants;
pub mod reset;
#[cfg(feature = "gauntlet")]
pub mod rooms;
@@ -493,6 +495,98 @@ pub fn setup_gauntlet(app: &mut App) {
}
app.insert_resource(snapshots);
// --- Sprint 14 component fixup ---
// Attach MoodState and InteractionMemory to all Npc entities that are
// missing them. Gauntlet room builders don't include these yet — this
// ensures invariant S14-1/S14-2 pass and the mood/trust systems have
// valid component targets.
{
use crate::npc::interaction::InteractionMemory;
use crate::npc::mood::MoodState;
use crate::npc::Npc;
let missing_mood: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<MoodState>,
)>();
q.iter(app.world()).collect()
};
for entity in missing_mood {
app.world_mut()
.entity_mut(entity)
.insert(MoodState::default());
}
let missing_mem: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<InteractionMemory>,
)>();
q.iter(app.world()).collect()
};
for entity in missing_mem {
app.world_mut()
.entity_mut(entity)
.insert(InteractionMemory::default());
}
}
// --- Dialogue fixup: wire up DialogueProfile on all Npc entities missing one ---
// Gauntlet room builders (except dialogue_room) don't include DialogueProfile.
// Without it, the Talk verb silently no-ops. This fixup ensures every NPC
// can respond to Talk using content from the YAML dialogue pools.
{
use crate::npc::Npc;
use crate::simulation::conversation::NpcColorIndex;
use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
// (location, role) pairs matching content/campaigns/.../dialogue/ YAML pools.
// Cycling through these gives NPC variety across rooms.
const DIALOGUE_ROLES: &[(&str, &str)] = &[
("the-terminal", "dock-worker"),
("the-terminal", "courier"),
("the-terminal", "maintenance-tech"),
("the-terminal", "new-hire"),
("the-terminal", "scheduler"),
("the-terminal", "shift-supervisor"),
("the-last-shift", "bartender"),
("the-last-shift", "bar-regular"),
("the-last-shift", "day-worker"),
("maintenance-corridors", "transit-worker"),
];
let missing: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<DialogueProfile>,
)>();
q.iter(app.world()).collect()
};
for (i, entity) in missing.iter().enumerate() {
let (location, role) = DIALOGUE_ROLES[i % DIALOGUE_ROLES.len()];
let color_index = registry
.to_stable(*entity)
.map(|sid| (sid.0 % 8) as u8)
.unwrap_or(0u8);
app.world_mut().entity_mut(*entity).insert((
DialogueProfile {
location: location.to_string(),
role: role.to_string(),
},
CurrentMood::default(),
NpcColorIndex(color_index),
));
}
}
app.insert_resource(registry);
}
@@ -536,6 +630,9 @@ mod tests {
setup_gauntlet(&mut app);
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
invariants::run_invariants(app.world_mut());
let registry = app.world().resource::<EntityRegistry>();
assert_eq!(
registry.len(),
@@ -24,10 +24,6 @@ use crate::simulation::path_follow::MovementSpeed;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::DayPhase;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 64;
const ORIGIN_Y: i32 = 78;
/// Observer position for Shift Change tests (absolute).
pub const OBSERVER_POS: TilePosition = TilePosition { x: 72, y: 90, z: 0 };
+2
View File
@@ -62,6 +62,8 @@ fn snapshot_roundtrip_over_unix_socket() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
rng_seed: None,
};
+2
View File
@@ -48,6 +48,8 @@ fn snapshot_roundtrip_over_tcp() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
rng_seed: None,
};
+8 -8
View File
@@ -117,12 +117,12 @@ fn load_real_transit_district() {
assert!(template_ids.contains(&"bar"));
assert!(template_ids.contains(&"smuggling-ring"));
// 20 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan)
// Populated by #398 (wiki→YAML NPC conversion)
// 23 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan) + 3 Sprint 14 additions
// Populated by #398 (wiki→YAML NPC conversion) and Sprint 14 content expansion
assert_eq!(
transit.npc_profiles.len(),
20,
"Expected 20 parseable NPC profiles (17 NPCs + 2 PCs + 1 extended)"
23,
"Expected 23 parseable NPC profiles"
);
}
@@ -338,8 +338,8 @@ fn spawn_real_content_with_relationships_and_secrets() {
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// All 20 profiles should spawn
assert_eq!(result.npcs_spawned, 20);
// All 23 profiles should spawn
assert_eq!(result.npcs_spawned, 23);
assert!(result.npc_ids.contains_key("npc:kael-davan"));
assert!(result.npc_ids.contains_key("npc:voss"));
assert!(result.npc_ids.contains_key("npc:pc-smuggler"));
@@ -360,8 +360,8 @@ fn spawn_real_content_with_relationships_and_secrets() {
npcs_with_want += 1;
}
assert_eq!(
npcs_with_want, 20,
"All 20 NPCs should have Want components"
npcs_with_want, 23,
"All 23 NPCs should have Want components"
);
// Spot-check specific Want values
+2 -2
View File
@@ -392,7 +392,7 @@ fn different_seed_produces_different_replay() {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
CurrentMood(Mood::Comfortable),
CurrentMood(Mood::Content),
));
}
@@ -408,7 +408,7 @@ fn different_seed_produces_different_replay() {
situation: vec![Situation::Routine],
topic: vec![],
mood: if i % 2 == 0 {
vec![Mood::Comfortable]
vec![Mood::Content]
} else {
vec![]
},
+2
View File
@@ -10,6 +10,7 @@ use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::npc::relationships::TrustEventQueue;
use settled_reach_server::simulation::SimulationPlugin;
use std::io::{BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
@@ -30,6 +31,7 @@ fn player_moves_north_through_full_pipeline() {
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.init_resource::<TrustEventQueue>();
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
app.world_mut().spawn((

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