Skip to main content

Investigation

Investigation

Status: COMPLETE — Full design documented

Last updated: October 27, 2026

Mystery -- Investigation & Evidence System

Status: COMPLETE — Full design documented

Source: , , , , , ,

This document covers zone navigation, evidence collection, exploration commands, clue types, the badge system, and blocked path mechanics extracted from the actual Java source code.


Zone Architecture

Zone Model

Source:

Each zone is a coordinate-grid area (room, garden, corridor) with:

Field Type Purpose
zoneRef String Unique identifier (e.g., "MANSION_STUDY", "AM_SERVANTS_QUARTERS")
scenarioId Long Which scenario this zone belongs to
zoneName String Display name (e.g., "The Study")
zoneDescription String Atmospheric text shown on zone entry
zoneSize Integer Grid dimension (e.g., 5 for 5x5)
zoneSquares Map<String, MysteryZoneSquare> Coordinate-indexed squares ("x,y" keys)
parentZoneRef String Parent zone for "go back" navigation
parentZoneX, parentZoneY Integer Return coordinates in parent zone
audioKey String Background ambience audio key (optional)
zoneLevel Integer Recommended difficulty level (optional)

Zone Square Model

Source:

Each square within a zone contains:

Field Type Purpose
xCoordinate, yCoordinate Integer Grid position
squareDescription String First-visit description text
squarePermanentDescription String Shorter revisit text
conversationNpcId String NPC stationed here (if any)
clueIds List<Long> Discoverable clues at this location
zoneRefToClone String Zone transition exit (links to another zone)
zoneXToEnter, zoneYToEnter Integer Entry coordinates in target zone
isBlocked Boolean Impassable (walls, locked doors)
requiredClueId Long Clue needed to unlock blocked path

Zone Hierarchy

Zones form a tree via parentZoneRef:

Root Zone (e.g., MANSION_ENTRANCE_HALL, parentZoneRef=null)
  |-- Sub-zone A (parentZoneRef -> Root)
  |-- Sub-zone B (parentZoneRef -> Root)
       |-- Sub-sub-zone C (parentZoneRef -> B)

Starting zone resolution (priority order):

  1. Scenario's startingZoneRef field
  2. Root zone for scenario (zone with parentZoneRef == null)
  3. Fallback: "MANSION_ENTRANCE_HALL"

Default starting coordinates: (2, 2).

Zone Caching

Source:

Property Value
Storage Redis
TTL 3 hours
Fallback MySQL on cache miss
Pattern Per-zone caching by zoneRef

Coordinate System

The coordinate system uses standard Cartesian with Y-axis pointing up:

// .calculateTargetX/Y()
"north" -> currentY + 1   // Y increases going north
"south" -> currentY - 1   // Y decreases going south
"east"  -> currentX + 1   // X increases going east
"west"  -> currentX - 1   // X decreases going west

Source:

Type Input Examples Resolution
Directional "go north", "north", "head east" Direct coordinate offset
Named room "go to library", "enter study" Fuzzy match against zone names in current zone's exits
Go back "go back", "back" Return to parent zone at parentZoneX/Y
Enter zone "enter", "enter [room]" Explicit zone transition from current/named exit square

Named-room phrasing variants: registerNamedDestinationSynonyms also registers a numeral form (ordinal words become digits, so "First Class Lounge" matches "1st class lounge") and a split-compound form (closed compounds split, so "Stateroom A" matches "state room a") of each destination name via destinationNameVariants, so common spoken variants resolve instead of falling to NLU fallback.

Room words and motion phrases: every destination option (the hidden per-destination options and the visible "Enter X" option on the entry square) takes its synonyms from one builder, destinationSynonyms. It adds each word of the room's name that no nearby room shares, so on the Bakery Floor the Bakery Kitchen answers to "kitchen" while "bakery", which both rooms carry, picks neither. Every form is registered bare and behind the motion phrases players use: "go to the", "walk to the", "head to the", "enter the", "take me to the". Direction and movement words never stand alone for a room, so "go north" keeps moving north even beside a North Wing.

Direction Normalization

// .normalizeDirection()
// Strips these prefixes before matching:
final String[] prefixes = {"go ", "head ", "travel ", "move ", "walk ", "run "};

So "go north", "head north", "walk north", "run north" all resolve to "north".

Valid Directions

// 
private static final Set<String> VALID_DIRECTIONS = Set.of(
    "north", "south", "east", "west", "back"
);

Movement Processing

Source:

On each successful move:

  1. Conversation state cleared (if in active conversation)
  2. Player position updated (currentZoneX, currentZoneY)
  3. totalMovements incremented by 1
  4. Square description displayed
  5. Automatic clue discovery for all clues at the new square
  6. Automatic NPC detection if an NPC is present
  7. Zone entry option displayed if the square has a zoneRefToClone
  8. Available exits shown from new position

Named Room Navigation

Source:

When a direction doesn't match cardinal directions, the handler searches for named rooms:

  1. Peel the motion phrase and leading article off the utterance and normalize it the way content names are (spokenDestination, MysteryTextMatching.normalizeForClueMatching), so "walk to the kitchen" searches for "kitchen"
  2. Collect the destinations: every square in the current zone with a zoneRefToClone, plus the parent zone for "go back" style navigation
  3. Match in two passes, exact name first and then a partial name through the shared MysteryTextMatching.namesLikelyMatch (either name contains the other, or every meaningful word of the shorter is a word of the longer)
  4. Within a pass, a reachable room wins over the parent, and both win over the room the player stands in. Standing in The Bakery, "bakery kitchen" enters the kitchen; it does not report "already in The Bakery" because the words overlap
  5. If no match: display available destinations list, naming what the player said without the motion phrase

Zone Transition

Source:

Zone transitions occur when entering a square with zoneRefToClone:

  1. Target zone loaded from cache
  2. Circular reference detection: prevents entering current zone
  3. Entry coordinate validation: verifies target square exists
  4. Conversation state cleared
  5. Zone reference, coordinates updated
  6. Zone visit tracked
  7. totalMovements incremented
  8. Zone description + square description displayed
  9. Automatic clue discovery at entry point
  10. Automatic NPC detection at entry point

Circular navigation detection (goBack):

// .goBack()
if (parentZone.getParentZoneRef() != null
    && parentZone.getParentZoneRef().equals(currentZone.getZoneRef())) {
    // Circular zone reference detected: A <-> B
    // "Navigation issue detected. Try using directional commands."
}

Explicit Entry System

Zone transitions are not automatic. When a player moves to a square with a zone exit:

  1. The exit is announced: "You can enter {zoneName} from here."
  2. Exit info stored in progress: availableZoneEntryRef, availableZoneEntryX/Y
  3. Player must explicitly say "enter" to transition
  4. Exit info cleared when player moves to a different square

Blocked Paths

Blocking Mechanics

Source:

Squares with isBlocked == true are impassable unless:

  1. The player has previously unlocked the square, OR
  2. The player possesses the requiredClueId

Per-Player Unlock Tracking

Unlocked squares are tracked per-player in MysteryPlayerProgress.unlockedSquares:

// .handleNavigationCommand()
// Format: "ZONE_REF:x,y" -> true
final String squareUnlockKey = progress.getCurrentZoneRef() + ":" + targetX + "," + targetY;

// Check player's personal unlock list first
if (progress.getUnlockedSquares() != null
    && Boolean.TRUE.equals(progress.getUnlockedSquares().get(squareUnlockKey))) {
    // Already unlocked - allow passage
} else if (targetSquare.getRequiredClueId() != null) {
    if (progress.getCluesFound() != null
        && progress.getCluesFound().contains(targetSquare.getRequiredClueId())) {
        // Unlock and record in player's progress (NOT in shared zone data)
        mysteryAccountService.addUnlockedSquare(progress, squareUnlockKey);
    } else {
        // "This path is blocked. You need to find a way to unlock it."
    }
} else {
    // No clue can unlock this - permanently blocked
    // "This path is blocked."
}

Design rationale: Unlock state is stored in MysteryPlayerProgress.unlockedSquares (player-specific) rather than modifying the shared MysteryZoneSquare.isBlocked field. This prevents one player's unlock from affecting other players and avoids shared zone state corruption.


Exploration Commands

Look

Source:

Provides full area description:

  1. Current square's squareDescription (or squarePermanentDescription if first-visit text is null)
  2. Falls back to zone name if both are null
  3. NPC presence announcement (if NPC at location)
  4. Zone entry option (if exit square leads to another zone)
  5. Available exits (cardinal directions)

Exit detection checks for adjacent squares in all 4 cardinal directions:

// North: zone.getZoneSquares().containsKey(currentX + "," + (currentY + 1))
// South: zone.getZoneSquares().containsKey(currentX + "," + (currentY - 1))
// East:  zone.getZoneSquares().containsKey((currentX + 1) + "," + currentY)
// West:  zone.getZoneSquares().containsKey((currentX - 1) + "," + currentY)

Source:

Names the way to the nearest undiscovered clue and the word to say, nearest first ():

Where the nearest undiscovered clue is What the search says
This square "You search. Something right here deserves a closer look. Say examine."
A square next to this one That square's own squarePermanentDescription and its direction: "You search. To the south, Margaret's coffee cup still sits beside the till. Say south." (a square with no description: "Something to the south catches your eye. Say south.")
Further away in this room "You search. Nothing here, but the trail leads north. Say north."
Another room of the case "You search. This room is searched. Bakery Kitchen still holds something."
Nowhere: every clue is found "You search. Every clue in this case is already in your notes. Say accuse when you are ready."

A search never ends on "try another area": a player who heard that and could not tell where to go heard it 135 times in one case. A player who has walked ten moves without a clue is offered the tiered hints once, in a region that can buy them; a search with every clue found offers none.

Important: Search does NOT discover clues. It only provides atmospheric hints about where undiscovered clues are. Clues are discovered by:

  • Moving to a square that has clueIds (automatic)
  • Using the examine command on a matching object
  • Choosing a conversation option with revealsClueId
  • Using an investigation badge

Narrative Direction Cues (movement + look)

Source: (called from move() and look())

To reduce blind empty-grid wandering — the dominant Mystery early-session weakness — every successful move and every look emits ONE short narrative cue pulling the player toward the nearest adjacent square that still holds progress. The directional option buttons say WHERE the player can go; the cue gives a reason to pick one instead of guessing into empty space.

Adjacent content (scanned N, E, S, W) Cue text
Undiscovered clue (priority) "Something to the {direction} seems worth a closer look."
Un-interviewed NPC (fallback) "You hear voices to the {direction}. Someone there may know something."
Neither in any adjacent square (no cue — a genuinely empty stretch reads clean, not chatty)

Rules:

  • An undiscovered clue outranks an NPC; at most ONE cue is spoken per turn so the response stays short for voice (per reference_voice_response_size_drives_alexa_render_lag).
  • "Undiscovered" is checked against progress.getCluesFound() and "un-interviewed" against progress.getNpcsInterviewed(), so the pull naturally fades as the player clears the area.
  • Only the four cardinal neighbours of the current square are scanned — the cue points to an adjacent square the player can reach in one move, complementing (not duplicating) the search hints, which also scan the current square, and the zone-entry addZoneNpcHints ("voices elsewhere in this area").

Examine

Source:

Inspects a specific named object at the current location, or the spot itself:

  • A bare examine (no object, or "it", "here", "closer") studies the spot the player stands on and discovers its first undiscovered clue. Search says something is right here without naming it, and closes with "Say examine to look closer here, or say a direction to move on", so the bare word must work.
  • A named object that matches nothing says so, and when this spot still holds an undiscovered clue adds "Say examine to study it".
  1. Extract object name from player input
  2. Normalize using normalizeForClueMatching() (strip possessives, lowercase, remove punctuation)
  3. Check current square's clueIds for fuzzy name match
  4. If clue match found:
    • Display clue name and description
    • If not already discovered: discover the clue and add to player's notebook
    • If already discovered: "You already have this clue in your notes."
  5. If no clue match, check for NPC name match: "Say 'talk' to speak with them."
  6. If nothing matches: "You don't see anything called '{name}' here."

Fuzzy Matching (Clue Name Normalization)

Source: MysteryTextMatching.normalizeForClueMatching() (shared by examine and confront)

text.toLowerCase()
    .replaceAll("'s\\b", "")               // Strip possessives: "Lord's" -> "Lord"
    .replaceAll("[^a-z0-9\\s]", " ")       // Remove non-alphanumeric
    .replaceAll("\\s+", " ")               // Collapse whitespace
    .trim()
    .replaceAll("(?<=[a-z]{3,})s\\b", "")  // "cashiers account" -> "cashier account"

The trailing-s strip exists for voice input: ASR drops apostrophes, so a spoken possessive arrives without one and the 's rule cannot catch it. Applied to BOTH sides of the comparison, so it is symmetric.

Matching is bidirectional: normalizedClueName.contains(searchTerm) || searchTerm.contains(normalizedClueName).


Evidence Model

Clue Model

Source:

Field Type Default Purpose
clueId Long -- Unique identifier
scenarioId Long -- Scenario this clue belongs to
clueName String -- Display name
clueDescription String -- Detailed description
clueCategory MysteryClueCategory -- One of 6 categories
pointsIfFound Integer -- Score value when discovered
implicatesCharacterId Long -- Links clue to a suspect
relatesToWeaponId Long -- Links clue to a weapon
isCritical Boolean -- Required for critical clue bonus (200 pts)
isConfrontable Boolean true Can be used in NPC confrontations
confrontationBonus Integer 50 Points for correct confrontation
confrontationPenalty Integer -25 Points for wrong confrontation
relatedNpcId String -- The "correct" NPC for confrontation validation

Clue Categories

Source:

Category Description
PHYSICAL Physical evidence (weapons, objects, fingerprints)
TESTIMONY NPC statements and interviews
MOTIVE Evidence of potential motives
TIMING Timeline and alibi information
LOCATION Location-related evidence
RELATIONSHIP Character relationship information

Weapon Model

Source:

Field Type Purpose
weaponId Long Unique identifier
scenarioId Long Scenario this weapon belongs to
weaponName String Display name
weaponDescription String Detailed description
isMurderWeapon Boolean True if this is the actual murder weapon
discoveryClueText String How/where it can be found
discoveryLocation String Where it can be found

Clue Discovery Methods

Clues can be discovered through 4 distinct mechanisms:

1. Automatic Discovery (Movement)

Source:

When a player moves to a square with clueIds, ALL undiscovered clues at that square are automatically discovered:

for (final Long clueId : clueIds) {
    if (progress.getCluesFound() != null && progress.getCluesFound().contains(clueId)) {
        continue; // Already found
    }
    mysteryAccountService.addClueFound(progress, clueId);
    // Display: "You discover a clue: {clueName}. {clueDescription}"
}

This fires on:

  • Regular directional movement to a new square
  • Zone transition (entry square clues)
  • Named room navigation

2. Examine Command (Targeted)

Source:

Player explicitly examines a named object. If it fuzzy-matches a clue at the current square, the clue is discovered. Allows targeted clue investigation vs. the automatic broad discovery.

3. NPC Conversation Reveal

Source:

Conversation options can have a revealsClueId field. When the player chooses that option, the clue is discovered:

if (option.getRevealsClueId() != null) {
    mysteryAccountService.addClueFound(progress, option.getRevealsClueId());
    // Display: "You learned something important: {clueName}."
}

4. Investigation Badge (Quick Action)

Source:

Players can spend 1 investigation badge to discover a random undiscovered clue:

  1. Check badge balance (cost: 1)
  2. Get all scenario clues, filter out already-found
  3. Pick random undiscovered clue (ThreadLocalRandom.current().nextInt(undiscoveredClues.size()))
  4. Subscriber bonus: 50% chance to keep badge (ThreadLocalRandom.current().nextBoolean())
  5. If not preserved: spend the badge
  6. Add clue to found list

Error states:

  • "You need 1 investigation badge to use this quick action. You currently have {N}."
  • "No clues found for this scenario."
  • "You have already discovered all clues in this scenario!"
  • "No scenario is active."

Progress Tracking

Visited Tracking

Three tracking granularities exist:

Tracker Storage Format Purpose
zonesVisited List<String> Zone ref strings Which zones player has been in (for exploration points)
zoneSquaresVisited Map<String, Boolean> "ZONE_REF:x,y" keys Per-square visitation
unlockedSquares Map<String, Boolean> "ZONE_REF:x,y" keys Per-player blocked path unlocks

Movement Counter

totalMovements is incremented on every successful move:

  • Directional movement (N/S/E/W)
  • Zone transitions (enter, go back)
  • Named room navigation

This counter directly impacts the movement efficiency bonus in scoring (max(0, 500 - totalMovements * 5)).


State Corruption Handling

Source: ,

All exploration handlers check for null progress, zone, or zoneSquares:

if (progress == null || zone == null || zone.getZoneSquares() == null) {
    log.warn("State corruption detected in {action}");
    addText(gameBasicProperties,
        "Your game session has expired. Say 'new game' to start fresh, or 'help' for commands.");
    return true;
}

If MysteryPlayerProgress is completely null, can restart the game from scratch using the customerId from , preserving best scores but losing all current progress.


Cooperative Party Play

Source: (324 LOC), PartyGameDetails.MysteryPartyDetails

When players are in a chat party together, evidence discovered by any member is shared across the entire team. This is the core cooperative investigation mechanic.

Shared Evidence Pool

The party's shared state is stored in PartyGameDetails.MysteryPartyDetails:

Shared Field Type Purpose
sharedClueIds Set<Long> Clues discovered by any party member
sharedNpcsInterviewed Set<String> NPCs interviewed by any party member
sharedWeaponsDiscovered Set<Long> Weapons found by any party member
activeScenarioId Long Which scenario the party is investigating
leaderZoneRef String Leader's current zone reference
leaderX, leaderY Integer Leader's current grid coordinates

Evidence Sync Flow

On every game load (populateGameDetails), the player's personal progress is synced from the party pool:

// .syncSharedEvidence() — party → player direction
for (Long clueId : mysteryDetails.getSharedClueIds()) {
    if (!progress.getCluesFound().contains(clueId)) {
        progress.getCluesFound().add(clueId);
    }
}
// Same for npcsInterviewed and weaponsDiscovered

After each handler runs, new discoveries are pushed back to the party pool:

// .pushEvidenceToParty() — player → party direction
for (Long clueId : progress.getCluesFound()) {
    details.addSharedClue(clueId);
}

Real-time sharing methods also exist for immediate propagation:

  • shareClue(gameBasicProperties, clueId) — Push a single newly-found clue
  • shareNpcInterviewed(gameBasicProperties, npcId) — Push a single NPC interview
  • shareWeapon(gameBasicProperties, weaponId) — Push a single weapon discovery

Leader Position Tracking

After each movement, the party leader's position is broadcast so members can locate them:

// .updateLeaderPosition()
// Only runs if current player is the party leader (party.isLeader(chatRef))
details.updateLeaderPosition(progress.getCurrentZoneRef(), progress.getCurrentZoneX(), progress.getCurrentZoneY());

Teleport to Leader

Non-leader party members can teleport to the leader's last known position:

// .teleportToLeader()
// Loads leader's zone from cache and sets player's coordinates to match
progress.setCurrentZoneRef(details.getLeaderZoneRef());
progress.setCurrentZoneX(details.getLeaderX());
progress.setCurrentZoneY(details.getLeaderY());

Error states:

  • Not in a party: "You are not in a party."
  • Already the leader: "You are the party leader. Your team can teleport to you."
  • Leader hasn't moved yet: "Party leader's location is not yet available."
  • Leader's zone not in cache: "Unable to locate the party leader's zone."

Party Scenario Management

When the leader starts a new scenario, the party's shared investigation is cleared and the new scenario ID is set:

// .setPartyScenario() — leader only
details.clearInvestigation();
details.setActiveScenarioId(scenarioId);

Only the party leader can change the active scenario. Non-leaders calling this method are ignored.

Party Lookup

The handler looks up the player's party via ChatUser.partyRef. It first checks the cached before falling back to .


MysteryInvestigationNode — Tree-Structured Investigation Paths

Source: ,

MysteryInvestigationNode provides an alternative tree-structured investigation system. Unlike the conversation tree (which is keyed to NPCs), investigation nodes are keyed to locations and support binary branching (2 options per node).

Entity Model

Source:

Field Type Purpose
investigationNodeId Long Unique identifier
scenarioId Long Scenario this node belongs to
parentNodeId Long Parent node (null = root node)
nodeDepth Integer Depth in the tree (0 = root)
locationName String Location description for this investigation point
investigationText String What the player observes/reads at this node
option1Text String Text for the first branching choice
option1NextNodeId Long Node ID to navigate to if option 1 chosen
option2Text String Text for the second branching choice
option2NextNodeId Long Node ID to navigate to if option 2 chosen
isTerminal Boolean True if this is a leaf node (no further branching)
revealsClueId Long Clue discovered when this node is reached (optional)
revealsWeaponId Long Weapon discovered when this node is reached (optional)
audioRef String Audio key for ambient sound (optional)
createdAt LocalDateTime Creation timestamp

Tree Structure

Nodes form a parent-child tree via parentNodeId:

Root Node (parentNodeId=null, nodeDepth=0)
  |-- Option 1 → Child Node A (nodeDepth=1)
  |     |-- Option 1 → Leaf Node A1 (isTerminal=true, revealsClueId=X)
  |     |-- Option 2 → Leaf Node A2 (isTerminal=true, revealsWeaponId=Y)
  |-- Option 2 → Child Node B (nodeDepth=1)
        |-- Option 1 → Leaf Node B1 (isTerminal=true)
        |-- Option 2 → Leaf Node B2 (isTerminal=true, revealsClueId=Z)

Location Branching

Each node has a locationName field that describes where in the scene this investigation point takes place. This allows the tree to represent investigation paths across multiple physical locations, with each branch representing a different area to examine.

Clue and Weapon Reveals

When a player navigates to a terminal node (or any node with revealsClueId/revealsWeaponId set), the corresponding clue or weapon is added to their progress. This is separate from the automatic movement-based clue discovery system.


Admin REST API

Source: — @RequestMapping("/api/mystery")

The admin controller exposes REST endpoints for content management and diagnostic operations. All admin endpoints (/admin/*) require JWT Bearer authentication.

Zone CRUD Endpoints

Method Path Purpose
GET /admin/zones List all zones across all scenarios
GET /admin/zones/{zoneRef} Get a single zone by reference
POST /admin/zones Create a new zone
PUT /admin/zones/{zoneRef} Update an existing zone
DELETE /admin/zones/{zoneRef} Delete a zone

Zone Validation and Repair Endpoints

Method Path Purpose
GET /admin/zones/validate Check all zones for structural issues (null zoneSquares, etc.)
POST /admin/zones/{zoneRef}/repair Re-save a specific zone (fixes nested zoneData structure issues)
POST /admin/zones/repair Repair all zones with detected issues
POST /admin/zones/fix-empty-descriptions Replace "An empty area." placeholders with contextual descriptions
GET /admin/zones/missing-npcs/{scenarioId} Identify zones that should have NPCs wired but don't

Validation service: — returns ZoneValidationResult per zone with issue details. Repair service: — handles description fixes and NPC gap detection.

AI Edit Queuing

Method Path Purpose
POST /admin/ai Queue an AI edit request for async processing via

AI requests are submitted as JSON bodies and queued by . This allows admin users to request content changes (zone descriptions, NPC dialog, clue text) without modifying code directly.

Public Content Endpoints

These are accessible without admin-level auth:

Method Path Purpose
GET /scenarios List all active scenarios
POST /start Begin a new game
GET /state Get current game state
POST /navigate Move in a direction
GET /zone/current Get current zone details
POST /npc/talk Start NPC conversation
POST /npc/choose Choose conversation option
POST /accusation Make final accusation
GET /leaderboard View scenario leaderboard (capped at 100)
GET /scenario/{scenarioId} Get scenario details
GET /scenario/{scenarioId}/characters List scenario characters
GET /scenario/{scenarioId}/clues List scenario clues
GET /scenario/{scenarioId}/weapons List scenario weapons
GET /scenario/{scenarioId}/zones List scenario zones
GET /scenario/{scenarioId}/npcs List scenario NPCs