Skip to main content

Scoring

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)
  • accusationAttempts incremented
  • 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 scenario
  • allCluesBonus (300): Found ALL clues in the scenario
  • allNpcsBonus (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:

  1. Score calculated by
  2. Best score updated in MysteryAccount.bestScores (if higher)
  3. Score added/updated in Redis sorted set: MYSTERY_LEADERBOARD:{scenarioId}
  4. 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: #N format
  • 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.