Scoring
Status: COMPLETE — Full design documented
Last updated: October 27, 2026
Mystery -- Accusation & Scoring System
Status: COMPLETE — Full design documented
Source: , , ,
This document covers the complete accusation flow, scoring formula, confrontation system, and victory/failure conditions extracted from the actual Java source code.
Accusation Flow
State Machine
The accusation is a guided flow tracked by the AccusationStep enum. It asks the core of the
mystery first and the location last, and only when the scenario names where the murder took place:
null --> CHOOSING_SUSPECT --> CHOOSING_WEAPON --> CHOOSING_MOTIVE [--> CHOOSING_LOCATION] --> Score Resolution
Source: , which asks the first part not yet named.
| Order | Enum Value | Asked when |
|---|---|---|
| 0 | null / NONE |
Not in accusation mode |
| 1 | CHOOSING_SUSPECT |
No suspect named yet |
| 2 | CHOOSING_WEAPON |
No weapon named yet |
| 3 | CHOOSING_MOTIVE |
No motive named yet |
| 4 | CHOOSING_LOCATION |
The scenario's settingDetails.murder_location is set and no location is named yet |
A scenario with no murder_location asks three questions, not four: a location the scenario does
not name could never be scored, so asking it only lengthened the path to the verdict.
One-Breath Accusation
Whatever the opening words name is filled before anything is asked (). "Accuse Tom with the poisoned coffee" fills the suspect and the weapon and asks only the motive; "it was the butler with the knife in the library" fills the suspect by title, the weapon and, when the scenario names a murder location, the place. A part is filled only when exactly one choice is named, so an ambiguous word is asked about rather than guessed. A named part is never asked again on a later step.
Prerequisites
Minimum 3 clues required to begin an accusation.
// .accuse()
final int clueCount = progress.getCluesFound() != null
? progress.getCluesFound().size()
: 0;
if (clueCount < 3) {
// "You need more evidence before making an accusation."
// "Clues found: {clueCount} of 3 minimum required."
}
Suspect Selection
Suspects are loaded dynamically from the scenario's character list, the victim excluded
(). Each character is displayed as
"{characterName}, {characterTitle}" with synonyms including both name and title in lowercase.
Weapon Selection
Weapons are loaded dynamically from the scenario's weapon list ().
Location Selection
Asked last, and only for a scenario whose settingDetails.murder_location names the room
(MysteryScenario.namedMurderLocation()). The choices are the scenario's zones. A location that
matches murder_location, ignoring case, scores 100 points (locationPoints).
Step 4: Motive Selection
Motives are loaded dynamically from the scenario's settingDetails.accusation_motives list:
// .getAccusationMotives()
final MysteryScenario scenario = mysteryScenarioCacheService.getScenarioById(scenarioId);
if (scenario != null && scenario.getSettingDetails() != null) {
final Object motiveData = scenario.getSettingDetails().get("accusation_motives");
if (motiveData instanceof List) {
final List<String> motives = (List<String>) motiveData;
if (!motives.isEmpty()) {
return motives;
}
}
}
// Fallback to generic motives if scenario has none configured
return List.of("Financial Gain", "Revenge", "Jealousy", "Self-Defense", "To Protect a Secret");
Each scenario can define its own motives via settingDetails.accusation_motives. The generic 5-motive fallback only applies when a scenario hasn't configured custom motives.
Option Synonyms System
All accusation options include rich synonym sets for voice recognition:
// .addAccusationOption()
// For each option, synonyms include:
// - "option {N}", "{N}", "number {N}", "choice {N}"
// - Ordinal words: "first"/"one", "second"/"two", "third"/"three", etc.
// - Extra synonyms from the comma-separated extraSynonyms parameter
Suspect/weapon/motive display names are additionally expanded into single-word partial-name match keys via distinctiveNameTokens() — every word of length 2 or more that is not a bare honorific/title/article (ACCUSATION_NAME_STOPWORDS) becomes its own synonym, so a player can say a bare first name ("Tom") or surname ("Reese"), not just the full "Tom Reese, Baker" display text. Single-letter tokens and honorific abbreviations ("dr") stay excluded as too ambiguous to bind to one option.
Cancellation
Player can say "back", "cancel", "nevermind", "exit", "quit", or "stop" at any step to cancel the accusation:
// .addAccusationBackOption()
"back,go back,cancel,nevermind,never mind,exit,quit,stop"
Cancellation clears all accusation state fields (accusationState, accusationLocation, accusationSuspectId, accusationWeaponId, accusationMotive) and returns the player to investigation mode.
Victory/Failure Conditions
Correct Solve
A solve is considered correct when both character AND weapon are correct:
// .handleMotiveChoice()
final boolean solvedCorrectly = Boolean.TRUE.equals(result.getCharacterCorrect())
&& Boolean.TRUE.equals(result.getWeaponCorrect());
On correct solve:
- Game state set to
COMPLETED - Final score displayed
- Player rank on leaderboard shown
- Motive correctness noted if also correct ("bonus points")
- Guild Wars contribution triggered (difficulty =
max(1, 5 - accusationAttempts))
Incorrect Solve
On wrong accusation:
- Partial feedback: tells player which of character/weapon was correct vs wrong
- Player can continue investigating and try again
- 100-point penalty per wrong attempt (reflected in scoring)
accusationAttemptsincremented- All accusation selections cleared for next attempt
- Help after the miss reads the wallet (). A subscriber, or a
player whose badges cover a hint, hears only the hints they can take now (a deduction hint at 25, a
solution hint at 50) with those options. Anyone else, and every player in a region that cannot buy
(
isInSellableLocale), hears the free next step from the evidence instead: "You have found 2 of 5 clues. Say search to find what you have missed.", or, holding every clue, "Say view clues to hear them again, then accuse." No badge or subscription is named to a player who cannot use one.
Feedback patterns:
| Character | Weapon | Message |
|---|---|---|
| Correct | Wrong | "You correctly identified the murderer, but the weapon was wrong." |
| Wrong | Correct | "You identified the correct weapon, but accused the wrong person." |
| Wrong | Wrong | "Both the suspect and weapon were incorrect." |
Scoring Formula
Source:
Complete Score Breakdown
| Component | Points | Formula | Source Method |
|---|---|---|---|
| Character correct | 500 | isCorrect ? 500 : 0 |
calculateCharacterPoints() |
| Weapon correct | 200 | isCorrect ? 200 : 0 |
calculateWeaponPoints() |
| Motive correct | 100 | isCorrect ? 100 : 0 |
calculateMotivePoints() |
| Location correct | 100 | isCorrectLocation ? 100 : 0; 0 when the scenario names no murder_location |
|
| NPC interviews | 50/each | npcsInterviewedCount * 50 |
calculateNpcPoints() |
| Zone exploration | 10/each | zonesVisitedCount * 10 |
calculateExplorationPoints() |
| Clue discovery | Variable | Sum of per-clue pointsIfFound values |
|
| Movement efficiency | 0--500 | max(0, 500 - totalMovements * 5) |
calculateMovementEfficiencyBonus() |
| Interview efficiency | 0--300 | max(0, 300 - conversationsCompleted * 20) |
calculateInterviewEfficiencyBonus() |
| Critical clue bonus | 200 | All critical clues found: 200, otherwise 0 |
calculateCriticalClueBonus() |
| All clues bonus | 300 | All scenario clues found: 300, otherwise 0 |
calculateAllCluesBonus() |
| All NPCs bonus | 150 | All NPCs interviewed: 150, otherwise 0 |
calculateAllNpcsBonus() |
| Wrong accusation penalty | -100/attempt | max(attempts * -100, -500) |
calculateWrongAccusationPenalty() |
| Confrontation (correct) | Per-clue | clue.confrontationBonus (default 50) |
calculateConfrontationPoints() |
| Confrontation (wrong) | Per-clue | clue.confrontationPenalty (default -25) |
calculateConfrontationPoints() |
Total Score Formula
// AccusationScore.getTotalScore()
totalScore = characterPoints + weaponPoints + motivePoints + locationPoints
+ cluePoints + npcPoints + explorationPoints
+ movementEfficiencyBonus + interviewEfficiencyBonus
+ perfectSolveBonus // criticalClueBonus + allCluesBonus + allNpcsBonus
+ wrongAccusationPenalty // already negative
+ confrontationPoints
Penalty Cap
Wrong accusation penalty is capped at -500 points maximum:
// .calculateWrongAccusationPenalty()
// M10 fix - cap at -500 points maximum penalty.
final int rawPenalty = attempts * -100;
return Math.max(rawPenalty, -500); // Cap at -500
This means the maximum penalty is reached at 5 wrong attempts. Additional wrong attempts beyond 5 do not increase the penalty further.
Efficiency Bonus Breakeven Analysis
Movement Efficiency (pool: 500 points, drain: 5/move):
- 0 moves: +500 bonus
- 50 moves: +250 bonus
- 100 moves: +0 bonus (drained)
- Breakeven: 100 movements to fully drain the bonus
Interview Efficiency (pool: 300 points, drain: 20/conversation):
- 0 conversations: +300 bonus
- 5 conversations: +200 bonus
- 10 conversations: +100 bonus
- 15 conversations: +0 bonus (drained)
- Breakeven: 15 completed conversations to fully drain the bonus
Design Tension
The scoring creates an intentional tension:
- Exploring more zones earns +10/zone, but each movement drains -5 from the 500-point movement efficiency pool
- Interviewing more NPCs earns +50/NPC, but each conversation drains -20 from the 300-point interview efficiency pool
- The optimal strategy is focused investigation -- gathering enough evidence without excessive wandering
Confrontation System
During Investigation (Real-time Points)
Source:
The confrontation system allows players to present discovered clues to NPCs during investigation, before making an accusation.
Command: "confront [NPC] with [clue]" or "present [clue] to [NPC]"
Real-time scoring (applied immediately to totalScore, uses same formula as final scoring):
// .handleClueConfrontation()
final boolean isRelatedNpc = npcId.equals(clue.getRelatedNpcId());
final int pointsAwarded = isRelatedNpc
? (clue.getConfrontationBonus() != null ? clue.getConfrontationBonus() : 50)
: (clue.getConfrontationPenalty() != null ? clue.getConfrontationPenalty() : -25);
final int newScore = (progress.getTotalScore() != null ? progress.getTotalScore() : 0) + pointsAwarded;
The player sees the exact points earned: "You earned +50 points for this confrontation." or "You earned -25 points for this confrontation."
The suspect's reaction comes from two facts the case holds, independent from scoring
(): whether this suspect is the murderer, and whether the
clue points at this suspect (relatedNpcId). The suspect's first personality trait from its CHARACTER
row leads the line ("Ever nervous, Tom Reese ..."), so no two suspects answer alike:
| Suspect | Clue points at them | Reaction |
|---|---|---|
| Murderer | Yes | "goes pale at the {clue}. 'Where did you get that?' comes out too sharply, and they will not meet your eyes." |
| Murderer | No | "glances at the {clue} and relaxes a little. 'That proves nothing about me, Detective.'" |
| Innocent | Yes | "studies the {clue} for a long moment. 'I can explain that. It is not what it looks like.'" |
| Innocent | No, the wrong clue | "frowns at the {clue}. 'I have never seen that before. You should ask someone else.'" |
The guilty tell stays a hint a player must confirm across confrontations (the information-asymmetry pillar). Per-suspect lines written for each CHARACTER row would replace these engine lines; until a character carries its own, the engine line is what is said.
A clue is named the way a player says it. Matching ignores spaces and plurals
(MysteryTextMatching.namesLikelyMatch): "blood stain" is the Bloodstain, "the brandy glasses" is
the Poisoned Brandy Glass. A refused clue says why, then names the clues that work: "You have not
found a clue called the candlestick. You can confront someone with Muddy Boots." (found, confrontable
and not yet used, ).
During Final Scoring (Accumulated Points)
Source:
At accusation time, all confrontations are re-evaluated with per-clue bonus/penalty values:
// .calculateConfrontationPoints()
for (Map.Entry<Long, String> entry : clueConfrontations.entrySet()) {
final MysteryClue clue = mysteryClueCacheService.getClueById(clueId);
final boolean isCorrectNPC = confrontedNpcId.equals(clue.getRelatedNpcId());
if (isCorrectNPC) {
points += clue.getConfrontationBonus() != null ? clue.getConfrontationBonus() : 50;
} else {
points += clue.getConfrontationPenalty() != null ? clue.getConfrontationPenalty() : -25;
}
}
Confrontation Validation Rules
| Check | Source | Behavior |
|---|---|---|
| Clue not discovered | Error: "You haven't discovered that clue yet." | |
| Clue already used in any confrontation | Error: "You have already used this clue in a confrontation." | |
| Clue not found in cache | Error: "Clue not found." | |
| NPC not found | Error: "NPC not found." | |
| Clue not confrontable | Error: "This clue cannot be used in confrontations." |
Confrontation Tracking
Confrontations are tracked in MysteryPlayerProgress.clueConfrontations: Map<Long, String> (clueId to npcId). Each clue can only be used in one confrontation — once a clue is confronted with any NPC, it is permanently "used" and cannot be re-confronted with a different NPC. This prevents point farming and eliminates the map overwrite issue.
Fuzzy Matching for Confrontation Input
Source: ( merely delegates to it)
The handler parses free text using these patterns:
"confront {npc} with {clue}" -- splits on " with "
"confront {npc} about {clue}" -- splits on " about "
"present {clue} to {npc}" -- splits on " to "
"show {clue} to {npc}" -- splits on " to "
Clue matching uses possessive-normalized fuzzy comparison:
// MysteryTextMatching.normalizeForClueMatching()
text.toLowerCase()
.replaceAll("'s\\b", "") // Strip possessives
.replaceAll("[^a-z0-9\\s]", " ") // Remove punctuation
.replaceAll("\\s+", " ") // Collapse whitespace
.trim()
.replaceAll("(?<=[a-z]{3,})s\\b", "") // Strip a word-final "s" on 4+ letter words
This allows "Lord Percival correspondence" to match "Lord Percival's Correspondence".
The trailing-s strip covers voice input specifically: transcription drops the apostrophe
entirely, so a spoken "the cashier's account" arrives as "cashiers account" and the
's rule never fires on it. Both the stored clue name and the player utterance pass through
this same normalizer, so the strip is symmetric and cannot produce a one-sided mismatch. The
3-letter floor keeps short words (is, as, his, was) intact.
Perfect Solve
A "perfect solve" earns a star marker on the leaderboard. The AccusationScore tracks:
// AccusationScore.getPerfectSolveBonus()
return criticalClueBonus + allCluesBonus + allNpcsBonus;
// 200 + 300 + 150 = 650 maximum perfect solve bonus
Requirements for perfect solve bonuses (each independent):
criticalClueBonus(200): Found ALL critical clues (isCritical == true) in the scenarioallCluesBonus(300): Found ALL clues in the scenarioallNpcsBonus(150): Interviewed ALL NPCs in the scenario
Best Score Tracking
Source:
Only the highest-scoring attempt per scenario is persisted:
| Field | Type | Purpose |
|---|---|---|
totalScore |
Integer | Overall score |
characterPoints, weaponPoints, motivePoints |
Integer | Accusation component breakdown |
cluePoints |
Integer | Total clue discovery points |
perfectSolveBonus |
Integer | Combined perfect solve bonus |
correctConfrontations, wrongConfrontations |
Integer | Confrontation stats |
confrontationPoints |
Integer | Net confrontation points |
characterCorrect, weaponCorrect, motiveCorrect |
Boolean | Per-element correctness |
perfectSolve |
Boolean | Whether this was a perfect solve |
timeToSolveMinutes |
Integer | Duration of playthrough |
accusedCharacterId, accusedWeaponId, accusedMotive, accusedLocation |
Mixed | What the player accused |
Leaderboard Integration
Source: ,
On accusation completion:
- Score calculated by
- Best score updated in
MysteryAccount.bestScores(if higher) - Score added/updated in Redis sorted set:
MYSTERY_LEADERBOARD:{scenarioId} - Player rank queried and displayed
Leaderboard display (top 10):
- Rank 1: Gold medal emoji
- Rank 2: Silver medal emoji
- Rank 3: Bronze medal emoji
- Ranks 4-10:
#Nformat - Perfect solve: star emoji
- Current player:
(You)marker - If not in top 10: rank shown separately below
Redis architecture: RScoredSortedSet per scenario, O(log N) operations, no TTL (permanent), warmup loads top 1000 from MySQL on startup.
Guild Wars Integration
On successful case solve, a Guild Wars contribution is triggered:
// .handleMotiveChoice()
final int difficulty = Math.max(1, 5 - (progress.getAccusationAttempts() != null
? progress.getAccusationAttempts() : 0));
.mysCaseSolved(gameBasicProperties, scenarioId, difficulty);
Difficulty scale: First-attempt solve = difficulty 5, each additional attempt reduces by 1, minimum 1.
On clue discovery (both via exploration and badges), a separate contribution fires:
.mysClueDiscovered(gameBasicProperties, clueId);
Insufficient-Badges UX Rule
Source: called from buyLocationHint/buyDeductionHint/buySolutionHint and premium-scenario unlock; error_log rows 264-268 (2026-04-24)
Every insufficient-investigation-badge branch — buyLocationHint, buyDeductionHint, buySolutionHint, and the premium-scenario gate inside — MUST render at least three escape options after the shortage message:
| Option label | Action key | Purpose |
|---|---|---|
buy badges |
BASELINE_BUY |
Opens the badge pack listing () |
View Balance |
MYSTERY_VIEW_BALANCE |
Surfaces the catalog of what badges unlock |
Main Menu |
MAIN_MENU |
Lets the player exit the case entirely |
Why this exists. Five NO_OPTIONS_RENDERED CRITICAL dead-ends (rows 264-268) hit at MANSION_ENTRANCE_HALL on 2026-04-24 — players said "begin investigation", "location hint", "deduction hint", "solution hint", "buy hint" with 0 badges and got the shortage text but no options. To Alexa players that is a silent dead-end (see ).
Implementation. All Mystery badge shortfall sites call with ShortfallCurrencyType.INVESTIGATION_BADGES (F8 batch 2). The service writes the canonical "Not enough investigation badges. You need N but have M." copy, attaches BASELINE_BUY + MYSTERY_VIEW_BALANCE + MAIN_MENU escape options through , and writes one NluFallback shortfall metric per call. Voice text reinforces the options ("Say buy investigation badges, view balance, or main menu") so Alexa players hear them. The previous static helper was deleted along with all four call sites.
Regression coverage. game_test.scenario_tests rows 142-146 (2026-04-24) assert option_key=BASELINE_BUY, option_key=MAIN_MENU, and contains_text=main menu for all five paths.