Skip to main content

NPCs

NPCs

Status: COMPLETE — Full design documented

Last updated: October 27, 2026

Mystery -- NPC & Conversation System

Status: COMPLETE — Full design documented

Source: , , , , , ,

This document covers NPC conversation trees, the recursive dialog system, clue-gated progressive disclosure, NPC resolution/proximity, pagination, and the character model extracted from the actual Java source code.


NPC Data Model

Conversation Tree

Source:

Each NPC has a single conversation tree rooted at rootNode:

Field Type Purpose
npcId String Unique NPC identifier (e.g., "BUTLER_JENKINS", "LADY_HARRINGTON")
scenarioId Long Scenario this NPC belongs to
npcName String Display name (e.g., "Jenkins")
voiceTag String Alexa SSML voice (options: joey, matthew, kendra, brian)
rootNode MysteryConversationNode Entry point of recursive tree

Conversation Node (Recursive)

Source:

MysteryConversationNode
  |-- description: String     (scene description, NO voice tags)
  |-- npcText: String         (what NPC says, WITH voice tags)
  |-- options: List<MysteryConversationOption>   (player choices)
       |-- each option has nextNode: MysteryConversationNode  (RECURSIVE)
Field Type Purpose
description String Physical/scene description text (no voice tags). Example: "The butler's expression grows somber."
npcText String What the NPC says, with voice tags for TTS. Example: "voicetagmatthew Lord Harrington was a good man. voicetagend"
options List<MysteryConversationOption> Player response choices (max 4 recommended for voice UI)

Conversation Option

Source:

Field Type Default Purpose
playerText String -- Text the player says (1-4 words for voice). Example: "ask about victim"
alternatePhrases List<String> -- Voice recognition alternates. Example: ["tell me about lord harrington", "the victim"]
nextNode MysteryConversationNode null Next node (recursive). If null, conversation ends.
revealsClueId Long null Clue discovered when this option is chosen
requiredClueIds List<Long> null ALL required clues to see this option (AND logic)
reward String null JSON reward string. Example: {"gold": 50, "experience": 25}
levelRequired Integer null Level needed to see this option

Character Model

Source:

Field Type Purpose
characterId Long Unique character identifier
scenarioId Long Scenario this character belongs to
characterName String Display name (e.g., "Lord Percival")
characterTitle String Title/role (e.g., "The Butler")
description String Character description
personalityTraits List<String> Trait labels stored as JSON array; legacy Map shapes tolerated by PersonalityTraitsDeserializer
relationshipToVictim String Relationship description
alibi String Character's alibi text
isVictim Boolean True if this character is the victim
isMurderer Boolean True if this character is the killer
isQuestGiver Boolean True if this character hired the detective
npcId String Links to MysteryNPCConversationTree.npcId (if interviewable)
displayOrder Integer UI ordering of characters
characterPortraitUrl String Portrait image URL
conversationTreeRootId Long DEPRECATED -- use npcId instead

Conversation Flow

Initiating a Conversation

Source:

Two modes of initiation:

1. No NPC specified ("talk", "speak"):

  • Checks current square for conversationNpcId
  • If no NPC present: "There is no one here to talk to."

2. NPC specified by name ("talk to Jenkins"):

  • NPC name resolved through 4-pass fuzzy matching
  • Proximity check (current square + 4 adjacent squares)
  • If NPC not nearby: "{npcName} is not nearby. You need to find them first."

NPC Name Resolution

Source:

Four-pass matching, first match wins:

Pass 1: Exact name match       (tree.getNpcName().toLowerCase().equals(searchName))
Pass 2: Exact ID match         (tree.getNpcId().toLowerCase().equals(searchName))
Pass 3: Name starts-with       (tree.getNpcName().toLowerCase().startsWith(searchName))
Pass 4: Contains (fuzzy)       (name.contains(searchName) || npcId.contains(searchName))

This means "jen" would match "Jenkins" in Pass 3, and "butler" would match "BUTLER_JENKINS" in Pass 4.

NPC Proximity Check

Source:

Player can talk to NPCs at their current square OR any of the 4 adjacent squares:

// Check current square
if (currentSquare != null && npcId.equals(currentSquare.getConversationNpcId())) {
    return true;
}

// Check adjacent squares (N, S, E, W)
final int[][] adjacentOffsets = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
for (final int[] offset : adjacentOffsets) {
    final String adjCoords = (currentX + offset[0]) + "," + (currentY + offset[1]);
    final MysteryZoneSquare adjSquare = zone.getZoneSquares().get(adjCoords);
    if (adjSquare != null && npcId.equals(adjSquare.getConversationNpcId())) {
        return true;
    }
}

Effective range: 1 square in any cardinal direction (5-square diamond pattern).

Conversation Initialization

Source:

On starting a conversation:

  1. loads the NPC's conversation tree
  2. Conversation state set: currentConversationNpcId = npcId, conversationDepth = 0
  3. conversationPath initialized as empty list
  4. cachedCurrentNode set to root node
  5. conversationOptionPage set to 0
  6. Root node presented with options

Conversation Navigation

Constants

// 
private static final int MAX_CONVERSATION_DEPTH = 50;
private static final int OPTIONS_PER_PAGE = 3;

Option Selection

Source:

Depth Limit Check

// M8 fix - check conversation depth limit
if (currentDepth >= MAX_CONVERSATION_DEPTH) {
    // "This conversation has gone on for quite a while. You should wrap it up."
    // "The conversation ends."
    // Clear conversation state
}

Option Number Resolution

Source:

Five strategies attempted in order:

Strategy Input Example Resolution
1. Parse basicValue as number "2" Direct numeric
2. Use optionalNumber from slot N/A (voice slot entity) Numeric from Alexa slot
3. Extract number from originalText "option 2 please" -> "2" Regex extraction
4. Text-match basicValue against options "ask about victim" Bidirectional contains on playerText and alternatePhrases
5. Text-match originalText as fallback "ask about victim" (when basicValue was transformed by NLU) Same bidirectional match but using raw input text

Text matching (Strategies 4 & 5) only matches against the current page of options. Both playerText and alternatePhrases use bidirectional contains for consistent free-text matching:

// .tryTextMatchOption()
final int startIdx = currentPage * OPTIONS_PER_PAGE;
final int endIdx = Math.min(startIdx + OPTIONS_PER_PAGE, availableOptions.size());

for (int i = startIdx; i < endIdx; i++) {
    // Bidirectional match against player text: option contains input OR input contains option
    final String optionText = option.getPlayerText().toLowerCase();
    if (optionText.contains(inputText) || inputText.contains(optionText)) {
        return (i - startIdx) + 1;  // Page-relative 1-based index
    }
    // Match against alternate phrases (bidirectional contains, consistent with playerText matching)
    for (String phrase : option.getAlternatePhrases()) {
        final String phraseLower = phrase.toLowerCase();
        if (phraseLower.contains(inputText) || inputText.contains(phraseLower)) {
            return (i - startIdx) + 1;
        }
    }
}

Strategy 5 handles the case where NLU or Alexa transforms the player's raw input (e.g., canonicalising a slot value), causing basicValue to differ from the original typed/spoken text. Falling back to originalText when basicValue didn't match catches these edge cases:

// .resolveOptionNumber()
// Strategy 5: Text-match using originalText (handles cases where basicValue is transformed)
final String origText = storyKey.getOriginalText();
if (origText != null && !origText.isEmpty() && !origText.equalsIgnoreCase(basicValue)) {
    return tryTextMatchOption(progress, origText.toLowerCase().trim());
}

Processing Selected Option

Source:

When an option is selected:

  1. Track path: Option index appended to conversationPath
  2. Increment depth: conversationDepth += 1
  3. Reset page: conversationOptionPage = 0
  4. Check clue reveal: If option.revealsClueId != null, clue discovered and Guild Wars contribution fired
  5. Navigate tree:
    • If nextNode != null: Cache new node, present it with voice tag
    • If nextNode == null: Conversation ends, NPC marked as interviewed

Clue-Gated Options (Progressive Disclosure)

Source:

Before displaying options, they are filtered by the player's discovered clues:

// .hasRequiredClues()
// No requirements = always visible
if (option.getRequiredClueIds() == null || option.getRequiredClueIds().isEmpty()) {
    return true;
}
// Player must have ALL required clues (AND logic)
return playerClues != null && playerClues.containsAll(option.getRequiredClueIds());

Design effect: Returning to an NPC after discovering new clues can unlock previously hidden conversation branches. This creates the progressive interrogation mechanic where evidence gathered from one NPC unlocks deeper questioning of another.

Example: An option with requiredClueIds: [28, 29] only appears after the player has discovered BOTH clue 28 AND clue 29.


Pagination

Conversation option paging follows the CANONICAL pattern in — canonical MORE/PREVIOUS vocabulary, real navigation options, boundary messages, no silent wrap. The transient conversationOptionPage domain field is the documented page-state exception (the option content changes between turns; the page resets on node change).

Option Paging

Source:

When a conversation node has more than OPTIONS_PER_PAGE (3) available options, pagination activates. Options speak and register their ABSOLUTE 1-based index in the clue-filtered list (page two reads "4: ..., 5: ..."), so a spoken number and the registered option key always agree, and the index base matches Darkness Falls through the shared BASELINE_CONVERSATION_OPTION action — processConversationOption consumes the number directly with no page re-offset math.

"more" / "previous" are real options on the conversation action (keys CONV_PAGE_<n>, synonyms from OptionSelector.MORE_SYNONYMS / PREVIOUS_SYNONYMS), not just spoken hints.

Command Action Source Method
CONV_PAGE_<n> option (More / Previous, canonical synonyms) Jump to page n conversationOption() page intercept
wrapper NEXT / PREVIOUS Increment / decrement with boundary message nextConversationOptions() / prevConversationOptions()

At the boundaries the handler says "You are already on the first/last page." and re-renders — never a silent wrap, never a zero-option card.

Back-to-Questions Affordance

The conversation tree is recursive: choosing a topic descends into that topic's sub-questions, so the parent topic's peer questions drop off the option menu as the player goes deeper. To keep those peer topics reachable without ending and re-starting the conversation, presentConversationNode surfaces a discoverable "back to questions" option (action BASELINE_CONVERSATION_BACK) whenever the player is below the root (conversationPath non-empty). Selecting it pops one level via conversationBack(), restoring the parent node's options. The recursive descent model itself is unchanged — this is a navigation aid, not a flattening of the tree.

The questions not yet asked are named, and the witness is not greeted twice (). Each question a player asks a witness is marked in the per-player use counts (MYSTERY_ASKED_<npcId>_<question>, cleared when a case begins), so no field is added to the Kryo-cached progress. Then:

Where the player is What is said
An answer with nothing further, under questions not yet asked The answer, then "Ask: where were you during the murder, or say goodbye." Each named question is an option (key CONV_PARENT_<n>) and is routed by its words too, so asking it costs one turn, not back then the question.
Back to the questions, or a talk to a witness met before "Diane waits. Ask: did he trouble you, or say goodbye.": the questions not yet asked, without the scene description or the greeting, which belong to the first talk. Every question on the page stays selectable.
Every opening question asked The interview closes in the witness's own farewell, the reply to the tree's goodbye choice, or "Diane has told you everything." when the tree has none.

"Back to questions" also answers to "ask about something else", "something else" and "other topics" (BACK_TO_QUESTIONS_SYNONYMS), the words the prompt itself uses.

Where Were You, In The Player's Words

The commonest unmatched phrase inside a conversation is a player asking the witness where they were. Before a miss is answered, an alibi phrasing ("where were you", "your alibi", "what did you see", "did you notice anything", "that night", "this morning") asks the choice on this node, the one above or the opening node whose words or phrases name where, alibi, see, notice, that night or this morning. When the tree has no such choice, the witness answers from the case's CHARACTER row's alibi ("Jenkins answers. I was in the pantry all evening."), so the question is never a fallback.

Page Overflow Handling

A stale persisted page past the end of the (clue-filter-shrunk) option list clamps to the last valid page and re-renders — state repair is silent, but player-driven past-end navigation gets the boundary message.

Transient Cache Invalidation

Source:

public void invalidateTransientCache() {
    this.cachedCurrentNode = null;
    this.currentFilteredOptions = null;
    this.conversationOptionPage = null;
}

Called after deserialization, conversation path changes, or state reset. Ensures derived state is recalculated from persisted data.

Conversation Clearing

When a conversation ends (explicitly or implicitly):

  • currentConversationNpcId set to null
  • conversationDepth cleared
  • conversationPath cleared
  • Transient caches invalidated
  • NPC added to npcsInterviewed list (if not already)
  • conversationsCompleted incremented

Implicit Conversation Clearing

Conversations are automatically ended when the player:

  • Moves to a new square (any direction)
  • Enters a new zone
  • Goes back to parent zone
// .move()
if (progress.getCurrentConversationNpcId() != null) {
    mysteryAccountService.clearConversationState(progress);
    gameBasicProperties.clearStoryOptions();
}

Voice Tags

Voice Tag Format

NPC dialog uses custom voice tags for Alexa SSML TTS rendering:

voicetagmatthew I was in the kitchen all evening. voicetagend

Available voice tags: voicetagjoey, voicetagmatthew, voicetagkendra, voicetagbrian

Voice Tag Stripping

For text display (non-TTS), voice tags are stripped:

// .presentConversationNode()
final String cleanText = .stripVoiceTags(npcText);

Voice Tag Separation

The data model separates tagged and untagged text:

  • description: Scene/physical description -- never contains voice tags
  • npcText: What the NPC says -- contains voice tags for TTS

NPC Presence Detection

Automatic Announcement

Source:

When a player enters a square with an NPC, the NPC is announced:

Encounter Type Message
First encounter "{npcName} is here. Say 'talk' to speak with them."
Already interviewed "{npcName} is here." (no talk prompt -- reduced verbosity)

NPC Interviewed Tracking

NPCs are marked as interviewed when:

  1. A conversation ends (reaches a leaf node with nextNode == null)
  2. Player explicitly exits (exitConversation())
  3. Depth limit reached (MAX_CONVERSATION_DEPTH = 50)
  4. No available options after clue filtering

Tracked in MysteryPlayerProgress.npcsInterviewed: List<String> (list of npcId strings).


Unrecognized Input Handling

Source:

If the player types a navigation command while in a conversation (e.g., "north", "go east", "back"), the conversation is automatically ended and the navigation handler takes over — no explicit "leave conversation" command is needed:

//  — NAVIGATION_WORDS set
private static final Set<String> NAVIGATION_WORDS = Set.of(
    "north", "south", "east", "west", "n", "s", "e", "w", "back", "enter", "go", "move", "walk", "run", "head", "travel");

// handleUnrecognizedInConversation()
if (isNavigationCommand(rawInput)) {
    mysteryAccountService.addNPCInterviewed(progress, progress.getCurrentConversationNpcId());
    mysteryAccountService.clearConversationState(progress);
    gameBasicProperties.clearStoryOptions();
    addText(gameBasicProperties, "You step away from the conversation. ");
    return null; // Signal to caller to delegate to navigation handler
}

The null return value signals the calling handler to forward the command to the navigation system. The NPC is marked as interviewed before clearing.

In-Conversation Fallback

Source:

When the player types something unrecognized while in a conversation:

  1. First: Try text-to-option matching (handles players who echo option text instead of saying a number)
  2. Second: Check for navigation commands — break out of conversation silently (see above)
  3. If no match: Show context-aware error:
    "You are currently in conversation with {npcName}."
    "Choose a numbered option or say 'back' to leave the conversation."

This replaces the generic "I didn't understand that" fallback with conversation-specific guidance.

Post-Conversation Context

Source:

After any conversation ends, players receive contextual guidance:

Clue Count Message
>= 3 "You have {N} clues. You can explore further, talk to other suspects, or say 'accuse' to make your accusation."
< 3 "You have {N} of 3 clues needed. Keep exploring and talking to suspects."

NPC Caching

Entity Cache Service TTL Pattern
Conversation trees 3 hours Per-NPC by npcId
Characters 3 hours Per-scenario

Character Roles

Role Types

Characters serve different roles determined by boolean flags:

Role Flag Purpose
Victim isVictim == true The murdered character
Murderer isMurderer == true The guilty party (solution)
Quest Giver isQuestGiver == true Hired the detective, receives final accusation
Suspect none of the above Regular suspect/witness with conversation tree

Characters with npcId != null are interviewable -- they have a corresponding MysteryNPCConversationTree entry. Characters without an npcId exist only in the accusation suspect list (e.g., the victim).

Confrontation Behavior by Role

Source:

NPCs react differently to confrontation based on whether their linked character is the murderer:

final boolean isMurderer = mysteryCharacterCacheService.getCharactersByScenario(scenarioId)
    .stream()
    .filter(c -> npcId.equals(c.getNpcId()))
    .anyMatch(c -> Boolean.TRUE.equals(c.getIsMurderer()));

// The reaction also reads whether the clue points at this suspect, and leads with the
// suspect's first personality trait: scoring.md § Confrontation System has the four lines.
.confrontationReaction(name, firstTrait, clueName, isMurderer, isRelatedNpc);