Address review comments from Hoshe, Paula, and Miri: - Update mood vocabulary in 3 docs (line-pool-format.md §6.5, style-guide §11, content-directory-structure.md Appendix B) from pre-Sprint 14 values to current D-035 enum - Fix worked example IDs in line-pool-format.md §3.5 to match actual the-last-shift kael-davan sequence (_015, _024, _026) - Fix Section 5.1 restart note to describe multi-location continuity - Fix style-guide §16 worked example: dock-worker_d_071 → kael-davan_d_076 - Fix stale mood reference in style-guide §16 Step 3 - Fix orphaned the-terminal_d_040 in maintenance-tech.yaml comment - Fix orphaned the-terminal_d_008/018 in smuggler-inventory.yaml - Fix Lera tenure: twelve → eighteen years (bar-owner_d_018) - Fix fact_id: location.surveillance_gaps → investigation.surveillance_gaps in ring-operative.yaml (2 occurrences) - Fix NPC name: Lera Osk → Lera Sessik in bar-owner.yaml comment - Fix mood line format example in style-guide §5 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
26 KiB
Line Pool Format Specification
Ticket: #308 | Sprint: 7 | Priority: HIGH (blocks #326, #305) Decisions: D-028 (dialogue architecture), D-032 (separate monologue pools), D-035 (tag taxonomy), D-041 (KG data model) Audience: Content authors (Mellanie, Paula), engine developers (Dudley), QA (Hoshe)
1. Overview
Line pools are the atomic content units for The Settled Reach's dialogue and monologue systems. Each pool is a YAML file containing tagged lines that the engine selects from at runtime using a four-layer filtering pipeline (D-028).
There are two pool types:
| Pool type | Scope | Selection model | Partition |
|---|---|---|---|
| Dialogue | Per-location, per-role | 4-layer filter (access > situation > trust > topic+mood) | None (role-based, character-agnostic) |
| Monologue | Per-location, per-character | Trigger-based with prerequisite gates | Hard partition by character (D-032) |
Both types share a common tag vocabulary defined in content/global/enums/.
2. Directory Structure
Content lives under content/campaigns/{campaign}/systems/{system}/stations/{station}/districts/{district}/. Below the district level:
{district}/
dialogue/
{location}/ # one subdirectory per location
{role}.yaml # one file per template role at that location
{named-npc}.yaml # named NPCs get dedicated files
pc-smuggler.yaml # PC-specific dialogue (if applicable)
pc-detective.yaml
monologue/
smuggler/ # hard partition per D-032
general.yaml # location-independent lines
{location}.yaml # location-specific lines
{topic-slug}.yaml # topic-specific cross-location pools
detective/
general.yaml
{location}.yaml
{topic-slug}.yaml
Naming conventions
| Element | Pattern | Examples |
|---|---|---|
| Location directory | kebab-case matching location YAML slug |
the-terminal, the-last-shift, maintenance-corridors |
| Dialogue file | {role-slug}.yaml or {npc-slug}.yaml |
dock-worker.yaml, kael-davan.yaml, pc-detective.yaml |
| Monologue file | {location-slug}.yaml or general.yaml or {topic}.yaml |
the-terminal.yaml, general.yaml, pc-detective-tells.yaml |
| Character directory | smuggler/ or detective/ |
Matches character enum exactly |
What is NOT encoded in per-line tags
Per D-035, the following are implicit from directory structure and never appear as line-level tags:
- Location — derived from the parent directory name
- Content type — derived from whether the file is under
dialogue/ormonologue/ - Character (monologue only) — derived from the parent
smuggler/ordetective/directory, confirmed by thecharacterfield in the YAML header
3. Dialogue Pool Format
3.1 File structure
# {NPC name or role description}
# Context notes for authors (not consumed by engine)
location: {location-slug} # required, must match parent directory name
role: {role-slug} # required, template role (not NPC name)
lines:
- id: {line-id}
text: "Dialogue line text."
role: {role-slug}
access: [{access-tier}, ...]
trust: {trust-tier}
situation: [{situation}, ...]
topic: [{topic}, ...] # optional, defaults to []
mood: [{mood}, ...] # optional, defaults to []
tags: [{freeform}, ...] # optional, defaults to []
knowledge_grant: # optional
fact_id: {category.fact_id}
confidence: {confidence-level}
3.2 Header fields
| Field | Type | Required | Description |
|---|---|---|---|
location |
string | YES | Location slug, must match parent directory. Pattern: ^[a-z][a-z0-9-]*$ |
role |
string | YES | Template role slug. Pattern: ^[a-z][a-z0-9-]*$. This is the abstract role (e.g. dock-worker), not a specific NPC name. NPC assignment to roles happens at runtime via template instantiation. |
3.3 Line fields
Structural tags (required on every line)
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
id |
string | YES | ^[a-z][a-z0-9-]*_d_[0-9]{3}$ |
Stable line identifier. See Section 5: ID Format. |
text |
string | YES | Non-empty | The authored dialogue line. No length limit (unlike monologue). |
role |
string | YES | ^[a-z][a-z0-9-]*$ |
Template role this line belongs to. Must match header role or be a valid role at this location. |
access |
list<enum> | YES | Min 1 item, unique | Access tiers this line is eligible for. Hard filter — line is invisible if player's access tier is not in this list. Values: public, insider, authority, peer, hostile. |
trust |
enum | YES | Single value | Minimum trust tier required. Hard filter — line is invisible below this tier. Values: surface, real, secret. |
situation |
list<enum> | YES | Min 1 item, unique | Situation contexts when this line can fire. Context filter — engine activates situations based on simulation state. Values: arrival, shift_start, shift_end, shift_transition, bar_evening, night_shift, investigation, confrontation, social, alone, emergency, routine, observation. |
Selection tags (optional, influence weighted selection)
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
topic |
list<enum> | no | [] |
Topic tags for Layer 4 weighted selection. Lines without topic tags are eligible for any topic context. Values: colleague, routine, cargo, money, trust, danger, institution, personal, investigation. |
mood |
list<enum> | no | [] |
Mood tags for Layer 4 weighted selection. Lines without mood tags are eligible for any mood context. Values: anxious, frustrated, content, suspicious, warm, hostile, relieved, focused. |
tags |
list<string> | no | [] |
Freeform escape hatch. Not consumed by the filtering engine — used for author organization, content queries, and the line previewer. No validation on values. |
Knowledge grant (optional)
| Field | Type | Required | Description |
|---|---|---|---|
knowledge_grant |
object | no | Knowledge the player gains from hearing this line. |
knowledge_grant.fact_id |
string | YES (if grant present) | Fact identifier in {category}.{fact_id} format. Must reference a fact defined in content/global/knowledge/{category}.yaml. |
knowledge_grant.confidence |
enum | YES (if grant present) | Confidence level granted. Values: suspects, knows_of, knows_details, direct. |
3.4 Four-layer filtering pipeline (D-028)
The engine processes dialogue lines through four layers in sequence:
All lines in pool
│
├─ Layer 1: ACCESS FILTER (hard)
│ Keep lines where player's access tier ∈ line.access
│
├─ Layer 2: SITUATION FILTER (context)
│ Keep lines where any active situation ∈ line.situation
│
├─ Layer 3: TRUST FILTER (hard)
│ Keep lines where player's trust ≥ line.trust
│ (surface < real < secret)
│
└─ Layer 4: TOPIC + MOOD SELECTION (weighted)
Score remaining lines by topic and mood match.
Lines with no topic/mood tags get a neutral weight (always eligible,
never boosted). Select from top-scored candidates with randomization.
Authoring implication: Every line must pass Layers 1-3 to be eligible. Layers 1 and 3 are hard gates — get them wrong and the line is invisible. Layer 2 controls when the line fires. Layer 4 is a soft preference.
3.5 Complete dialogue example
# Kael Davan — dock worker at The Last Shift
# Voice: direct, practical, short sentences, warm to trusted people
location: the-last-shift
role: dock-worker
lines:
- id: kael-davan_d_015
text: "Saved you a seat. Lera's got the spiced rice tonight."
role: dock-worker
access: [insider, peer]
trust: surface
situation: [bar_evening, social, arrival]
mood: [warm]
topic: [personal, colleague]
tags: [kael, greeting, phase-1]
- id: kael-davan_d_024
text: "Nils wants to talk. Tomorrow, bay side. Said it's about volume."
role: dock-worker
access: [insider]
trust: real
situation: [bar_evening, social]
mood: [anxious]
topic: [danger]
tags: [kael, ring-ops, nils]
- id: kael-davan_d_026
text: "Lera knows more than she lets on. She won't say anything — but don't test it."
role: dock-worker
access: [insider]
trust: real
situation: [bar_evening, social, alone]
mood: [anxious]
topic: [colleague, danger]
tags: [kael, ring-ops, lera, caution]
knowledge_grant:
fact_id: knowledge.bar_ring_awareness
confidence: suspects
4. Monologue Pool Format
4.1 File structure
character: {character} # required, hard partition (D-032)
location: {location-slug} # required, or "general" for location-independent
lines:
- id: {line-id}
text: "Internal monologue text."
trigger: {trigger-type}
prerequisites: # optional
facts:
- fact_id: {category.fact_id}
min_confidence: {confidence-level}
entity_attributes:
- entity: {entity-ref}
key: {attribute-key}
value: {attribute-value}
relationship:
target: {entity-ref}
state: {relationship-state}
priority: {0-10} # optional, default 5
cooldown: {ticks} # optional, minimum re-fire interval
tags: [{freeform}, ...] # optional
4.2 Header fields
| Field | Type | Required | Description |
|---|---|---|---|
character |
enum | YES | smuggler or detective. Hard partition per D-032 — pools are completely separate. The engine never crosses this boundary. |
location |
string | YES | Location slug, or general for location-independent lines. Pattern: ^[a-z][a-z0-9-]*$|^general$ |
4.3 Line fields
Core fields (required)
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
id |
string | YES | ^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$ |
Stable line identifier. See Section 5: ID Format. |
text |
string | YES | 1-160 characters | The monologue line. 160-char max — authoring constraint to fit the monologue display without scrolling (established in monologue-pool.schema.json, not yet formalized as a decision). |
trigger |
enum | YES | Single value | What causes this line to fire. Values: enter_location, observe_npc, hear_sound, observe_anomaly, post_conversation, discover_evidence, witness_interaction, time_idle, return_visit. |
Prerequisites (optional, AND-combined)
All prerequisite conditions are AND-combined: every specified condition must be true for the line to be eligible.
| Field | Type | Description |
|---|---|---|
prerequisites |
object | Knowledge state gates. If omitted, the line has no prerequisites (always eligible given trigger). |
prerequisites.facts |
list<object> | Fact-based gates. Each entry requires fact_id (string, {category}.{fact_id} format) and min_confidence (enum: suspects, knows_of, knows_details, direct). The player's confidence for the referenced fact must be ≥ the specified minimum. |
prerequisites.entity_attributes |
list<object> | Entity attribute gates. Each entry requires entity (string, entity reference like npc:kael-davan), key (string, attribute name), value (string, expected value). |
prerequisites.relationship |
object | Relationship state gate. Requires target (string, entity reference) and state (enum: unknown, known, friendly, person_of_interest, hostile). The player's relationship with the target must be at or beyond the specified state. |
Prerequisite-to-KG mapping:
| Prerequisite type | KG query | D-041 structure |
|---|---|---|
facts[].fact_id + min_confidence |
Look up FactId in BTreeMap<FactId, FactKnowledge>, check confidence ≥ min |
FactKnowledge.confidence (4-level: Suspects < KnowsOf < KnowsDetails < Direct) |
entity_attributes[].entity |
Look up StableId via EntityRegistry, check attribute |
EntityKnowledge attribute fields |
relationship.target + state |
Look up entity in BTreeMap<StableId, EntityKnowledge>, check relationship state |
EntityKnowledge.relationship_state |
Selection fields (optional)
| Field | Type | Default | Description |
|---|---|---|---|
priority |
integer | 5 |
Selection priority, 0-10. Higher = more likely to fire when multiple lines match the same trigger + prerequisites. Priority 9-10 should be reserved for critical narrative moments. |
cooldown |
integer | 0 |
Minimum ticks before this line can fire again for the same player. At 10 tps (D-031), a cooldown of 600 = 1 game-minute. |
tags |
list<string> | [] |
Freeform tags. Not consumed by the selection engine — used for author organization and the line previewer. |
4.4 Monologue selection flow
Trigger fires (e.g. enter_location)
│
├─ Character partition: select pool for current PC (D-032)
│
├─ Location filter: prefer location-specific pool, fall back to general
│
├─ Trigger filter: keep lines matching this trigger type
│
├─ Prerequisite gate: evaluate all prerequisites against KG state
│ (AND-combined — all must pass)
│
├─ Cooldown check: exclude recently fired lines
│
└─ Priority-weighted selection: pick from eligible lines
Higher priority = higher weight. Randomized among equal-priority.
4.5 Complete monologue example
character: detective
location: the-last-shift
lines:
# Basic atmospheric line — no prerequisites, any visit
- id: pc-detective_m_d_001
text: "The Last Shift. Only place in this district that doesn't smell like freight lubricant."
trigger: enter_location
tags: [arrival, atmospheric]
# Knowledge-gated observation — requires prior suspicion
- id: pc-detective_m_d_021
text: "Sera left when Torek arrived. Second time. Different excuse. Same result."
trigger: observe_anomaly
prerequisites:
facts:
- fact_id: behavioral.sera_avoidance_pattern
min_confidence: suspects
priority: 7
tags: [npc, sera, torek, tell, friend-arc]
# Relationship-gated line — requires person_of_interest status
- id: pc-detective_m_d_026
text: "Same booth. Same warm smile. Same offer to buy me a drink. Everything except the truth."
trigger: observe_npc
prerequisites:
relationship:
target: npc:sera-venn
state: person_of_interest
priority: 8
tags: [npc, sera, contaminated-trust, friend-arc]
5. ID Format
5.1 Pattern
{npc-slug}_{type}_{character?}_{sequence}
| Segment | Format | Values | Example |
|---|---|---|---|
npc-slug |
kebab-case | NPC name in kebab-case. Each NPC has an independent sequence. | kael-davan, dock-worker, pc-detective |
type |
single char | d = dialogue, m = monologue, e = environmental (future) |
d, m |
character |
single char | s = smuggler, d = detective. Monologue only. |
s, d |
sequence |
3-digit zero-padded | 001–999 per NPC |
001, 042 |
General validation regex (matches both dialogue and monologue IDs):
^[a-z0-9-]+_(d|m)(_[a-z])?_\d{3}$
[a-z0-9-]+— npc slug (kebab-case, at least one character)(d|m)— pool type:dfor dialogue,mfor monologue(_[a-z])?— optional character segment (monologue only):_sor_d\d{3}— three-digit zero-padded sequence number
Use the pool-specific regexes in Section 5.2 for strict per-type validation. This general regex is useful for quick format checks that accept either type.
D-035 Sprint 15 amendment: IDs are NPC-scoped, not location-scoped. The old
{location-slug}_{d|m}_{###}format caused collisions when the same NPC appeared at multiple locations (e.g.the-terminal_d_039appeared in multiple NPC files). The new format{npc-slug}_{d|m}_{###}gives each NPC an independent 999-line ceiling. For multi-location NPCs, sequences are globally continuous across files (e.g. kael-davan uses _001-_014 at maintenance-corridors, _015-_033 at the-last-shift, _034-_075 at the-terminal). Single-location NPCs start at _001. Monologue uses per-file restart with composite key (file_path + line_id).
5.2 Regex patterns
| Pool type | Regex | Example |
|---|---|---|
| Dialogue | ^[a-z][a-z0-9-]*_d_[0-9]{3}$ |
kael-davan_d_001 |
| Monologue | ^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$ |
pc-detective_m_d_021, pc-smuggler_m_s_003 |
5.3 Uniqueness scope
- IDs must be unique within a single YAML file.
- IDs are not required to be globally unique — npc-slug + file path provides global uniqueness. The engine uses
(file_path, line_id)as the composite key. - Sequence numbers need not be contiguous. Gaps are expected when lines are removed or reordered.
5.4 ID stability
IDs are stable references. Once assigned, a line ID should not change. Other systems (cooldown tracking, analytics, the line previewer) reference lines by ID. If a line's text changes, keep the ID. Only assign a new ID when creating a genuinely new line.
6. Tag Enums Reference
All enum values are defined in content/global/enums/ and validated by the JSON schemas in content/_schema/. This section is a quick reference — see the enum YAML files for full descriptions.
6.1 Access tiers (D-028 Layer 1)
| Value | Description |
|---|---|
public |
Available to anyone. Surface-level conversation. |
insider |
Available to group members. Ring membership, established regulars. |
authority |
Available to institutional figures. Commission agents, security. |
peer |
Available to social equals with earned personal trust. |
hostile |
Available when relationship has broken down. |
6.2 Trust tiers (D-028 Layer 3)
| Value | Gate level | Description |
|---|---|---|
surface |
Default | Safe, non-committal. What NPCs tell strangers. |
real |
Earned | Genuine opinions, workplace complaints, personal concerns. |
secret |
Highest | Information they'd normally hide. Ring involvement, vulnerabilities. |
6.3 Situations (D-028 Layer 2)
arrival, shift_start, shift_end, shift_transition, bar_evening, night_shift, investigation, confrontation, social, alone, emergency, routine, observation
13 values for v0.1. See content/global/enums/situations.yaml for full descriptions.
6.4 Topics (D-028 Layer 4)
colleague, routine, cargo, money, trust, danger, institution, personal, investigation
9 values for v0.1. Note: crime is deliberately excluded — NPCs think of smuggling as cargo or money.
6.5 Moods (D-028 Layer 4)
anxious, frustrated, content, suspicious, warm, hostile, relieved, focused
8 values for v0.1. (Updated Sprint 14 amendment to D-035.)
6.6 Monologue triggers
enter_location, observe_npc, hear_sound, observe_anomaly, post_conversation, discover_evidence, witness_interaction, time_idle, return_visit
9 values for v0.1.
6.7 Confidence levels (D-041)
| Value | Gate meaning | Maps to D-028 |
|---|---|---|
suspects |
"Something's off" | Gates initial investigation |
knows_of |
"X is involved in Y" | Gates real trust dialogue, peer access |
knows_details |
Actionable detail | Gates confrontation, secret trust dialogue |
direct |
Currently in LOS | Live position data, maximum rendering fidelity |
7. Authoring-Only Fields
These fields are consumed by the line previewer and authoring tools but not by the runtime engine. They may appear in any pool file.
| Field | Type | Scope | Description |
|---|---|---|---|
dual_lens |
map | Per-line | Per-character authoring notes for mirror moments. Keys: smuggler, detective. Values: string notes about how each character interprets this line/moment differently. |
notes |
string | Per-line | Free-text author notes. Context, intent, voice reminders. |
These fields are defined in the JSON schemas with additionalProperties: false — if you need to add them, the schemas must be updated first. Current schemas do not include these fields; they are reserved for a future schema update when the previewer authoring workflow matures.
8. Validation Rules
8.1 Structural validation (make validate-content)
The existing make validate-content target (#392) validates against JSON schemas. The following rules are enforced:
| Rule | Scope | Error level |
|---|---|---|
| All required fields present | Both | ERROR |
id matches regex pattern |
Both | ERROR |
id unique within file |
Both | ERROR |
| Enum values match defined sets | Both | ERROR |
access has ≥ 1 item |
Dialogue | ERROR |
situation has ≥ 1 item |
Dialogue | ERROR |
text is non-empty |
Both | ERROR |
text ≤ 160 chars |
Monologue | ERROR |
character matches parent directory |
Monologue | ERROR |
location matches parent directory |
Both | ERROR |
priority is 0-10 |
Monologue | ERROR |
knowledge_grant.fact_id references existing fact |
Dialogue | WARNING |
prerequisites.facts[].fact_id references existing fact |
Monologue | WARNING |
Lists have uniqueItems |
Both | ERROR |
8.2 Content-level validation (line previewer, future)
These are not yet enforced by tooling but are authoring guidelines:
- Every template role should have ≥ 5
surfacetrust lines atpublicaccess (baseline conversation) - Every location should have ≥ 3 monologue lines per character for
enter_locationtrigger (first-visit coverage) - Lines tagged with
phase-5(contaminated trust) should have correspondingphase-1baseline lines knowledge_grantconfidence should not exceedknows_offrom a single dialogue line (hearing one line shouldn't grantknows_details)- Named NPC files should have lines across ≥ 2 trust tiers
9. Rust Loader Interface
For #326 (YAML content loader), the engine parses these files into in-memory structures. This section defines the target API — not the implementation.
9.1 Core types
/// A loaded dialogue line, fully parsed and validated.
struct DialogueLine {
id: LineId,
text: String,
role: RoleSlug,
access: Vec<AccessTier>, // D-028 Layer 1
trust: TrustTier, // D-028 Layer 3
situation: Vec<Situation>, // D-028 Layer 2
topic: Vec<Topic>, // D-028 Layer 4
mood: Vec<Mood>, // D-028 Layer 4
tags: Vec<String>,
knowledge_grant: Option<KnowledgeGrant>,
}
/// A loaded monologue line, fully parsed and validated.
struct MonologueLine {
id: LineId,
text: String, // ≤ 160 chars
trigger: Trigger,
prerequisites: Option<Prerequisites>,
priority: u8, // 0-10, default 5
cooldown: u32, // ticks, default 0
tags: Vec<String>,
}
/// Pool container — one per file loaded.
struct DialoguePool {
location: LocationSlug,
role: RoleSlug,
lines: Vec<DialogueLine>,
}
struct MonologuePool {
character: Character, // smuggler | detective
location: LocationSlug, // or "general"
lines: Vec<MonologueLine>,
}
9.2 Query API
/// Query dialogue lines through the 4-layer pipeline.
fn query_dialogue(
pool: &DialoguePool,
player_access: AccessTier,
active_situations: &[Situation],
player_trust: TrustTier,
npc_topics: &[Topic], // weighted preference, not hard filter
npc_mood: &[Mood], // weighted preference, not hard filter
) -> Vec<&DialogueLine>;
/// Query monologue lines for a trigger event.
fn query_monologue(
pools: &[MonologuePool], // all pools for current character + location
character: Character,
trigger: Trigger,
knowledge: &KnowledgeGraph, // D-041
fired_cooldowns: &BTreeMap<LineId, Tick>, // BTreeMap per D-041
) -> Vec<&MonologueLine>;
9.3 Indexing strategy
Per D-041 determinism requirements, all internal maps use BTreeMap:
- Primary index:
BTreeMap<(LocationSlug, RoleSlug), DialoguePool>— dialogue pools by location + role - Access pre-filter: Lines within each pool pre-sorted by access tier for O(1) hard filter
- Monologue index:
BTreeMap<(Character, LocationSlug), Vec<MonologuePool>>— monologue pools by character + location - Trigger index: Within each monologue pool, lines grouped by trigger type for fast lookup
10. Content Volume Estimates
Per D-028, target line counts for v0.1 vertical slice (Sova Transit District):
| Content type | Per template role | Per location | District total |
|---|---|---|---|
| Dialogue lines | 165-210 authored | ~500-700 | ~2,000-3,000 |
| Monologue lines (per character) | — | 20-40 | ~100-200 |
| Generation-expanded (4x) | 660-840 | ~2,000-2,800 | ~8,000-12,000 |
The generation pass (write 10, generate 40) is a future pipeline step — authored YAML files contain only human-written lines.
11. Schema Files
The canonical JSON Schema files that validate this format:
| Schema | Path | Validates |
|---|---|---|
| Dialogue pool | content/_schema/dialogue-pool.schema.json |
dialogue/**/*.yaml |
| Monologue pool | content/_schema/monologue-pool.schema.json |
monologue/**/*.yaml |
| Fact catalog | content/_schema/fact-catalog.schema.json |
global/knowledge/*.yaml |
These schemas are the machine-enforceable subset of this specification. This document is the authoritative reference; the schemas enforce the structural rules.