Daily Case (Case of the Day)
Status: SHIPPED 2026-06-15 (gd-03 retention fix)
Last updated: October 27, 2026
Mystery — Daily Case (Case of the Day)
Status: SHIPPED 2026-06-15 (gd-03 retention fix)
Parent: index.md. Siblings: monetization.md, scoring.md. This Case of the Day is the Mystery theming of the shared Daily framework (../../engine/daily-framework.md) — the framework generalizes the deterministic date-seeded selection proven here. Mystery's economy-write-free stance is preserved as a first-class framework shape.
The Problem (gd-03)
Murder Mystery is content-finite. A scenario is consumed once: the player solves it, the
solution is now known, and getAvailableScenariosForPlayer filters completed cases out of the list.
There is no renewal — once a returning player has solved everything interesting to them, the game
gives them no fresh challenge and no reason to come back tomorrow. This is the single
highest-leverage retention gap in the game (process-reports report 3, finding gd-03, severity high).
The owner's chosen direction: add a daily rotating case so players return.
The Chosen Model: Deterministic Daily Rotation
Of the three candidates the renewal model could take —
| Candidate | Verdict |
|---|---|
| Daily rotating case (chosen) | A single "Case of the Day" deterministically selected by date from the full scenario pool. Stateless, hermetic-testable, zero new content, classic daily-challenge retention loop (Wordle-style). |
| Daily reset of the available pool | Rejected — it would re-serve already-solved cases en masse with no framing, and undermines the "completed cases are done" model. |
| Light procedural generation | Rejected for this pass — high risk, large scope, would touch the scenario/clue/zone generation stack. A future enhancement, not the retention fix. |
— the daily rotating case is the lowest-risk, highest-leverage fix and the one the owner picked. The daily layer sits on top of the existing one-shot scenario flow; it does not replace or alter it.
Selection (deterministic, stateless)
The Case of the Day is a pure function of the calendar date:
pool = all active scenarios, sorted ascending by scenarioId (stable order — 39 cases, ids 1..39)
index = floorMod(epochDay, pool.size) // epochDay = days since 1970-01-01, UTC
daily = pool.get(index)
- Every player sees the same Case of the Day on the same day — a shared daily challenge, which is what makes the daily leaderboard meaningful and gives players a reason to compare. This holds for ESTABLISHED players; a brand-new player is deliberately routed to a guided onboarding case instead (see § New-player exception below).
- No persisted "today's case" state. Selection is recomputed from
LocalDate.now(UTC)every time it is rendered or started. The rotation therefore resets automatically at the UTC date boundary — there is no scheduler, no cron, no stored cursor that can drift. A new case simply is the answer the next day. floorMod(not%) so the math is always a valid non-negative index.- Sorting by
scenarioIdmakes selection independent of howevergetActiveScenarios()happens to order rows, so the choice is reproducible.
New-player exception (guided onboarding)
The all-players-same-case invariant above holds for ESTABLISHED players. It is deliberately
narrowed for a brand-new player: a player with fewer than one completed case (an empty
bestScores) gets the guided EASY Academy onboarding case as their Case of the Day, NOT whatever
HARD premium scenario the date rotation happens to land on. Owner decisions: human_question 23623,
finding 874 (the new-player exception); the Mystery onboarding overhaul (2026-07-13,
human_idea 24153) refined the guided case from Gothic Manor to the shorter, easier EASY Academy tutorial.
- Why. A new player's very first daily must be solvable — a case with a start-square witness and a first clue reachable in one or two moves — or the retention hook backfires by dropping them into a hard premium mystery with no footing. The lowest-id EASY Academy case (Murder at the Seaside Bakery, scenarioId 40) is the guided on-ramp: it is a short two-zone tutorial, its start square holds Clara Webb the cashier, and the first talk front-loads clue 1 (see index.md, First-Timer Pre-Loaded Guided Onboarding Case, and the finding 873 first-clue front-load). If no EASY case is active, the fallback is Gothic Manor (scenarioId 1), which shares the same start-square-NPC + early-clue shape.
- Who is "new". Empty
bestScores(zero completed cases) — the SAME first-timer testlistScenariosalready uses to auto-direct to the guided case. The moment a player completes their first case they become established and rejoin the shared rotation. - Established players are unchanged. One or more completed cases → the shared, date-rotating Case of the Day, preserving the everyone-shares-today's-case property that makes the daily leaderboard meaningful.
- Applied consistently across every surface. The exception lives in the player-aware resolver
.getDailyScenarioForPlayer(customerId, date), which every player-scoped surface now calls: the in-list "Case of the Day" option (addDailyCaseOption), theMYSTERY_DAILY_CASEstart (startDailyCase), and the shared-frameworkdailycommand / objective (). So a new player is both SHOWN and STARTED the same guided case — no display-vs-start mismatch. The pure date functiongetDailyScenario(date)is unchanged and still backs the shared rotation and the daily-leaderboard guarantee for established players. The guided case itself resolves via (EASY-first, Gothic Manor fallback), which is ALSO what the first-timer launch materialization ( ->getFirstCaseScenarioForNewPlayer) and the first-timer scenario offer (listScenarios) resolve, so the pre-loaded case, the offered case, and the new-player daily are always the same case. - Known limitation (streak credit on the first-day transition). Because solving the guided case
makes the player established, a new player's very first daily solve is not credited toward the
shared-case streak within that same day (the objective flips from Gothic Manor to the shared case on
completion, from the guided case to the shared case). This mirrors the existing
achievedAtsame-day known limitation documented below and is accepted — the streak is a nicety, the guided on-ramp is the point.
Cadence
- Rotates once per day, at 00:00 UTC. With 39 cases the full cycle is 39 days; a player who returns daily sees a different mystery each day for over a month before any repeat.
- A single UTC boundary (not per-player local midnight) keeps the "everyone has the same case today" guarantee intact across timezones.
How a "consumed" case re-opens
This is the crux of the renewal model. A scenario the player has already completed is normally filtered out of the selectable list forever. The Daily Case is the one entry point that re-opens a consumed case: when today's rotation lands on a scenario the player has already solved, the Case of the Day still starts it. The player replays it — for a fresh run at the daily leaderboard and a shot at beating their own best score (best scores are preserved across replays, see index.md § Best Score Tracking). The rotation guarantees that even a player who has 100%-ed the game always has a case waiting for them tomorrow.
The reward loop
The daily case is its own reward loop, built from mechanics that already exist — no new economy writes, no currency grants (which avoids the cache-writeback stomp risk documented in the economy design):
- Free access, every day. The Case of the Day is playable for free by everyone, even when the
rotation lands on a premium (100-badge / subscriber) scenario. This is the daily incentive to
return: a fresh case at no cost. The premium gate in
selectScenariois not applied on the daily path. - Premium teaser → conversion funnel. A free player sampling a premium case via the daily slot is a funnel, not a leak: it showcases the locked content. When a non-subscriber plays a premium-tier daily case we append a one-line upsell ("Subscribe to play any of these mysteries any time") so the daily case drives conversion rather than cannibalizing it. Aligns with monetization.md § F (free experience complete but bounded; premium irresistible).
- Daily leaderboard standing. Because everyone shares today's case, the per-scenario leaderboard doubles as the day's leaderboard — a competitive reason to return and play today's case today.
- Beat-your-best. Replaying a solved case through the daily slot is a score-attack loop against the player's own preserved best score.
- Once per UTC day, a score attack with the Inspector's bonus. The day's first solve of the
shared Case of the Day is the one that counts: it reaches the board, and when it took no hint and
no bought clue it adds the Inspector's bonus (, 100
points). The case is introduced that way ("A score attack: solve it with no hint or bought clue
for the Inspector's bonus of 100 points."). A later solve the same day is a replay: it is said to
be one, and it writes nothing to the board (). The day
is the use count
MYSTERY_DAILY_SOLVED_DAY(the UTC epoch day plus one), the same day the case is picked by, so a new day's case is never taken for a replay. The bonus is added only to a solve: a wrong accusation on the day's case earns none. The same mark is what lets the day's case pay badges once that day (monetization.md, G4b). - Help is named, never praised as flawless. The help a player takes on a case, the badges spent
on hints and quick clues and any hint at all, is counted per case (
MysteryCaseAids, cleared when a case begins). A solve that spent badges hears how many ("Case cracked, Detective, with 35 investigation badges of help.") in place of "Your deduction was flawless", and the board marks the standing score of a solve that took none ", unaided" (, a Redis set beside the board, so no column or cached field is added).
Future enhancement (designed, not yet shipped): a daily-solve streak bonus (e.g. bonus investigation badges on the first daily solve of the day, escalating with consecutive days) would deepen the loop. Deferred from the gd-03 pass to keep it free of economy-write risk; tracked as a follow-on, not a blocker.
Player-facing surface
- The Case of the Day is announced at the top of the scenario list (
listScenarios) for prominence, in BOTH the first-timer branch and the returning-player branch, showing today's case name, difficulty, and estimated time plus a "free today" tag. Its selectable option is added AFTER the numbered scenarios, never at option position 1: the numeric selector (Tier 2 OPTIONS) matches an option by its POSITION in the list, so a Case of the Day at position 1 made a bare "1" start the daily case and pushed every scenario one position past its "Option N" label (fixed 2026-07-05, .announceDailyCase + addDailyCaseButton). The daily case is therefore reached by NAME ("case of the day", "daily"), truly distinct from the numbered index selection. - It routes to the dedicated action
MYSTERY_DAILY_CASE→ . - Synonyms on the displayed option: "case of the day", "daily case", "daily", "todays case", "today's case", "daily mystery" — so a returning player can say it once the list is shown.
startDailyCaseclears any in-flight progress (same asselectScenario), starts today's case free, shows the intro, and drops the player into the starting zone exactly as a normal scenario start.- An open case asks first: "You already have a case open. Say yes to leave it and start the Case of the Day, or say no to keep investigating." A case is open when it is unsolved and the player has worked it: a move, a clue found, an NPC interviewed, a conversation completed or an accusation made. Launch seats every player in a case, so standing in one is not enough; a player who has only launched or listed scenarios starts the daily case straight away, and so does a player whose case is solved. .
MYSTERY_DAILY_CASEis whitelisted in the COMPLETED-state guard in , so a player who just finished a case can immediately jump into the Case of the Day.
A global "case of the day" voice command reachable from anywhere (not only when the scenario list is on screen) requires
process_one_shot_valuesNLU rows; that is a small follow-on. The in-list option is the primary, fully-working entry point.
Tests
- Determinism / rotation (JUnit):
MysteryGameServiceTest.getDailyScenario_*— same date → same case; consecutive days → different case (pool > 1); selection wraps modulo pool size; sort-stable regardless of input order. The "new case after reset" and "old case not re-served identically" guarantees are proven here, where the date is fully controllable. - Handler (JUnit): —
startDailyCasestarts the daily case free even when it is a premium scenario / already completed (no shortfall, no badge debit). - Player flow (hermetic scenario_test): the "Case of the Day" option appears in the scenario list
and is startable end-to-end. Assertions are on the date-independent surface (the stable "Case of
the Day" label and the post-start "Search for clues" option), never on the rotating case name, so
the test is hermetic — same result every day (per
.claude/rules/hermetic-scenario-tests.md).
Shared Daily framework adoption (E2)
The Case of the Day is now also the Mystery theming of the shared Daily framework
(../../engine/daily-framework.md) via
(engines/handlers/common/daily/.java). The provider wraps the
already-shipped selection and launch — it delegates to for the
date-rotating pick and to for the free, replayable launch — and
adds nothing new to either.
What it lights up:
- The baseline
dailycommand now resolves in any Mystery session (supportsgates on a presentmysteryPlayerProgress, mirroring ), surfacing today's Case of the Day, the player's progress as "You are at N of M", and a main-menu next action — in addition to the existing in-list "Case of the Day" option (which still wins when the scenario list is on screen). Verified end to end on the local backend: after starting a case,dailyspeaks "Today's Case of the Day: solve {today's case}. You are at 0 of 1." - The streak is fed by the shared (keyed on
getGameId()= MURDER_MYSTERY) and surfaces inline in thedailyreadout once the player has a streak (the sharedappendStreakLine), realizing the daily-solve streak the gd-03 pass deferred (see § The reward loop, future enhancement).
Reward stays DailyReward.none() — Mystery's deliberate economy-write-free stance is preserved as
a first-class framework shape, not a degraded one. The daily IS its own reward: a fresh case, a
daily-leaderboard standing, and a streak.
Completion binds to an existing tracker, no new counter. A day's case counts as solved
(EVENT_COUNTED, target 1) when the player's best-score record for today's rotating scenario was
achieved on the current UTC day (MysteryAccount.bestScores[scenarioId].achievedAt). Known
limitation: updateBestScoreIfHigher only refreshes achievedAt when a replay beats the prior best,
so a same-day replay that scores lower than an earlier day's best does not re-credit the daily — the
common first-solve / new-best path credits correctly. A dedicated per-day solve timestamp is a future
refinement if the streak loop needs it.
Known gap (E1 shared-NLU scope, not E2): only daily -> BASELINE_DAILY resolves in the Mystery
context today. The standalone daily streak / my streak (BASELINE_DAILY_STREAK), claim daily
(BASELINE_DAILY_CLAIM), and start daily / play daily (BASELINE_DAILY_START) phrases are NLU
misses there — the framework handlers and this provider's startDaily are wired, but the
always-active phrase rows for those three variants are not yet registered. Tracked for the cross-game
verify / E1 NLU completeness; the streak still surfaces inline via the working daily
command.
Regression test
- Player flow (hermetic scenario_test 2161):
list scenarios; case of the day; dailyasserts the date-independent surface — the stable "Case of the Day" title, the "You are at" N-of-M progress shape, and the MAIN_MENU option — never the rotating case name (per.claude/rules/hermetic-scenario-tests.md). Authored to prodgame_test; behavior verified on the local backend viaplay_game.