Skip to content

Dialogue System

Dynamic conversation engine with strategies, handlers, and NPC interactions.

This document will be auto-updated by Minh when code changes are detected.

Architecture Overview

The dialogue system uses the Strategy Pattern to allow flexible, extensible NPC dialogue handling. Core packages live under dialogue/ in TheSmithy:

Package Purpose
dialogue/core/ Interfaces, formatters, dialogue sources
dialogue/handlers/ Input routing, session management, system selection
dialogue/strategies/ Pluggable dialogue strategy implementations
dialogue/services/ Condition evaluation, command execution, input matching
dialogue/models/ Data models (e.g. DialogueResult)
dialogue/interfaces/ Abstract interfaces for command executors, condition evaluators, dialogue sources, freeform matchers

Strategy Pattern

All dialogue strategies extend DialogueStrategy (dialogue/strategies/base.py), which defines:

  • can_handle(npc) -> bool — Whether this strategy applies to the NPC.
  • get_priority() -> int — Priority (0–100, highest checked first).
  • start_dialogue(caller, npc) — Begin a conversation.
  • handle_input(caller, npc, input_text) — Process player input during dialogue.
  • end_dialogue(caller, npc) — Clean up when dialogue ends.

Registered Strategies

The DialogueStrategyRegistry (dialogue/strategies/registry.py) lazily initializes and registers the default strategies on first access:

Strategy Priority Description
ConditionalDialogueStrategy 90 API-based conditional dialogue
StructuredDialogueStrategy 80 JSON-file-driven passage-based dialogues
SimpleDialogueStrategy 50 Basic dialogue handling
LegacyDialogueStrategy 30 Backward-compatible dialogue menus

NPCs can also provide a custom strategy (priority 100) by implementing get_dialogue_strategy(caller) or get_dialogue_handler() on their typeclass. The caller argument enables per-(caller, npc) strategy decisions — for example, FaeFamiliar returns a TeachBackDialogueStrategy when the caller has a pending teach-back at the current room, and falls through to the registry default otherwise.

TeachBackDialogueStrategy

TeachBackDialogueStrategy (dialogue/strategies/teach_back_strategy.py) is an additional strategy that is not registered in the global DialogueStrategyRegistry. Because its routing decision requires caller context (whether the caller has a pending teach-back at the current room), it cannot use the registry's can_handle(npc) signature. Instead, FaeFamiliar.get_dialogue_strategy(caller) returns an instance when teach-back should fire.

When active, the strategy delegates to TeachBackOrchestrator:

  • start_dialogue(caller, npc) — Looks up the pending teach-back location via orch.get_pending_location_for_player(caller) and calls orch.deliver_prompt(caller, pending_loc, npc=npc). Returns False (with a warning log) if no pending location exists at start time.
  • handle_input(caller, npc, input_text) — Returns False; teach-back input flows through TeachBackOrchestrator's own dialogue_message handler.
  • end_dialogue(caller, npc) — Calls end_active_dialogue(caller, reason="teach_back_end").

System Selector

DialogueSystemSelector (dialogue/handlers/system_selector.py) is the entry point for starting NPC dialogue. Resolution order:

  1. NPC custom strategy via npc.get_dialogue_strategy(caller) (highest priority).
  2. Registry lookup — iterates registered strategies by descending priority and returns the first whose can_handle(npc) returns True.
  3. Fallback message if no strategy matches.
class CustomNPC(BaseNPC):
    def get_dialogue_strategy(self, caller):
        return MyCustomDialogueStrategy()

Handlers

  • input_router.py — Routes player input to the active dialogue handler.
  • session_manager.py — Manages active dialogue sessions per player/NPC pair.
  • system_selector.py — Selects the appropriate dialogue strategy (see above).

Interaction Dispatch (CmdInteract)

CmdInteract (commands/interact.py) is the player-facing command that initiates all object and NPC interactions. After target resolution, it applies the following dispatch chain:

InteractableMixin

Any typeclass that inherits InteractableMixin (typeclasses/mixins/interactable.py) is routed through a single generic dispatch branch. The mixin defines an at_interact(caller) contract that subclasses must implement. Current implementations include Telescope, L2RBeacon, and MendableBoat.

Adding a new interactable object requires no changes to CmdInteract — just inherit the mixin and implement at_interact:

from typeclasses.mixins.interactable import InteractableMixin

class MyNewProp(InteractableMixin, Object):
    def at_interact(self, caller):
        # ... your response ...

If at_interact raises an exception, CmdInteract catches it, logs the error, and sends a generic failure message to the caller.

Note: BoardGameObject still uses a dedicated branch in CmdInteract because its player-selection / challenge-OOB flow has not yet been migrated to the mixin pattern.

Pre-Dialogue Hook (at_pre_dialogue)

Before starting the dialogue strategy, CmdInteract checks whether the NPC defines an at_pre_dialogue(caller) method. This hook lets NPCs inspect caller state, transfer items, set flags, or perform other mutations before the dialogue widget opens or the dialogue strategy evaluates conditional state.

  • Return True to suppress the rest of the interaction (no preamble, no dialogue strategy). Use this for NPCs that fully handle the interaction without a dialogue widget.
  • Return False / None (or don't define the hook) to proceed to dialogue normally.
  • If the hook raises an exception, it is logged and dialogue continues.
class Arturo(BaseNPC):
    def at_pre_dialogue(self, caller):
        # Scan caller's inventory for puzzle items and place them
        # on the Stone Table before dialogue opens.
        self._check_puzzle_items(caller)
        return False  # Proceed to dialogue

Note: Choosing an alternate dialogue strategy (e.g. teach-back) belongs in NPC.get_dialogue_strategy(caller), not in at_pre_dialogue.

Full Resolution Order

  1. BoardGameObject — dedicated branch (player-selection / challenge-OOB flow).
  2. InteractableMixin — generic at_interact(caller) dispatch (Telescope, L2RBeacon, MendableBoat, etc.). No dialogue.
  3. Non-NPC rejection — objects that are neither interactable nor NPCs get a "can't interact" message.
  4. at_pre_dialogue hook — NPC mutation/inspection before dialogue. May suppress dialogue.
  5. Client dialogue-start messages — OOB messages sent to iOS/Cascade/Reader clients.
  6. DialogueSystemSelector.start_dialogue(caller, npc) — strategy resolution (custom → registry → fallback).

Narrator (Wisp) State Machine

The Narrator NPC ("Wisp") uses a state machine to manage its lifecycle during narrated storylines. The WispState class (typeclasses/npcs/narrator.py) replaces the previous simple narrator_mode boolean with explicit states and validated transitions.

States

State Value Description
WispState.IDLE "idle" In shared rooms, available for chat and storyline offers
WispState.FOLLOWING "following" Moving with player between rooms
WispState.NARRATING "narrating" Actively delivering story chunks
WispState.PAUSED "paused" Narrator paused, awaiting resume

All valid states are available as WispState.ALL.

State Property

The Narrator class exposes a state property that validates transitions and keeps the legacy db.narrator_mode boolean in sync for backward compatibility:

from typeclasses.npcs.narrator import Narrator, WispState

wisp = character.search("Wisp", global_search=True)

# Read current state
wisp.state          # -> "idle"

# Set state (validated — invalid values are logged and rejected)
wisp.state = WispState.NARRATING

# Legacy attribute still works (derived from state)
wisp.db.narrator_mode  # -> True (when NARRATING or PAUSED)

The setter logs state transitions at INFO level: [NARRATOR] Wisp state: idle -> narrating.

State Transitions

State transitions are managed by NarratorModeManager (storybook/services/narrator_services/mode_manager.py):

Action Method State Change
Enter narration mode_manager.enter_narrator_mode() NARRATING
Exit narration mode_manager.exit_narrator_mode() IDLE
Pause narration mode_manager.pause_narration() PAUSED
Resume narration mode_manager.resume_narration() NARRATING (via enter)

ensure_idle()

The ensure_idle() method on Narrator provides a safe reset to IDLE. It cancels any active narration (delegating to mode_manager.exit_narrator_mode() if narrative data exists) and sets the state to IDLE. This is used when the player moves away from the narrator during an active narration:

# In PlayerCharacter.at_post_move — if player leaves during narration
wisp.ensure_idle()

Display Name

Wisp's display name reflects the current state:

State Display
IDLE or FOLLOWING Wisp
NARRATING Wisp (narrating)
PAUSED Wisp (paused)

Narrative Commands

Defined in commands/narrative_commands.py. These commands control pacing during narrated storylines and are available to all players (cmd:all()).

Command Aliases Usage
continue next, c Advance to the next narrative chunk
back previous, prev, b Return to the previous chunk
pause Toggle auto-advance on/off
resume continue story, play Resume from a saved position
leave story exit story, quit, leave Exit narration and return to main world
narrative narrative status, story, story status View narrative progress

Narrator Lookup

The _find_narrator(caller) helper locates the Narrator NPC for a character:

  1. Room contents (fast path) — checks caller.location.contents for an object with db.is_narrator = True.
  2. Global search — queries all Narrator typeclasses and matches by db.current_narrative.character. Only returns narrators whose state is NARRATING or PAUSED (i.e., actively engaged with the caller). If found remotely, the narrator is moved to the caller's room.

State Checks in Commands

All narrative commands that require an active narration check wisp.state against WispState values rather than the legacy db.narrator_mode boolean:

from typeclasses.npcs.narrator import WispState

# Commands check for active narration states
if wisp.state not in (WispState.NARRATING, WispState.PAUSED):
    caller.msg("The narrative isn't currently active.")
    return

The resume command checks state.get("narrator_mode") on the NarrativeState dict (not the Wisp attribute) to determine if the narrative is already active before attempting to resume.

Player Commands — Object Nicknames

Defined in commands/nickname.py (Issue #1066). Available to all players (cmd:all()). Lets a player assign a personal short handle to any game object — useful for distinguishing cantrip cartridges or frequently referenced items.

Command Aliases Usage
nickname callit nickname <name> = <item> — set a personal handle
nickname/clear nickname/clear <name> — remove a nickname
nickname nickname (no args) — list your nicknames

Nicknames are implemented as Evennia object nicks (category "object"), mapping the player's label to the target's dbref. Because caller.search() expands object nicks automatically, nicknames work transparently with commands that use it — including CmdInteract for NPC target resolution (e.g. interact buddy after nickname buddy = Pearl).

The inventory commands CmdGet and CmdPut (commands/inventory_enhanced.py) use manual matching in addition to caller.search(), so they expand nicks explicitly via a _denick() helper that calls caller.nicks.nickreplace(name, categories=("object",)).

nickname bluey = dampen cartridge
get bluey from looper
put bluey in looper

Each player keeps their own nicknames — other players are unaffected.

Admin Commands — Dialogue

Defined in commands/admin/dialogue_admin.py. All require perm(Builder) unless noted.

Command Aliases Usage
@load_dialogue @loadstory, @dialogue_load @load_dialogue <npc> = <dialogue_file>
@test_dialogue @testdialogue, @dialogue_test @test_dialogue <npc>
@clear_dialogue @cleardialogue, @dialogue_clear @clear_dialogue <target> or @clear_dialogue/all <npc>
@list_dialogues @listdialogues, @dialogues @list_dialogues [npc_name]
@dialoguestats @dialogue_stats @dialoguestats
@cleanupdialogue @cleanup_dialogue @cleanupdialogue [days] or @cleanupdialogue disconnected
@state_dialogue @dialoguestate, @dialogue_state @state_dialogue <player> [= type:name:value]
@reset_dialogue_state @resetdialoguestate, @dialogue_reset @reset_dialogue_state <player> [= <npc>]
@setconditional @setconditional <npc> = <on/off> (requires Admin)

Player Dialogue State

@state_dialogue exposes the PlayerDialogueState manager (utils/dialogue/state.py), which tracks per-player:

  • Flags — boolean dialogue flags (e.g. helped_pearl)
  • Counters — numeric counters (e.g. reputation)
  • Relationships — per-NPC relationship values
  • Quest stages — quest progress keyed by quest ID
  • Discovered topics — topics unlocked per NPC
  • Completed dialogues — list of finished dialogue IDs
@state_dialogue Alice = flag:helped_pearl:true
@state_dialogue Bob = counter:reputation:50
@state_dialogue Carol = relationship:pearl:25

Admin Commands — Knowledge Repositories

Defined in commands/admin/knowledge_repo.py. Manages personal knowledge repos for personalized vocabulary learning (Issue #523). COPPA-compliant — only vocabulary content is synced, player IDs are anonymized.

Command Aliases Lock Usage
@linkknowledge @linkk Builder @linkknowledge <player> = <gitlab_url> [; <repo_key>]
@unlinkknowledge @unlinkk Builder @unlinkknowledge <player> [= all]
@vocabstatus @vocab Player @vocabstatus [player]
@syncknowledge @synck Builder @syncknowledge <player> [= force]

Knowledge Repo Utilities (refactored)

As of this update, the utility functions extract_repo_key(), validate_gitlab_url(), and the SPYDER_API_URL constant have been extracted from knowledge_repo.py into a dedicated module:

commands/admin/knowledge_repo_utils.py

This refactor (Issue #523) resolves circular imports and Evennia initialization issues that occurred when Django URL patterns loaded during server startup. The functions are unchanged; only their import location has moved:

from commands.admin.knowledge_repo_utils import (
    SPYDER_API_URL,
    extract_repo_key,
    validate_gitlab_url,
)
  • SPYDER_API_URL — Resolved from NPC_API_SERVER env var (default: https://app.glassumbrella.io).
  • extract_repo_key(gitlab_url) — Extracts a short key from a GitLab URL (e.g. "beckett" from …/beckett-the-builder-knowledge-repo).
  • validate_gitlab_url(gitlab_url) — HEAD request to verify a GitLab repo is accessible. Uses GITLAB_URL_VALIDATION_TIMEOUT_SECONDS (5 s) from utils/knowledge_repo_constants.py.

Admin Commands — Lore Export

Defined in commands/admin/lore_export.py. All require perm(Admin) or perm(Developer).

These commands export game state from the Evennia DB back to lore repository files on disk. Changes are written locally; use git to review and commit.

Prerequisite: Lore repositories must be configured in LORE_REPOS in settings.py, and room-description frontmatter must exist for room mapping resolution (via build_room_mapping).

Command Aliases Usage
@exportobjects @objexport @exportobjects <repo_key> [--dry-run]
@exportnpcs @npcexport @exportnpcs <repo_key> [--dry-run]
@exportrooms @roomexport @exportrooms <repo_key> [--dry-run]
@export @exportlore, @loreexport @export <repo_key> [--dry-run] [--pull]

@exportobjects

Exports objects tagged with object_id to YAML. Placement is room-aware: objects in rooms with a known folder mapping go to locations/{folder}/objects/{id}.yaml; otherwise worldbuilding/objects/{id}.yaml. Existing YAML files are merged — known fields are overwritten while unknown sections (meta, etc.) are preserved.

@exportnpcs

Exports all BaseNPC subclass instances to worldbuilding/npcs/{faction}/{name}/{name}.json. Serializes cascade data (asset_id, position, rotation, scale) and room_id. Companion/familiar NPCs are written with cascade.spawn_mode = "companion" so the UE5 room content editor skips them. Existing JSON files are merged, preserving dialogue refs, custom_attributes, and meta sections.

@exportrooms

Exports rooms with cascade layout data (room_layout) to room-data.json files. Creates new files for rooms not yet in the repo; updates existing ones while preserving metadata (book, storyline, category, etc.).

@export (combined)

Runs all three exporters in sequence (objects → NPCs → rooms). Supports --pull to run git pull on the lore repo before exporting.

@export grit --dry-run    # Preview all changes
@export grit --pull       # Pull latest, then export

Error cases

  • Repository not found — If repo_key is not in LORE_REPOS, the command prints an error and lists available repos.
  • No room mappings — If no room-description.md frontmatter files are found, a warning is shown but export proceeds (objects go to worldbuilding/ fallback paths).
  • git pull failure@export --pull aborts if git pull fails (timeout, merge conflict, git not on PATH).

Admin Commands — L2R Artifact Regeneration

Defined in commands/admin/regen_l2r.py. Requires perm(Admin). Thin wrapper around utils.l2r_artifact_pipeline.regenerate — the pipeline does the heavy lifting (reading-level text adaptation, Spyder narration, S3 upload, lore-repo write-back); the command exposes it in-game for ad-hoc backfill or refresh. Issue #967, Stage 2.

Command Aliases Usage
@regen_l2r @regenl2r @regen_l2r <room_key> [<beat_id>] [<level>]

Switches:

Switch Effect
/force Regenerate even when text_hash matches the existing audio_artifacts entry (otherwise skipped as idempotent)
/dry Dry-run: report what would change without writing back to lore-repo or calling Spyder beyond text adaptation

Arguments:

  • room_key — lore-repo room directory (e.g. "old-persey")
  • beat_id (optional) — specific beat (e.g. "rivers-patience"); omit for all beats in the room
  • level (optional) — specific reading level: emergent, early, transitional, or fluent; omit for all four sub-advanced levels. advanced is intentionally not supported — canonical artifacts already serve it.
@regen_l2r old-persey                              # All beats, all 4 levels
@regen_l2r old-persey rivers-patience               # One beat, all 4 levels
@regen_l2r old-persey rivers-patience fluent         # One beat, one level
@regen_l2r/force old-persey rivers-patience          # Force even when text_hash matches
@regen_l2r/dry old-persey                            # Preview changes (no audio gen / write-back)

L2R Artifact Pipeline

The core pipeline (utils/l2r_artifact_pipeline.py) orchestrates per-(beat, level) artifact generation:

  1. Text adaptation — Computes the level-adapted text via ReadingLevelService (same code path room descriptions use).
  2. Idempotency check — SHA-256 hashes the adapted text; skips if the existing audio_artifacts[level].text_hash already matches (unless force=True).
  3. Spyder narration — Calls SpyderNarrationService.generate() which uploads audio to S3 and returns {audio_url, duration, alignment}. Alignment is stored inline (not as an S3 sibling) to avoid an HTTP round-trip at delivery time.
  4. Lore-repo write-back — Writes the updated audio_artifacts[level] block (text, audio_url, alignment, text_hash) back to worldbuilding/locations/{room_key}/l2r.yaml via ruamel.yaml round-trip mode (preserving comments and formatting). Busts the in-memory _l2r_cache so subsequent deliver_beat calls pick up the new artifacts.

The pipeline generates artifacts for four reading levels: emergent, early, transitional, and fluent. The advanced level is intentionally omitted — canonical narration is already at advanced level, so resolve_beat_artifacts falling back to canonical for advanced readers ships the correct bundle without duplicating S3 storage.

Batch Regeneration Script

For bulk backfill across many rooms, prefer the headless script at scripts/regen_l2r.py (invoked via evennia shell <):

# Local
evennia shell < scripts/regen_l2r.py

# Production via SSH
ssh -i "$PROD_SSH_KEY_PATH" "$PROD_SSH_USER@$PROD_SSH_HOST" \
    "cd $PROD_GAME_PATH && /home/ubuntu/.local/bin/evennia shell" \
    < scripts/regen_l2r.py

Configuration is via environment variables:

Variable Default Description
REGEN_ROOM_KEY all DEFAULT_ROOMS Specific room key, or omit to process all known L2R rooms
REGEN_BEAT_ID all beats Specific beat ID
REGEN_LEVEL all 4 sub-advanced Specific reading level
REGEN_FORCE false "1" / "true" to skip text_hash idempotency
REGEN_DRY_RUN false "1" / "true" to skip Spyder calls + lore-repo write

The script's DEFAULT_ROOMS list ("old-persey", "the-passion") defines rooms that currently ship L2R beats. When new rooms get L2R beats, they must be added to this list (or an auto-discovery pass implemented).

Error cases

  • No l2r.yaml — If get_l2r_beats(room_key) returns no data, the pipeline reports "no l2r.yaml for room" and returns an empty summary.
  • Beat not found — If beat_id is specified but not present in the room's l2r.yaml, the pipeline reports the error without processing other beats.
  • Unknown reading level — If level is not a valid ReadingLevel value, the pipeline returns immediately with an error.
  • Narration generation failure — Per-(beat, level) errors are captured and reported in the summary; the pipeline continues processing remaining beats/levels.
  • Write-back failure — If the lore-repo path cannot be resolved (e.g. LORE_REPOS not configured or repo not on disk), a RuntimeError is raised and reported. The admin must ensure the grit lore repository is registered in LORE_REPOS in settings.py and accessible on disk (or via the LORE_REPO_GRIT_PATH environment variable).

Data Files

Dialogue JSON files are stored under data/dialogues/ in TheSmithy. These files define passage-based conversations loaded via @load_dialogue and parsed by DialogueLoader / DialogueParser from utils/dialogue.

Lore Repository Dialogue Loading

NPCs can also load conditional dialogue directly from lore repositories at creation time or on demand, using get_content_path() from utils/lore_repo_config.py. This is an alternative to the @load_dialogue admin command — the NPC typeclass resolves the dialogue file path from a configured lore repo and stores the parsed JSON on its db attributes.

Pattern (as implemented by Arturo in typeclasses/npcs/arturo.py):

  1. _load_arturo_dialogue() calls get_content_path("worldbuilding/npcs/human/arturo/dialogue/conditional.json", repo="grit") to resolve the file from the grit lore repo.
  2. setup_conditional_dialogue() loads the data and sets db.conditional_dialogue, db.use_conditional_dialogue, and db.dialogue_enabled on the NPC.
  3. at_object_creation() calls setup_conditional_dialogue() so dialogue is loaded when the NPC is first created.
  4. setup_conditional_dialogue() can also be called on existing NPC instances to refresh dialogue from the lore repo without recreating the object.

If the lore repo path cannot be resolved or the JSON fails to load, the NPC logs a warning and falls back to legacy dialogue.

Prerequisites: The target lore repository must be registered in LORE_REPOS in settings.py and accessible on disk (or via the LORE_REPO_<KEY>_PATH environment variable). See utils/lore_repo_config.py for path resolution details.

See Also

  • typeclasses/npcs/narrator.pyNarrator class and WispState state machine
  • commands/narrative_commands.py — Player-facing narrative pacing commands
  • commands/interact.pyCmdInteract interaction dispatch and pre-dialogue hooks
  • commands/nickname.pyCmdNickname personal object handles; nicks are expanded by caller.search() (used by CmdInteract for NPC target resolution) and by _denick() in inventory commands (#1066)
  • storybook/services/narrator_services/mode_manager.pyNarratorModeManager lifecycle service
  • storybook/services/narrator_services/scene_navigator.py — Scene transition handling
  • typeclasses/characters/player.pyPlayerCharacter.at_post_move uses ensure_idle() on narrator when player moves away
  • typeclasses/mixins/interactable.pyInteractableMixin contract for interact-routable typeclasses (Telescope, L2RBeacon, MendableBoat, etc.)
  • dialogue/strategies/teach_back_strategy.pyTeachBackDialogueStrategy for teach-back prompt delivery via TeachBackOrchestrator
  • utils/dialogue/state.pyPlayerDialogueState manager
  • utils/dialogue/cleanup.py — Session cleanup and stats utilities
  • utils/dialogue/npc_integration.pyStructuredDialogueMixin for dynamic NPC dialogue support
  • utils/lore_repo_config.py — Lore repository path resolution and validation
  • utils/knowledge_repo_constants.py — Constants for quiz API, URL validation, and answer evaluation thresholds
  • commands/room_sync.pyCmdGetRoomCluster room-cluster data for the UE5 client; conditional exits (those with non-all() view locks) are included in the exits array with "hidden": true and an asset_config containing "start_hidden": "true" and "visibility_mode": "none", delegating client-side hiding to SpawnExitPortal via ParseVisibilityFromConfig
  • typeclasses/objects/l2r_beacon.pyL2RBeacon world-anchored narrative delivery; crossing completion sends exit_revealed with asset_config via delayed OOB (reactor.callLater)
  • commands/admin/regen_l2r.pyCmdRegenL2R admin command for in-game L2R artifact regeneration
  • scripts/regen_l2r.py — Headless batch script for bulk L2R artifact regeneration via evennia shell
  • utils/l2r_artifact_pipeline.py — L2R artifact pipeline: text adaptation, Spyder narration, S3 upload, lore-repo write-back
  • utils/l2r_service.py — L2R beat delivery service; resolve_beat_artifacts resolves per-level audio/text/alignment bundles produced by the pipeline

Feature Repositories

  • TheSmithy → project_id: the-smithy1/TheSmithy

Code Paths to Explore

  • data/dialogues/** in TheSmithy
  • commands/** in TheSmithy
  • dialogue/** in TheSmithy