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:
- loads the NPC's conversation tree
- Conversation state set:
currentConversationNpcId= npcId,conversationDepth= 0 conversationPathinitialized as empty listcachedCurrentNodeset to root nodeconversationOptionPageset to 0- 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:
- Track path: Option index appended to
conversationPath - Increment depth:
conversationDepth += 1 - Reset page:
conversationOptionPage = 0 - Check clue reveal: If
option.revealsClueId != null, clue discovered and Guild Wars contribution fired - Navigate tree:
- If
nextNode != null: Cache new node, present it with voice tag - If
nextNode == null: Conversation ends, NPC marked as interviewed
- If
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.
Page Navigation
| 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):
currentConversationNpcIdset tonullconversationDepthclearedconversationPathcleared- Transient caches invalidated
- NPC added to
npcsInterviewedlist (if not already) conversationsCompletedincremented
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 tagsnpcText: 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:
- A conversation ends (reaches a leaf node with
nextNode == null) - Player explicitly exits (
exitConversation()) - Depth limit reached (
MAX_CONVERSATION_DEPTH = 50) - No available options after clue filtering
Tracked in MysteryPlayerProgress.npcsInterviewed: List<String> (list of npcId strings).
Unrecognized Input Handling
Navigation Break-Out
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:
- First: Try text-to-option matching (handles players who echo option text instead of saying a number)
- Second: Check for navigation commands — break out of conversation silently (see above)
- 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 |
Character-NPC Link
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);