--- title: "Line Pool Format Specification" description: "YAML format spec for dialogue and monologue line pools — file structure, tag enums, four-layer filtering pipeline, ID format, validation rules, and Rust loader interface" type: architecture status: active ticket: "#308" decision_refs: [D-028, D-032, D-035, D-041] author: "" created: YYYY-MM-DD updated: YYYY-MM-DD --- # 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/` or `monologue/` - **Character** (monologue only) — derived from the parent `smuggler/` or `detective/` directory, confirmed by the `character` field in the YAML header --- ## 3. Dialogue Pool Format ### 3.1 File structure ```yaml # {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](#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\ | 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\ | 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\ | 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\ | 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\ | 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 ```yaml # 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 ```yaml 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](#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\ | 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\ | 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`, 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`, 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\ | `[]` | 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 ```yaml 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: `d` for dialogue, `m` for monologue - `(_[a-z])?` — optional character segment (monologue only): `_s` or `_d` - `\d{3}` — three-digit zero-padded sequence number Use the pool-specific regexes in [Section 5.2](#52-regex-patterns) for strict per-type validation. This general regex is useful for quick format checks that accept either type. > **D-035 Sprint 15 amendment:** IDs are NPC-scoped, not location-scoped. The old `{location-slug}_{d|m}_{###}` format caused collisions when the same NPC appeared at multiple locations (e.g. `the-terminal_d_039` appeared in multiple NPC files). The new format `{npc-slug}_{d|m}_{###}` gives each NPC an independent 999-line ceiling. For multi-location NPCs, sequences are globally continuous across files (e.g. kael-davan uses _001-_014 at maintenance-corridors, _015-_033 at the-last-shift, _034-_075 at the-terminal). Single-location NPCs start at _001. Monologue uses per-file restart with composite key (file_path + line_id). ### 5.2 Regex patterns | Pool type | Regex | Example | |-----------|-------|---------| | Dialogue | `^[a-z][a-z0-9-]*_d_[0-9]{3}$` | `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 `surface` trust lines at `public` access (baseline conversation) - Every location should have ≥ 3 monologue lines per character for `enter_location` trigger (first-visit coverage) - Lines tagged with `phase-5` (contaminated trust) should have corresponding `phase-1` baseline lines - `knowledge_grant` confidence should not exceed `knows_of` from a single dialogue line (hearing one line shouldn't grant `knows_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 ```rust /// A loaded dialogue line, fully parsed and validated. struct DialogueLine { id: LineId, text: String, role: RoleSlug, access: Vec, // D-028 Layer 1 trust: TrustTier, // D-028 Layer 3 situation: Vec, // D-028 Layer 2 topic: Vec, // D-028 Layer 4 mood: Vec, // D-028 Layer 4 tags: Vec, knowledge_grant: Option, } /// A loaded monologue line, fully parsed and validated. struct MonologueLine { id: LineId, text: String, // ≤ 160 chars trigger: Trigger, prerequisites: Option, priority: u8, // 0-10, default 5 cooldown: u32, // ticks, default 0 tags: Vec, } /// Pool container — one per file loaded. struct DialoguePool { location: LocationSlug, role: RoleSlug, lines: Vec, } struct MonologuePool { character: Character, // smuggler | detective location: LocationSlug, // or "general" lines: Vec, } ``` ### 9.2 Query API ```rust /// 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, // 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>` — 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.