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):
- Scenario's
startingZoneReffield - Root zone for scenario (zone with
parentZoneRef == null) - 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 |
Navigation System
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
Navigation Types
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:
- Conversation state cleared (if in active conversation)
- Player position updated (
currentZoneX,currentZoneY) totalMovementsincremented by 1- Square description displayed
- Automatic clue discovery for all clues at the new square
- Automatic NPC detection if an NPC is present
- Zone entry option displayed if the square has a
zoneRefToClone - Available exits shown from new position
Named Room Navigation
Source:
When a direction doesn't match cardinal directions, the handler searches for named rooms:
- 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" - Collect the destinations: every square in the current zone with a
zoneRefToClone, plus the parent zone for "go back" style navigation - 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) - 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
- 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:
- Target zone loaded from cache
- Circular reference detection: prevents entering current zone
- Entry coordinate validation: verifies target square exists
- Conversation state cleared
- Zone reference, coordinates updated
- Zone visit tracked
totalMovementsincremented- Zone description + square description displayed
- Automatic clue discovery at entry point
- 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:
- The exit is announced: "You can enter {zoneName} from here."
- Exit info stored in progress:
availableZoneEntryRef,availableZoneEntryX/Y - Player must explicitly say "enter" to transition
- Exit info cleared when player moves to a different square
Blocked Paths
Blocking Mechanics
Source:
Squares with isBlocked == true are impassable unless:
- The player has previously unlocked the square, OR
- 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:
- Current square's
squareDescription(orsquarePermanentDescriptionif first-visit text is null) - Falls back to zone name if both are null
- NPC presence announcement (if NPC at location)
- Zone entry option (if exit square leads to another zone)
- 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)
Search
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
examinecommand 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" againstprogress.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
searchhints, which also scan the current square, and the zone-entryaddZoneNpcHints("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".
- Extract object name from player input
- Normalize using
normalizeForClueMatching()(strip possessives, lowercase, remove punctuation) - Check current square's
clueIdsfor fuzzy name match - 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."
- If no clue match, check for NPC name match: "Say 'talk' to speak with them."
- 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:
- Check badge balance (cost: 1)
- Get all scenario clues, filter out already-found
- Pick random undiscovered clue (
ThreadLocalRandom.current().nextInt(undiscoveredClues.size())) - Subscriber bonus: 50% chance to keep badge (
ThreadLocalRandom.current().nextBoolean()) - If not preserved: spend the badge
- 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 clueshareNpcInterviewed(gameBasicProperties, npcId)— Push a single NPC interviewshareWeapon(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 |