HusseinEid commited on
Commit
414dc55
·
0 Parent(s):

Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +24 -0
  2. .gitattributes +37 -0
  3. .gitignore +39 -0
  4. COMPLIANCE.md +95 -0
  5. Dockerfile +47 -0
  6. README.md +91 -0
  7. app.py +70 -0
  8. assets/fonts/body.woff2 +0 -0
  9. assets/fonts/heading.woff2 +0 -0
  10. assets/ui/music/ambient_theme.mp3 +3 -0
  11. assets/ui/music/ambient_theme.wav +3 -0
  12. assets/ui/sfx/accuse.wav +0 -0
  13. assets/ui/sfx/click.wav +0 -0
  14. assets/ui/sfx/fail.wav +0 -0
  15. assets/ui/sfx/page.wav +0 -0
  16. assets/ui/sfx/present.wav +0 -0
  17. assets/ui/sfx/select.wav +0 -0
  18. assets/ui/sfx/success.wav +0 -0
  19. cases/prebaked/CASE-0001.json +543 -0
  20. cases/prebaked/CASE-0002.json +543 -0
  21. cases/prebaked/CASE-0003.json +543 -0
  22. cases/prebaked/CASE-0004.json +543 -0
  23. cases/prebaked/CASE-0005.json +543 -0
  24. cases/seeds/GRAYMOOR-3107.json +236 -0
  25. cases/seeds/tutorial.json +588 -0
  26. docs/FIELD_NOTES.md +97 -0
  27. docs/sample_agent_trace.json +104 -0
  28. pyproject.toml +33 -0
  29. requirements.txt +23 -0
  30. scripts/build_tutorial_case.py +286 -0
  31. scripts/curate_prebaked.py +65 -0
  32. scripts/fetch_models.py +74 -0
  33. scripts/net_audit.py +81 -0
  34. scripts/prebake_cases.py +125 -0
  35. src/case_zero/__init__.py +7 -0
  36. src/case_zero/api/__init__.py +13 -0
  37. src/case_zero/api/case_adapter.py +221 -0
  38. src/case_zero/api/dto.py +31 -0
  39. src/case_zero/api/hints.py +37 -0
  40. src/case_zero/api/public_view.py +179 -0
  41. src/case_zero/api/questions.py +20 -0
  42. src/case_zero/api/routes_case.py +55 -0
  43. src/case_zero/api/routes_run.py +202 -0
  44. src/case_zero/api/runtime.py +243 -0
  45. src/case_zero/api/scripted.py +48 -0
  46. src/case_zero/api/server.py +96 -0
  47. src/case_zero/api/tts_service.py +73 -0
  48. src/case_zero/audio/__init__.py +16 -0
  49. src/case_zero/audio/manifest.py +26 -0
  50. src/case_zero/audio/synth.py +168 -0
.dockerignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build context excludes — keep the image lean and force a clean web build in-image.
2
+ .git
3
+ .venv
4
+ venv
5
+ env
6
+ **/__pycache__
7
+ *.pyc
8
+ .pytest_cache
9
+ .ruff_cache
10
+ .mypy_cache
11
+ .coverage
12
+ htmlcov
13
+
14
+ # Frontend: node_modules and any local dist are rebuilt by the Node stage.
15
+ web/node_modules
16
+ web/dist
17
+ web/.vite
18
+
19
+ # Large/generated artifacts (weights are fetched in-build; runtime cases are ephemeral).
20
+ models/llm/*.gguf
21
+ models/**/*.onnx
22
+ cases/runtime
23
+ *.log
24
+ .env
.gitattributes ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/ui/music/ambient_theme.mp3 filter=lfs diff=lfs merge=lfs -text
37
+ assets/ui/music/ambient_theme.wav filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .coverage
9
+ htmlcov/
10
+ .venv/
11
+
12
+ # Models and large artifacts (baked at build time, never committed)
13
+ models/llm/*.gguf
14
+ models/**/*.onnx
15
+ assets/voices/*.onnx
16
+ assets/voices/*.onnx.json
17
+
18
+ # Generated runtime data
19
+ cases/runtime/
20
+ .cache/
21
+ *.log
22
+
23
+ # OS
24
+ .DS_Store
25
+ Thumbs.db
26
+
27
+ # Dev screenshots / scratch artifacts
28
+ /*.png
29
+ .playwright-mcp/
30
+ assets/sprites/cache/
31
+
32
+ # Frontend (Vite + Preact) - node_modules and the build output are not committed;
33
+ # the bundle is built in the Docker image (M5).
34
+ web/node_modules/
35
+ web/dist/
36
+ web/.vite/
37
+
38
+ # Local environment overrides (optional; the game runs fully on defaults) - never commit.
39
+ .env
COMPLIANCE.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Case Zero - Hackathon Compliance
2
+
3
+ Built for the **Build Small Hackathon** ("Small models, big adventure").
4
+
5
+ Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
6
+ "Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
7
+ registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
8
+ GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
9
+ app itself is Gradio, served end to end by `gradio.Server`.
10
+
11
+ ## Core requirements
12
+
13
+ | Requirement | Status |
14
+ |---|---|
15
+ | Total model params <= 32B | ✓ ~1.6B (see budget below) |
16
+ | Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
17
+ | Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
18
+ | Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
19
+ | Social-media post | ☐ to post |
20
+
21
+ ## Parameter budget (<= 32B total)
22
+
23
+ Every model is open-weights and self-run. **No third-party AI service is ever called.**
24
+
25
+ | Component | Model | Open? | Params | Runs |
26
+ |---|---|---|---|---|
27
+ | Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
28
+ | Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
29
+ | Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
30
+ | Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
31
+ | Embeddings / vector RAG | none | n/a | 0B | - |
32
+
33
+ **Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
34
+ **Tiny Titan** special award).
35
+
36
+ ## Merit badges
37
+
38
+ ### Earned by the build (verifiable on the Space)
39
+
40
+ - **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
41
+ The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
42
+ rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
43
+ baked into the Docker image at build time, so the running container makes **no AI network
44
+ calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
45
+ socket guard and asserts **zero non-loopback connections**. ✓
46
+ - **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
47
+ through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
48
+ endpoint. ✓
49
+ - **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
50
+ end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
51
+ TypeScript)** - 12 screens, a custom pixel design system (self-hosted Silkscreen /
52
+ Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards, a ruled-paper
53
+ dossier with page-flips), a draggable corkboard, a live interrogation stage with a
54
+ voiced suspect, procedural canvas art and rain FX, and a full client audio layer. The
55
+ built bundle is served as static files by the same `gradio.Server` that exposes the
56
+ `/api` routes - one process, no separate frontend host. ✓
57
+
58
+ ### Targeted / in progress
59
+
60
+ - **Field Notes** - *"Write a blog post or report about your project."* Draft in
61
+ [`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md) - to be published on the Hub.
62
+ - **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
63
+ from."* A captured interrogation/generation trace to be uploaded to the Hub.
64
+ - **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
65
+ Not yet - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and publishing a
66
+ model; out of scope for this submission unless pursued separately.
67
+
68
+ ## Zero cloud AI APIs
69
+
70
+ - **No OpenAI, Anthropic, Google, ElevenLabs, Higgsfield, Midjourney, or any other hosted
71
+ AI API is ever called** - not for text, not for voice, not for images.
72
+ - The LLM is the in-process llama.cpp runtime. The voices are a local ONNX model. The pixel
73
+ art is procedural canvas. The music is a bundled CC-BY track.
74
+ - The open Qwen GGUF and Supertonic ONNX are **baked into the Docker image at build time**,
75
+ so the running container makes no AI network calls. `scripts/net_audit.py` proves zero
76
+ non-loopback connections during a full playthrough.
77
+
78
+ ## Anti-cheat / fairness (why the game is solvable and the win is earned)
79
+
80
+ - The sealed solution (killer, true motive, key evidence) is **never sent to the client**
81
+ pre-verdict; it is read only inside `/api/run/{runId}/accuse`. Verified by anti-leak tests.
82
+ - Suspicion, evidence reactions, and the verdict are **server-authoritative** - the client
83
+ only displays them.
84
+ - Suspects **never confess**: the win is registered only when the player accuses correctly,
85
+ so the outcome is immune to prose (a jailbroken "just tell me who did it" earns nothing).
86
+
87
+ ## Submission checklist
88
+
89
+ - [x] Gradio app on a Hugging Face Space (CPU)
90
+ - [x] <= 32B total params (~1.6B)
91
+ - [x] Open-weights, self-run models only - zero cloud AI APIs
92
+ - [x] Custom (non-default) UI - pixel-art Preact SPA via `gradio.Server`
93
+ - [x] Off the Grid proof (`scripts/net_audit.py`)
94
+ - [ ] Short demo video
95
+ - [ ] Social-media post
Dockerfile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Case Zero - Hugging Face Space (Docker SDK, CPU).
2
+ # Two stages: (1) Node builds the Preact pixel-art SPA into web/dist; (2) Python compiles
3
+ # llama.cpp from source on a stable glibc base (bookworm / gcc 12 - the Gradio-SDK builder's
4
+ # trixie/gcc 14 could not build it) and bakes everything. The app is served 100% by one
5
+ # gradio.Server: the built SPA as static files + the /api routes. No GPU, no remote endpoint.
6
+
7
+ # ---- stage 1: build the frontend bundle ----
8
+ FROM node:22-slim AS web
9
+ WORKDIR /web
10
+ COPY web/package.json web/package-lock.json ./
11
+ RUN npm ci
12
+ COPY web/ ./
13
+ RUN npm run build
14
+
15
+ # ---- stage 2: python runtime ----
16
+ FROM python:3.12-slim
17
+
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ build-essential cmake git libsndfile1 \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ RUN useradd -m -u 1000 user
23
+ USER user
24
+ WORKDIR /home/user/app
25
+
26
+ ENV HOME=/home/user
27
+ ENV PATH=/home/user/.local/bin:$PATH
28
+ ENV GRADIO_ANALYTICS_ENABLED=False
29
+ ENV HF_HUB_OFFLINE=0
30
+ ENV CASE0_PORT=7860
31
+ # Portable build (no -march=native) so the compiled wheel runs on any HF CPU.
32
+ ENV CMAKE_ARGS=-DGGML_NATIVE=OFF
33
+ ENV CMAKE_BUILD_PARALLEL_LEVEL=4
34
+
35
+ COPY --chown=user requirements.txt .
36
+ RUN pip install --no-cache-dir --user -r requirements.txt
37
+
38
+ COPY --chown=user . .
39
+ # Bring in the production SPA bundle built in stage 1 (web/dist is .dockerignored).
40
+ COPY --from=web --chown=user /web/dist ./web/dist
41
+
42
+ # Bake the open weights (LLM GGUF + Supertonic voices) so cold starts are fast and the
43
+ # running container needs no network. Falls back to a runtime fetch if this is skipped.
44
+ RUN python scripts/fetch_models.py || echo "weight prefetch skipped (will fetch at runtime)"
45
+
46
+ EXPOSE 7860
47
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Case Zero
3
+ emoji: 🕵️
4
+ colorFrom: indigo
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ license: apache-2.0
10
+ models:
11
+ - Qwen/Qwen2.5-1.5B-Instruct
12
+ tags:
13
+ - build-small-hackathon
14
+ - llama-cpp
15
+ - tiny-titan
16
+ - detective-game
17
+ - text-generation
18
+ - tts
19
+ ---
20
+
21
+ # 🕵️ Case Zero — the AI *is* the detective game
22
+
23
+ **A brand-new murder mystery, written and acted by a 1.5B model, every single time.**
24
+
25
+ No scripted cases. No content library. A single small local model invents the whole
26
+ thing — the victim, the suspects, their secrets and motives, the timeline, the murder
27
+ weapon, the evidence, and the one who did it — then **role-plays every suspect live**.
28
+ They remember what you asked. They lie to your face. And when you slap down the right
29
+ piece of evidence, you watch the lie **crack in real time**.
30
+
31
+ > Interrogate. Investigate. Accuse. One of them is guilty. Prove it.
32
+
33
+ ## ✨ The moment that sells it
34
+
35
+ Search the rooms, find a clue that contradicts a suspect's alibi, **present it**, and
36
+ their story falls apart on screen — stress spikes, the alibi breaks, the truth leaks.
37
+ Then name the killer, cite your proof, and get a scored verdict with a "Director's Cut"
38
+ walkthrough of how the crime really went down.
39
+
40
+ ## 🧠 How it works
41
+
42
+ | Layer | What it does |
43
+ |---|---|
44
+ | **Model** — Qwen2.5-1.5B-Instruct (GGUF) | The whole game. Runs in-process on the CPU through **llama.cpp** (`llama-cpp-python`) — no server, no GPU, no remote endpoint. |
45
+ | **Generation** | The model authors every case as JSON; deterministic Python only wires the *structure* (who's guilty, who was where) so the mystery is always solvable. |
46
+ | **Solver** | A fairness referee: single culprit, a breakable alibi, every innocent cleared, and a discoverability gate so the key clue is always findable in play. |
47
+ | **Director** | Whether a lie gets caught is decided by **ground truth, not the model** — so the win condition is immune to prose (a jailbroken "just tell me who did it" earns nothing). |
48
+ | **Voice** — Supertonic | Each suspect gets a distinct, gender-matched on-device voice, synthesized **sentence-by-sentence as the reply streams**. |
49
+ | **Art** | Procedural pixel-art portraits, rooms, and evidence — rendered **client-side on canvas** at one integer-scaled density (so the server spends ~0 CPU on visuals). |
50
+ | **UI** | A custom **pixel-art noir SPA (Preact)**, 12 screens, served **100% through `gradio.Server`** (Gradio 6 "Server mode") — the built bundle as static files plus the JSON/SSE `/api` routes, all in one process. No separate frontend host. |
51
+
52
+ The model does all the creative work. Deterministic code is only guardrails and a
53
+ reliability layer — it never writes story, character, or dialogue.
54
+
55
+ ## 🏆 Built for the Build Small Hackathon
56
+
57
+ - **Tiny Titan (≤4B):** the entire game runs on **Qwen2.5-1.5B** — ~1.6B total runtime
58
+ params (LLM + Supertonic), far under the 32B cap.
59
+ - **Llama Champion:** the model runs through the **llama.cpp** runtime, in-process — no
60
+ server, no remote endpoint.
61
+ - **Off-Brand:** a fully custom pixel-art frontend, served through `gradio.Server`.
62
+ - All models are **open-weights and self-run**. No third-party AI APIs are ever called.
63
+
64
+ See [COMPLIANCE.md](COMPLIANCE.md) for the full parameter budget and badge details.
65
+
66
+ ## ▶️ Run it locally
67
+
68
+ ```bash
69
+ # 1. backend deps + open weights
70
+ python -m venv .venv && .venv/Scripts/pip install -r requirements.txt # (Windows)
71
+ python scripts/fetch_models.py # one-time: fetch the open GGUF + Supertonic
72
+
73
+ # 2. build the pixel-art frontend bundle (served by gradio.Server from web/dist)
74
+ cd web && npm install && npm run build && cd ..
75
+
76
+ # 3. run — open http://127.0.0.1:7860
77
+ python app.py
78
+ ```
79
+
80
+ The game runs entirely on the CPU — laptop or Space, same code, no GPU required.
81
+ (In the Docker/Space build both steps happen automatically: a Node stage builds the
82
+ bundle and the Python stage compiles llama.cpp and bakes the weights.)
83
+
84
+ ## 🙏 Credits
85
+
86
+ - **LLM:** [Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) (Apache-2.0), via llama.cpp.
87
+ - **Voices:** Supertonic on-device TTS.
88
+ - **Music:** *"Backbay Lounge"* by Kevin MacLeod (incompetech.com), licensed under
89
+ [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/).
90
+ - **Fonts:** Silkscreen & Pixelify Sans (SIL Open Font License), self-hosted.
91
+ - Pixel art and UI sound effects: procedurally generated.
app.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Case Zero entrypoint - one ``gradio.Server`` for a Hugging Face (CPU) Space.
2
+
3
+ The pixel-art frontend (built Preact bundle in ``web/dist``) and the game's JSON/SSE API
4
+ are both served by a single ``gradio.Server`` (a FastAPI subclass). The LLM and TTS run
5
+ in-process on the CPU via llama.cpp / Supertonic - no GPU, no inference API, fully local.
6
+
7
+ Gradio's own frontend and Node SSR proxy are disabled (``_frontend=False``,
8
+ ``ssr_mode=False``) so our SPA owns ``/`` and we don't pay the node-proxy CPU cost on the
9
+ 2-vCPU Space.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # Make src importable whether run from the repo root or as a Space.
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
20
+
21
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
22
+
23
+
24
+ def _ensure_weights() -> None:
25
+ """Download the LLM GGUF once if it is not already on disk (Spaces have no baked
26
+ weights). A no-op locally where the file already exists. Invoked lazily on first
27
+ real generation (M1+), not at boot, so the server starts instantly."""
28
+ from case_zero.config import get_settings
29
+
30
+ settings = get_settings()
31
+ if settings.llm_model_path.exists():
32
+ return
33
+ try:
34
+ import shutil
35
+
36
+ from huggingface_hub import hf_hub_download
37
+
38
+ dest = settings.llm_model_path
39
+ dest.parent.mkdir(parents=True, exist_ok=True)
40
+ cached = hf_hub_download(
41
+ repo_id="Qwen/Qwen2.5-1.5B-Instruct-GGUF",
42
+ filename="qwen2.5-1.5b-instruct-q4_k_m.gguf",
43
+ )
44
+ shutil.copy(cached, dest)
45
+ print(f"[startup] fetched LLM weights -> {dest}")
46
+ except Exception as exc: # pragma: no cover
47
+ print(f"[startup] weight fetch failed: {exc}", file=sys.stderr)
48
+
49
+
50
+ def main() -> None:
51
+ from case_zero.api import build_server
52
+ from case_zero.api.runtime import RUNTIME
53
+
54
+ # Fetch weights if needed, then prebuild one case in the background so the first
55
+ # "New Case" is ready (or nearly) by the time a detective connects.
56
+ _ensure_weights()
57
+ RUNTIME.start_buffer()
58
+
59
+ server = build_server()
60
+ server.launch(
61
+ server_name="0.0.0.0",
62
+ server_port=int(os.environ.get("CASE0_PORT", "7860")),
63
+ share=False,
64
+ ssr_mode=False,
65
+ _frontend=False,
66
+ )
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
assets/fonts/body.woff2 ADDED
Binary file (7.69 kB). View file
 
assets/fonts/heading.woff2 ADDED
Binary file (8.4 kB). View file
 
assets/ui/music/ambient_theme.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1adaff8da50ff31e2b813b7d7886a0f674d777d0e4ee95c526c326a925e68c3d
3
+ size 985453
assets/ui/music/ambient_theme.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7a7f34402a5e90b90602d1517650dbf60cef2155fb6cd3dff7ecd843607104e4
3
+ size 705644
assets/ui/sfx/accuse.wav ADDED
Binary file (22.1 kB). View file
 
assets/ui/sfx/click.wav ADDED
Binary file (2.69 kB). View file
 
assets/ui/sfx/fail.wav ADDED
Binary file (21.2 kB). View file
 
assets/ui/sfx/page.wav ADDED
Binary file (7.98 kB). View file
 
assets/ui/sfx/present.wav ADDED
Binary file (9.75 kB). View file
 
assets/ui/sfx/select.wav ADDED
Binary file (6.22 kB). View file
 
assets/ui/sfx/success.wav ADDED
Binary file (21.2 kB). View file
 
cases/prebaked/CASE-0001.json ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_id": "CASE-0001",
3
+ "seed": 43004,
4
+ "schema_version": "1.0",
5
+ "title": "The Mysterious Vanishing",
6
+ "briefing": "A wealthy socialite vanishes mysteriously from her luxurious estate, leaving behind a cryptic note and no trace of the weapon used in the murder.",
7
+ "knobs": {
8
+ "setting_hint": "",
9
+ "era_hint": "",
10
+ "tone_hint": "",
11
+ "n_suspects": 4,
12
+ "n_red_herrings": 2,
13
+ "alibi_tightness": 0.6,
14
+ "difficulty": "standard"
15
+ },
16
+ "setting": {
17
+ "name": "Oakwood Manor",
18
+ "description": "",
19
+ "locations": [
20
+ {
21
+ "loc_id": "L1",
22
+ "name": "Entrance Hall",
23
+ "description": "",
24
+ "adjacent_to": [
25
+ "L2",
26
+ "L3",
27
+ "L4"
28
+ ]
29
+ },
30
+ {
31
+ "loc_id": "L2",
32
+ "name": "Garden Room",
33
+ "description": "",
34
+ "adjacent_to": [
35
+ "L1"
36
+ ]
37
+ },
38
+ {
39
+ "loc_id": "L3",
40
+ "name": "Dining Room",
41
+ "description": "",
42
+ "adjacent_to": [
43
+ "L1"
44
+ ]
45
+ },
46
+ {
47
+ "loc_id": "L4",
48
+ "name": "Study",
49
+ "description": "",
50
+ "adjacent_to": [
51
+ "L1"
52
+ ]
53
+ }
54
+ ],
55
+ "murder_window": {
56
+ "start_min": 1260,
57
+ "end_min": 1320
58
+ }
59
+ },
60
+ "victim": {
61
+ "vic_id": "V1",
62
+ "name": "Isabella Whitcomb",
63
+ "role": "Society Lady",
64
+ "found_at_loc_id": "L3",
65
+ "found_at_min": 1325,
66
+ "cause_of_death": "Struck with a hidden dagger by an unseen assailant in her Study at 21:45.",
67
+ "time_of_death": {
68
+ "start_min": 1280,
69
+ "end_min": 1310
70
+ }
71
+ },
72
+ "weapon": {
73
+ "weapon_id": "W1",
74
+ "name": "Silent Dagger",
75
+ "kind": "Blade, concealed as a candlestick on the mantelpiece",
76
+ "origin_loc_id": "L3",
77
+ "requires_strength": false,
78
+ "leaves_trace": ""
79
+ },
80
+ "suspects": [
81
+ {
82
+ "sus_id": "S1",
83
+ "name": "Charles Rivington",
84
+ "role": "Billionaire Business Partner",
85
+ "persona_summary": "A charming yet ruthless man who keeps secrets close to his chest.",
86
+ "demeanour": "hostile and defensive, bristling at any hint of suspicion",
87
+ "is_culprit": true,
88
+ "physical_capability": {
89
+ "strength": true,
90
+ "mobility": true
91
+ },
92
+ "personality": {
93
+ "composure": 0.55,
94
+ "aggression": 0.88,
95
+ "evasiveness": 0.4
96
+ },
97
+ "tells": [
98
+ "I don't have secrets, just a perfect facade for the world outside this house."
99
+ ],
100
+ "knows_facts": [
101
+ "F_scene",
102
+ "F_sec1"
103
+ ],
104
+ "secrets": [
105
+ "He is secretly involved in illegal activities that he wants no one to know about, fearing the law will reveal too much about his reputation and business."
106
+ ],
107
+ "true_whereabouts": [
108
+ {
109
+ "window": {
110
+ "start_min": 1260,
111
+ "end_min": 1280
112
+ },
113
+ "loc_id": "L4",
114
+ "activity": "mingling in plain sight",
115
+ "co_present_sus_ids": []
116
+ },
117
+ {
118
+ "window": {
119
+ "start_min": 1280,
120
+ "end_min": 1310
121
+ },
122
+ "loc_id": "L3",
123
+ "activity": "alone with the victim",
124
+ "co_present_sus_ids": []
125
+ }
126
+ ],
127
+ "stated_alibi": {
128
+ "claim_text": "I am currently in the Study writing a report on our business strategy.",
129
+ "claimed_segments": [
130
+ {
131
+ "window": {
132
+ "start_min": 1260,
133
+ "end_min": 1320
134
+ },
135
+ "loc_id": "L4",
136
+ "witness_sus_ids": []
137
+ }
138
+ ]
139
+ },
140
+ "must_lie_about": [
141
+ "F_scene"
142
+ ],
143
+ "anchored_lies": [
144
+ {
145
+ "lie_id": "LIE_alibi",
146
+ "topic": "where you were during the murder",
147
+ "claimed": "I am currently in the Study writing a report on our business strategy.",
148
+ "truth_ref": "F_scene",
149
+ "breaks_on": [
150
+ "C_b1",
151
+ "C_b2"
152
+ ],
153
+ "fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
154
+ }
155
+ ],
156
+ "voice": null,
157
+ "visual": {
158
+ "subject_type": "suspect",
159
+ "palette": "noir",
160
+ "gender": "male",
161
+ "age_band": "50s",
162
+ "build": null,
163
+ "hair": null,
164
+ "attire": "Tuxedo and polished shoes",
165
+ "mood": "tense",
166
+ "accent_color": "#b8860b",
167
+ "location_tags": [],
168
+ "prop_tags": [],
169
+ "prompt_hint": "Fitted suit with a sharp, intimidating demeanor, Tuxedo and polished shoes"
170
+ }
171
+ },
172
+ {
173
+ "sus_id": "S2",
174
+ "name": "Sophia Holmes",
175
+ "role": "Deceased Lover of Isabella Whitcomb",
176
+ "persona_summary": "A quiet and gentle woman who kept her love life private.",
177
+ "demeanour": "composed and cooperative on the surface, carefully measured",
178
+ "is_culprit": false,
179
+ "physical_capability": {
180
+ "strength": true,
181
+ "mobility": true
182
+ },
183
+ "personality": {
184
+ "composure": 0.72,
185
+ "aggression": 0.4,
186
+ "evasiveness": 0.3
187
+ },
188
+ "tells": [
189
+ "I am not a villain, my heart is in the right place."
190
+ ],
191
+ "knows_facts": [
192
+ "F_sec2"
193
+ ],
194
+ "secrets": [
195
+ "She secretly loved Isabella deeply, but chose to be faithful to Charles for his wealth. This secret is hidden in the attic of her childhood home."
196
+ ],
197
+ "true_whereabouts": [
198
+ {
199
+ "window": {
200
+ "start_min": 1260,
201
+ "end_min": 1320
202
+ },
203
+ "loc_id": "L2",
204
+ "activity": "going about the evening",
205
+ "co_present_sus_ids": []
206
+ }
207
+ ],
208
+ "stated_alibi": {
209
+ "claim_text": "I was in Garden Room the whole time.",
210
+ "claimed_segments": [
211
+ {
212
+ "window": {
213
+ "start_min": 1260,
214
+ "end_min": 1320
215
+ },
216
+ "loc_id": "L2",
217
+ "witness_sus_ids": []
218
+ }
219
+ ]
220
+ },
221
+ "must_lie_about": [
222
+ "F_sec2"
223
+ ],
224
+ "anchored_lies": [
225
+ {
226
+ "lie_id": "LIE_sec2",
227
+ "topic": "She secretly loved Isabella deeply, but chose to",
228
+ "claimed": "Her death was a heart attack related to stress over their marriage. The dagger she held belonged to an old family heirloom.",
229
+ "truth_ref": "F_sec2",
230
+ "breaks_on": [
231
+ "C_h2"
232
+ ],
233
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
234
+ }
235
+ ],
236
+ "voice": null,
237
+ "visual": {
238
+ "subject_type": "suspect",
239
+ "palette": "noir",
240
+ "gender": "female",
241
+ "age_band": "20s",
242
+ "build": null,
243
+ "hair": null,
244
+ "attire": "Formal attire with delicate jewelry",
245
+ "mood": "tense",
246
+ "accent_color": "#3a6ea5",
247
+ "location_tags": [],
248
+ "prop_tags": [],
249
+ "prompt_hint": "Gentle demeanor, wearing white lace gloves for evening, Formal attire with delicate jewelry"
250
+ }
251
+ },
252
+ {
253
+ "sus_id": "S3",
254
+ "name": "Arthur Reed",
255
+ "role": "Former Business Rival of Charles Rivington",
256
+ "persona_summary": "An unscrupulous businessman who often played dirty tricks on his competitors.",
257
+ "demeanour": "rattled and evasive, voice tightening under pressure",
258
+ "is_culprit": false,
259
+ "physical_capability": {
260
+ "strength": true,
261
+ "mobility": true
262
+ },
263
+ "personality": {
264
+ "composure": 0.32,
265
+ "aggression": 0.55,
266
+ "evasiveness": 0.68
267
+ },
268
+ "tells": [
269
+ "I have no secrets, just a lack of time to plan my next scheme."
270
+ ],
271
+ "knows_facts": [
272
+ "F_sec3"
273
+ ],
274
+ "secrets": [
275
+ "He secretly owned a large corporation that was funding the illegal activities Charles financed, hoping to eliminate competition through deceitful means."
276
+ ],
277
+ "true_whereabouts": [
278
+ {
279
+ "window": {
280
+ "start_min": 1260,
281
+ "end_min": 1320
282
+ },
283
+ "loc_id": "L4",
284
+ "activity": "going about the evening",
285
+ "co_present_sus_ids": []
286
+ }
287
+ ],
288
+ "stated_alibi": {
289
+ "claim_text": "I was in Study the whole time.",
290
+ "claimed_segments": [
291
+ {
292
+ "window": {
293
+ "start_min": 1260,
294
+ "end_min": 1320
295
+ },
296
+ "loc_id": "L4",
297
+ "witness_sus_ids": []
298
+ }
299
+ ]
300
+ },
301
+ "must_lie_about": [
302
+ "F_sec3"
303
+ ],
304
+ "anchored_lies": [
305
+ {
306
+ "lie_id": "LIE_sec3",
307
+ "topic": "He secretly owned a large corporation that was f",
308
+ "claimed": "His business rivalship with Charles led to an argument at their annual meeting. He used an old map as evidence of Charles's guilt.",
309
+ "truth_ref": "F_sec3",
310
+ "breaks_on": [
311
+ "C_h3"
312
+ ],
313
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
314
+ }
315
+ ],
316
+ "voice": null,
317
+ "visual": {
318
+ "subject_type": "suspect",
319
+ "palette": "noir",
320
+ "gender": "male",
321
+ "age_band": "50s",
322
+ "build": null,
323
+ "hair": null,
324
+ "attire": "Corporate suit and formal tie",
325
+ "mood": "tense",
326
+ "accent_color": "#9a9aa0",
327
+ "location_tags": [],
328
+ "prop_tags": [],
329
+ "prompt_hint": "Business attire with a cold stare, Corporate suit and formal tie"
330
+ }
331
+ },
332
+ {
333
+ "sus_id": "S4",
334
+ "name": "Elizabeth Taylor",
335
+ "role": "Servant in Isabella's Household",
336
+ "persona_summary": "A reliable but unremarkable housemaid with a strict job description.",
337
+ "demeanour": "guarded and weary, giving away as little as possible",
338
+ "is_culprit": false,
339
+ "physical_capability": {
340
+ "strength": true,
341
+ "mobility": true
342
+ },
343
+ "personality": {
344
+ "composure": 0.6,
345
+ "aggression": 0.25,
346
+ "evasiveness": 0.55
347
+ },
348
+ "tells": [
349
+ "I am just a servant, doing my job."
350
+ ],
351
+ "knows_facts": [
352
+ "F_sec4"
353
+ ],
354
+ "secrets": [
355
+ "She was secretly part of an organization that had been spying on her employer for years, leading to the death of several wealthy women they were investigating. This knowledge kept hidden from her due to fear and lack of evidence against herself."
356
+ ],
357
+ "true_whereabouts": [
358
+ {
359
+ "window": {
360
+ "start_min": 1260,
361
+ "end_min": 1320
362
+ },
363
+ "loc_id": "L1",
364
+ "activity": "going about the evening",
365
+ "co_present_sus_ids": []
366
+ }
367
+ ],
368
+ "stated_alibi": {
369
+ "claim_text": "I was in Entrance Hall the whole time.",
370
+ "claimed_segments": [
371
+ {
372
+ "window": {
373
+ "start_min": 1260,
374
+ "end_min": 1320
375
+ },
376
+ "loc_id": "L1",
377
+ "witness_sus_ids": []
378
+ }
379
+ ]
380
+ },
381
+ "must_lie_about": [
382
+ "F_sec4"
383
+ ],
384
+ "anchored_lies": [
385
+ {
386
+ "lie_id": "LIE_sec4",
387
+ "topic": "She was secretly part of an organization that ha",
388
+ "claimed": "Her mistress believed she was just a devoted housemaid and found her death under mysterious circumstances in the garden.",
389
+ "truth_ref": "F_sec4",
390
+ "breaks_on": [
391
+ "C_h4"
392
+ ],
393
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
394
+ }
395
+ ],
396
+ "voice": null,
397
+ "visual": {
398
+ "subject_type": "suspect",
399
+ "palette": "noir",
400
+ "gender": "female",
401
+ "age_band": "20s",
402
+ "build": null,
403
+ "hair": null,
404
+ "attire": "Simple evening gown",
405
+ "mood": "tense",
406
+ "accent_color": "#6b8f71",
407
+ "location_tags": [],
408
+ "prop_tags": [],
409
+ "prompt_hint": "Well-groomed but not overly enthusiastic with work, Simple evening gown"
410
+ }
411
+ }
412
+ ],
413
+ "culprit": {
414
+ "sus_id": "S1",
415
+ "true_motive": {
416
+ "motive_id": "M1",
417
+ "category": "revenge",
418
+ "summary": "Charles Rivington murdered Isabella Whitcomb in the Dining Room as a means to exact revenge against her for publicly revealing his affair with her."
419
+ },
420
+ "method_narrative": "Rivington used the Silent Dagger, which could be seen through the dining room window, to commit the murder when no one was around. He then fabricated the alibi of being in the Study to cover up his actions.",
421
+ "alibi_lie": {
422
+ "claimed_loc_id": "L4",
423
+ "actual_loc_id": "L3",
424
+ "contradicted_by_clue_ids": [
425
+ "C_b1",
426
+ "C_b2"
427
+ ]
428
+ }
429
+ },
430
+ "relationships": [],
431
+ "facts": [
432
+ {
433
+ "fact_id": "F_scene",
434
+ "statement": "Charles Rivington was in Dining Room during the murder.",
435
+ "true_value": true,
436
+ "loc_id": "L3",
437
+ "at_min": 1280
438
+ },
439
+ {
440
+ "fact_id": "F_sec1",
441
+ "statement": "He is secretly involved in illegal activities that he wants no one to know about, fearing the law will reveal too much about his reputation and business.",
442
+ "true_value": true,
443
+ "loc_id": null,
444
+ "at_min": null
445
+ },
446
+ {
447
+ "fact_id": "F_sec2",
448
+ "statement": "She secretly loved Isabella deeply, but chose to be faithful to Charles for his wealth. This secret is hidden in the attic of her childhood home.",
449
+ "true_value": true,
450
+ "loc_id": null,
451
+ "at_min": null
452
+ },
453
+ {
454
+ "fact_id": "F_sec3",
455
+ "statement": "He secretly owned a large corporation that was funding the illegal activities Charles financed, hoping to eliminate competition through deceitful means.",
456
+ "true_value": true,
457
+ "loc_id": null,
458
+ "at_min": null
459
+ },
460
+ {
461
+ "fact_id": "F_sec4",
462
+ "statement": "She was secretly part of an organization that had been spying on her employer for years, leading to the death of several wealthy women they were investigating. This knowledge kept hidden from her due to fear and lack of evidence against herself.",
463
+ "true_value": true,
464
+ "loc_id": null,
465
+ "at_min": null
466
+ }
467
+ ],
468
+ "clues": [
469
+ {
470
+ "clue_id": "C_b1",
471
+ "name": "Dropped cufflink",
472
+ "reveal_text": "A monogrammed cufflink wedged under the rug, dropped in haste.",
473
+ "discoverable_at_loc_id": "L3",
474
+ "discovery_method": "forensic",
475
+ "supports_fact_id": "F_scene",
476
+ "points_to_sus_id": "S1",
477
+ "contradicts_alibi_of": "S1",
478
+ "is_red_herring": false,
479
+ "weight": 1.0
480
+ },
481
+ {
482
+ "clue_id": "C_b2",
483
+ "name": "Partial fingerprint",
484
+ "reveal_text": "A fresh partial fingerprint on the silent dagger, unaccounted for among the guests.",
485
+ "discoverable_at_loc_id": "L1",
486
+ "discovery_method": "forensic",
487
+ "supports_fact_id": "F_scene",
488
+ "points_to_sus_id": "S1",
489
+ "contradicts_alibi_of": "S1",
490
+ "is_red_herring": false,
491
+ "weight": 0.7
492
+ },
493
+ {
494
+ "clue_id": "C_h2",
495
+ "name": "A small photograph of Isabella, aged 16",
496
+ "reveal_text": "The photo has been replaced with a fake, and the original lies in her late mother's attic, containing love letters from both Sophia and Charles.",
497
+ "discoverable_at_loc_id": "L2",
498
+ "discovery_method": "search",
499
+ "supports_fact_id": "F_sec2",
500
+ "points_to_sus_id": "S2",
501
+ "contradicts_alibi_of": null,
502
+ "is_red_herring": true,
503
+ "weight": 0.3
504
+ },
505
+ {
506
+ "clue_id": "C_h3",
507
+ "name": "Old photograph",
508
+ "reveal_text": "A worn photograph they would rather no one had seen.",
509
+ "discoverable_at_loc_id": "L4",
510
+ "discovery_method": "search",
511
+ "supports_fact_id": "F_sec3",
512
+ "points_to_sus_id": "S3",
513
+ "contradicts_alibi_of": null,
514
+ "is_red_herring": true,
515
+ "weight": 0.3
516
+ },
517
+ {
518
+ "clue_id": "C_h4",
519
+ "name": "A small keychain with no initials but containing information about an organization's codes",
520
+ "reveal_text": "The keychain had been left behind when another servant tried to take the code for a secret meeting of the group, leading Elizabeth to use her position to access the information.",
521
+ "discoverable_at_loc_id": "L1",
522
+ "discovery_method": "search",
523
+ "supports_fact_id": "F_sec4",
524
+ "points_to_sus_id": "S4",
525
+ "contradicts_alibi_of": null,
526
+ "is_red_herring": true,
527
+ "weight": 0.3
528
+ }
529
+ ],
530
+ "solution": {
531
+ "culprit_sus_id": "S1",
532
+ "weapon_id": "W1",
533
+ "motive_id": "M1",
534
+ "minimal_clue_set": [
535
+ "C_b1"
536
+ ],
537
+ "deduction_chain": [
538
+ "Rivington planted the Silent Dagger through the dining room window on the night of the murder to cover his tracks.",
539
+ "The glass dropped by Rivington during the crime can be traced back to the dining room, indicating he was still there when Isabella was murdered.",
540
+ "The torn fiber from the carpet near the dining room window is consistent with him being in the Study at that time, supporting the false claim of his alibi."
541
+ ]
542
+ }
543
+ }
cases/prebaked/CASE-0002.json ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_id": "CASE-0002",
3
+ "seed": 43015,
4
+ "schema_version": "1.0",
5
+ "title": "The Biblioteca Romantica Verdict",
6
+ "briefing": "A mysterious murder occurred in the dimly lit halls of the Whispering Library, where shadows dance on the walls and secrets linger.",
7
+ "knobs": {
8
+ "setting_hint": "",
9
+ "era_hint": "",
10
+ "tone_hint": "",
11
+ "n_suspects": 4,
12
+ "n_red_herrings": 2,
13
+ "alibi_tightness": 0.6,
14
+ "difficulty": "standard"
15
+ },
16
+ "setting": {
17
+ "name": "Whispering Library",
18
+ "description": "",
19
+ "locations": [
20
+ {
21
+ "loc_id": "L1",
22
+ "name": "Entrance Hall",
23
+ "description": "",
24
+ "adjacent_to": [
25
+ "L2",
26
+ "L3",
27
+ "L4"
28
+ ]
29
+ },
30
+ {
31
+ "loc_id": "L2",
32
+ "name": "Mystic Room",
33
+ "description": "",
34
+ "adjacent_to": [
35
+ "L1"
36
+ ]
37
+ },
38
+ {
39
+ "loc_id": "L3",
40
+ "name": "Biblioteca Romantica",
41
+ "description": "",
42
+ "adjacent_to": [
43
+ "L1"
44
+ ]
45
+ },
46
+ {
47
+ "loc_id": "L4",
48
+ "name": "Coffeeshop",
49
+ "description": "",
50
+ "adjacent_to": [
51
+ "L1"
52
+ ]
53
+ }
54
+ ],
55
+ "murder_window": {
56
+ "start_min": 1260,
57
+ "end_min": 1320
58
+ }
59
+ },
60
+ "victim": {
61
+ "vic_id": "V1",
62
+ "name": "Isabella Whitmore",
63
+ "role": "Author and Writer",
64
+ "found_at_loc_id": "L3",
65
+ "found_at_min": 1325,
66
+ "cause_of_death": "Poisoned with an ancient elixir from the library's oldest collection",
67
+ "time_of_death": {
68
+ "start_min": 1280,
69
+ "end_min": 1310
70
+ }
71
+ },
72
+ "weapon": {
73
+ "weapon_id": "W1",
74
+ "name": "Engraved Dagger",
75
+ "kind": "Silver",
76
+ "origin_loc_id": "L3",
77
+ "requires_strength": false,
78
+ "leaves_trace": ""
79
+ },
80
+ "suspects": [
81
+ {
82
+ "sus_id": "S1",
83
+ "name": "Thomas Reed",
84
+ "role": "Rival Writer",
85
+ "persona_summary": "A calculating and ambitious rival, Thomas always seems to find ways to undermine Isabella's work.",
86
+ "demeanour": "visibly frightened and on edge, dreading every question",
87
+ "is_culprit": false,
88
+ "physical_capability": {
89
+ "strength": true,
90
+ "mobility": true
91
+ },
92
+ "personality": {
93
+ "composure": 0.2,
94
+ "aggression": 0.3,
95
+ "evasiveness": 0.7
96
+ },
97
+ "tells": [
98
+ "The dagger was given to me on my mother’s request, but now I regret not giving it to her."
99
+ ],
100
+ "knows_facts": [
101
+ "F_sec1"
102
+ ],
103
+ "secrets": [
104
+ "He was secretly developing a similar poison for personal vendetta against Isabella."
105
+ ],
106
+ "true_whereabouts": [
107
+ {
108
+ "window": {
109
+ "start_min": 1260,
110
+ "end_min": 1320
111
+ },
112
+ "loc_id": "L1",
113
+ "activity": "going about the evening",
114
+ "co_present_sus_ids": []
115
+ }
116
+ ],
117
+ "stated_alibi": {
118
+ "claim_text": "I was in Entrance Hall the whole time.",
119
+ "claimed_segments": [
120
+ {
121
+ "window": {
122
+ "start_min": 1260,
123
+ "end_min": 1320
124
+ },
125
+ "loc_id": "L1",
126
+ "witness_sus_ids": []
127
+ }
128
+ ]
129
+ },
130
+ "must_lie_about": [
131
+ "F_sec1"
132
+ ],
133
+ "anchored_lies": [
134
+ {
135
+ "lie_id": "LIE_sec1",
136
+ "topic": "He was secretly developing a similar poison for ",
137
+ "claimed": "This dagger is a gift from his mother, honoring her late husband, who inspired the poem he wrote about. He regrets not giving it to his wife but sees this as compensation.",
138
+ "truth_ref": "F_sec1",
139
+ "breaks_on": [
140
+ "C_h1"
141
+ ],
142
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
143
+ }
144
+ ],
145
+ "voice": null,
146
+ "visual": {
147
+ "subject_type": "suspect",
148
+ "palette": "noir",
149
+ "gender": "male",
150
+ "age_band": "50s",
151
+ "build": null,
152
+ "hair": null,
153
+ "attire": "Silk robes with a silver brooch depicting his wife and her husband's urn. He has a stoic look that hides a deep sorrow.",
154
+ "mood": "tense",
155
+ "accent_color": "#b8860b",
156
+ "location_tags": [],
157
+ "prop_tags": [],
158
+ "prompt_hint": "Steady, bookish, 40s, dressed in dark dress robes., Silk robes with a silver brooch depicting his wife and her husband's urn. He has a stoic look that hides a deep sorrow."
159
+ }
160
+ },
161
+ {
162
+ "sus_id": "S2",
163
+ "name": "Kendall Black",
164
+ "role": "Rival's Rival",
165
+ "persona_summary": "A quiet and level-headed sibling who sees Thomas as a threat.",
166
+ "demeanour": "hostile and defensive, bristling at any hint of suspicion",
167
+ "is_culprit": false,
168
+ "physical_capability": {
169
+ "strength": true,
170
+ "mobility": true
171
+ },
172
+ "personality": {
173
+ "composure": 0.55,
174
+ "aggression": 0.88,
175
+ "evasiveness": 0.4
176
+ },
177
+ "tells": [
178
+ "I have no secrets, just a love for my late father’s poetry."
179
+ ],
180
+ "knows_facts": [
181
+ "F_sec2"
182
+ ],
183
+ "secrets": [
184
+ "She was secretly working on the elixir together with Thomas for personal interest in poetry."
185
+ ],
186
+ "true_whereabouts": [
187
+ {
188
+ "window": {
189
+ "start_min": 1260,
190
+ "end_min": 1320
191
+ },
192
+ "loc_id": "L2",
193
+ "activity": "going about the evening",
194
+ "co_present_sus_ids": []
195
+ }
196
+ ],
197
+ "stated_alibi": {
198
+ "claim_text": "I was in Mystic Room the whole time.",
199
+ "claimed_segments": [
200
+ {
201
+ "window": {
202
+ "start_min": 1260,
203
+ "end_min": 1320
204
+ },
205
+ "loc_id": "L2",
206
+ "witness_sus_ids": []
207
+ }
208
+ ]
209
+ },
210
+ "must_lie_about": [
211
+ "F_sec2"
212
+ ],
213
+ "anchored_lies": [
214
+ {
215
+ "lie_id": "LIE_sec2",
216
+ "topic": "She was secretly working on the elixir together ",
217
+ "claimed": "Her cover story is to show respect, but her passion for poetry is evident from her bookish appearance and love of rare books.",
218
+ "truth_ref": "F_sec2",
219
+ "breaks_on": [
220
+ "C_h2"
221
+ ],
222
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
223
+ }
224
+ ],
225
+ "voice": null,
226
+ "visual": {
227
+ "subject_type": "suspect",
228
+ "palette": "noir",
229
+ "gender": "female",
230
+ "age_band": "20s",
231
+ "build": null,
232
+ "hair": null,
233
+ "attire": "A vintage dress and a soft leather coat that fits her slender figure.",
234
+ "mood": "tense",
235
+ "accent_color": "#3a6ea5",
236
+ "location_tags": [],
237
+ "prop_tags": [],
238
+ "prompt_hint": "Flaxen-haired, wearing simple attire as she carries an old book with her., A vintage dress and a soft leather coat that fits her slender figure."
239
+ }
240
+ },
241
+ {
242
+ "sus_id": "S3",
243
+ "name": "Henry Mason",
244
+ "role": "Business Partner",
245
+ "persona_summary": "A pragmatic businessman who sees the library as a place of knowledge and learning.",
246
+ "demeanour": "composed and cooperative on the surface, carefully measured",
247
+ "is_culprit": false,
248
+ "physical_capability": {
249
+ "strength": true,
250
+ "mobility": true
251
+ },
252
+ "personality": {
253
+ "composure": 0.72,
254
+ "aggression": 0.4,
255
+ "evasiveness": 0.3
256
+ },
257
+ "tells": [
258
+ "I don’t see any reason to worry, my name is listed as the borrower in this contract."
259
+ ],
260
+ "knows_facts": [
261
+ "F_sec3"
262
+ ],
263
+ "secrets": [
264
+ "He was involved in Isabella's manuscript with an ulterior motive, attempting to find loopholes to cheat her financially."
265
+ ],
266
+ "true_whereabouts": [
267
+ {
268
+ "window": {
269
+ "start_min": 1260,
270
+ "end_min": 1320
271
+ },
272
+ "loc_id": "L4",
273
+ "activity": "going about the evening",
274
+ "co_present_sus_ids": []
275
+ }
276
+ ],
277
+ "stated_alibi": {
278
+ "claim_text": "I was in Coffeeshop the whole time.",
279
+ "claimed_segments": [
280
+ {
281
+ "window": {
282
+ "start_min": 1260,
283
+ "end_min": 1320
284
+ },
285
+ "loc_id": "L4",
286
+ "witness_sus_ids": []
287
+ }
288
+ ]
289
+ },
290
+ "must_lie_about": [
291
+ "F_sec3"
292
+ ],
293
+ "anchored_lies": [
294
+ {
295
+ "lie_id": "LIE_sec3",
296
+ "topic": "He was involved in Isabella's manuscript with an",
297
+ "claimed": "His cover story is about his interest in literature and how he respects her work, but there’s always a chance he may have made mistakes.",
298
+ "truth_ref": "F_sec3",
299
+ "breaks_on": [
300
+ "C_h3"
301
+ ],
302
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
303
+ }
304
+ ],
305
+ "voice": null,
306
+ "visual": {
307
+ "subject_type": "suspect",
308
+ "palette": "noir",
309
+ "gender": "male",
310
+ "age_band": "50s",
311
+ "build": null,
312
+ "hair": null,
313
+ "attire": "Business attire with a pocket watch on his belt.",
314
+ "mood": "tense",
315
+ "accent_color": "#9a9aa0",
316
+ "location_tags": [],
317
+ "prop_tags": [],
318
+ "prompt_hint": "Business suit and tie, looking stern but slightly worried., Business attire with a pocket watch on his belt."
319
+ }
320
+ },
321
+ {
322
+ "sus_id": "S4",
323
+ "name": "Emily Thompson",
324
+ "role": "Friend",
325
+ "persona_summary": "A bright and passionate friend who often volunteers her time at the library.",
326
+ "demeanour": "rattled and evasive, voice tightening under pressure",
327
+ "is_culprit": true,
328
+ "physical_capability": {
329
+ "strength": true,
330
+ "mobility": true
331
+ },
332
+ "personality": {
333
+ "composure": 0.32,
334
+ "aggression": 0.55,
335
+ "evasiveness": 0.68
336
+ },
337
+ "tells": [
338
+ "I just thought the book was old, Emily."
339
+ ],
340
+ "knows_facts": [
341
+ "F_scene",
342
+ "F_sec4"
343
+ ],
344
+ "secrets": [
345
+ "She was secretly recording Isabella's every move to blackmail her later. She left a note on a bookshelf."
346
+ ],
347
+ "true_whereabouts": [
348
+ {
349
+ "window": {
350
+ "start_min": 1260,
351
+ "end_min": 1280
352
+ },
353
+ "loc_id": "L4",
354
+ "activity": "mingling in plain sight",
355
+ "co_present_sus_ids": []
356
+ },
357
+ {
358
+ "window": {
359
+ "start_min": 1280,
360
+ "end_min": 1310
361
+ },
362
+ "loc_id": "L3",
363
+ "activity": "alone with the victim",
364
+ "co_present_sus_ids": []
365
+ }
366
+ ],
367
+ "stated_alibi": {
368
+ "claim_text": "I was in the Coffeeshop during that time, as I am sure you can verify.",
369
+ "claimed_segments": [
370
+ {
371
+ "window": {
372
+ "start_min": 1260,
373
+ "end_min": 1320
374
+ },
375
+ "loc_id": "L4",
376
+ "witness_sus_ids": []
377
+ }
378
+ ]
379
+ },
380
+ "must_lie_about": [
381
+ "F_scene"
382
+ ],
383
+ "anchored_lies": [
384
+ {
385
+ "lie_id": "LIE_alibi",
386
+ "topic": "where you were during the murder",
387
+ "claimed": "I was in the Coffeeshop during that time, as I am sure you can verify.",
388
+ "truth_ref": "F_scene",
389
+ "breaks_on": [
390
+ "C_b1",
391
+ "C_b2"
392
+ ],
393
+ "fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
394
+ }
395
+ ],
396
+ "voice": null,
397
+ "visual": {
398
+ "subject_type": "suspect",
399
+ "palette": "noir",
400
+ "gender": "female",
401
+ "age_band": "25s",
402
+ "build": null,
403
+ "hair": null,
404
+ "attire": "A simple dress and a handbag that’s always full of notes.",
405
+ "mood": "tense",
406
+ "accent_color": "#6b8f71",
407
+ "location_tags": [],
408
+ "prop_tags": [],
409
+ "prompt_hint": "Bookish, with long hair tied back. She’s in her 30s and wears stylish yet slightly disheveled attire., A simple dress and a handbag that’s always full of notes."
410
+ }
411
+ }
412
+ ],
413
+ "culprit": {
414
+ "sus_id": "S4",
415
+ "true_motive": {
416
+ "motive_id": "M1",
417
+ "category": "revenge",
418
+ "summary": "Emily Thompson killed Isabella Whitmore to exact revenge over a past betrayal."
419
+ },
420
+ "method_narrative": "The killer, Emily Thompson, murdered Isabella Whitmore at the Biblioteca Romantica between 21:00 and 22:00 using an engraved dagger. They then claimed they never left the Coffeeshop to cover their tracks.",
421
+ "alibi_lie": {
422
+ "claimed_loc_id": "L4",
423
+ "actual_loc_id": "L3",
424
+ "contradicted_by_clue_ids": [
425
+ "C_b1",
426
+ "C_b2"
427
+ ]
428
+ }
429
+ },
430
+ "relationships": [],
431
+ "facts": [
432
+ {
433
+ "fact_id": "F_scene",
434
+ "statement": "Emily Thompson was in Biblioteca Romantica during the murder.",
435
+ "true_value": true,
436
+ "loc_id": "L3",
437
+ "at_min": 1280
438
+ },
439
+ {
440
+ "fact_id": "F_sec1",
441
+ "statement": "He was secretly developing a similar poison for personal vendetta against Isabella.",
442
+ "true_value": true,
443
+ "loc_id": null,
444
+ "at_min": null
445
+ },
446
+ {
447
+ "fact_id": "F_sec2",
448
+ "statement": "She was secretly working on the elixir together with Thomas for personal interest in poetry.",
449
+ "true_value": true,
450
+ "loc_id": null,
451
+ "at_min": null
452
+ },
453
+ {
454
+ "fact_id": "F_sec3",
455
+ "statement": "He was involved in Isabella's manuscript with an ulterior motive, attempting to find loopholes to cheat her financially.",
456
+ "true_value": true,
457
+ "loc_id": null,
458
+ "at_min": null
459
+ },
460
+ {
461
+ "fact_id": "F_sec4",
462
+ "statement": "She was secretly recording Isabella's every move to blackmail her later. She left a note on a bookshelf.",
463
+ "true_value": true,
464
+ "loc_id": null,
465
+ "at_min": null
466
+ }
467
+ ],
468
+ "clues": [
469
+ {
470
+ "clue_id": "C_b1",
471
+ "name": "Stopped clock",
472
+ "reveal_text": "A mantel clock knocked still at the very minute of death.",
473
+ "discoverable_at_loc_id": "L3",
474
+ "discovery_method": "forensic",
475
+ "supports_fact_id": "F_scene",
476
+ "points_to_sus_id": "S4",
477
+ "contradicts_alibi_of": "S4",
478
+ "is_red_herring": false,
479
+ "weight": 1.0
480
+ },
481
+ {
482
+ "clue_id": "C_b2",
483
+ "name": "Smudged tumbler",
484
+ "reveal_text": "A tumbler left where the body fell, its rim marked with a recent lip-print.",
485
+ "discoverable_at_loc_id": "L1",
486
+ "discovery_method": "forensic",
487
+ "supports_fact_id": "F_scene",
488
+ "points_to_sus_id": "S4",
489
+ "contradicts_alibi_of": "S4",
490
+ "is_red_herring": false,
491
+ "weight": 0.7
492
+ },
493
+ {
494
+ "clue_id": "C_h1",
495
+ "name": "Poison Bottle",
496
+ "reveal_text": "A letter detailing plans for both poison and Isabella's manuscript underlined with 'Favor.'",
497
+ "discoverable_at_loc_id": "L2",
498
+ "discovery_method": "search",
499
+ "supports_fact_id": "F_sec1",
500
+ "points_to_sus_id": "S1",
501
+ "contradicts_alibi_of": null,
502
+ "is_red_herring": true,
503
+ "weight": 0.3
504
+ },
505
+ {
506
+ "clue_id": "C_h2",
507
+ "name": "Aunt's Book",
508
+ "reveal_text": "The receipt from a rare manuscript by the poet who inspired Thomas’s poem. It was found in Isabella’s room.",
509
+ "discoverable_at_loc_id": "L4",
510
+ "discovery_method": "search",
511
+ "supports_fact_id": "F_sec2",
512
+ "points_to_sus_id": "S2",
513
+ "contradicts_alibi_of": null,
514
+ "is_red_herring": true,
515
+ "weight": 0.3
516
+ },
517
+ {
518
+ "clue_id": "C_h3",
519
+ "name": "Loan Agreement",
520
+ "reveal_text": "A copy of the loan agreement that shows Henry’s involvement with Isabella's manuscript under his name.",
521
+ "discoverable_at_loc_id": "L1",
522
+ "discovery_method": "search",
523
+ "supports_fact_id": "F_sec3",
524
+ "points_to_sus_id": "S3",
525
+ "contradicts_alibi_of": null,
526
+ "is_red_herring": true,
527
+ "weight": 0.3
528
+ }
529
+ ],
530
+ "solution": {
531
+ "culprit_sus_id": "S4",
532
+ "weapon_id": "W1",
533
+ "motive_id": "M1",
534
+ "minimal_clue_set": [
535
+ "C_b1"
536
+ ],
537
+ "deduction_chain": [
538
+ "The alibi claim suggests Emily Thompson's motive could be related to a past betrayal or misunderstanding that led her to seek revenge. The fact she was in her own home and not at the Biblioteca Romantica is intriguing, as it implies the crime might have been premeditated.",
539
+ "The broken droplet of ink on one of the bookshelves suggests someone had their pen placed down and walked away before the murder took place. This indicates a timeframe consistent with Emily Thompson being in the library.",
540
+ "The torn fiber from the carpet near the door could be linked to the fact that there were no footprints found outside, which is another piece of evidence pointing to Emily Thompson's alibi. The presence of this fiber suggests she was present when Isabella Whitmore entered and left the Biblioteca."
541
+ ]
542
+ }
543
+ }
cases/prebaked/CASE-0003.json ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_id": "CASE-0003",
3
+ "seed": 43016,
4
+ "schema_version": "1.0",
5
+ "title": "The Mistletoe Kiss",
6
+ "briefing": "A cold morning in the English countryside, a small town where secrets are as common as the mist.",
7
+ "knobs": {
8
+ "setting_hint": "",
9
+ "era_hint": "",
10
+ "tone_hint": "",
11
+ "n_suspects": 4,
12
+ "n_red_herrings": 2,
13
+ "alibi_tightness": 0.6,
14
+ "difficulty": "standard"
15
+ },
16
+ "setting": {
17
+ "name": "Roches Bleues Manor",
18
+ "description": "",
19
+ "locations": [
20
+ {
21
+ "loc_id": "L1",
22
+ "name": "Entrance Hall",
23
+ "description": "",
24
+ "adjacent_to": [
25
+ "L2",
26
+ "L3",
27
+ "L4"
28
+ ]
29
+ },
30
+ {
31
+ "loc_id": "L2",
32
+ "name": "Kitchen",
33
+ "description": "",
34
+ "adjacent_to": [
35
+ "L1"
36
+ ]
37
+ },
38
+ {
39
+ "loc_id": "L3",
40
+ "name": "Library",
41
+ "description": "",
42
+ "adjacent_to": [
43
+ "L1"
44
+ ]
45
+ },
46
+ {
47
+ "loc_id": "L4",
48
+ "name": "Bedroom",
49
+ "description": "",
50
+ "adjacent_to": [
51
+ "L1"
52
+ ]
53
+ }
54
+ ],
55
+ "murder_window": {
56
+ "start_min": 1260,
57
+ "end_min": 1320
58
+ }
59
+ },
60
+ "victim": {
61
+ "vic_id": "V1",
62
+ "name": "Eleanor Blakeley",
63
+ "role": "Dormant Housekeeper, 50s",
64
+ "found_at_loc_id": "L3",
65
+ "found_at_min": 1325,
66
+ "cause_of_death": "Strangled with a ribbon around the neck in the library while asleep.",
67
+ "time_of_death": {
68
+ "start_min": 1280,
69
+ "end_min": 1310
70
+ }
71
+ },
72
+ "weapon": {
73
+ "weapon_id": "W1",
74
+ "name": "Ribbon",
75
+ "kind": "Natural",
76
+ "origin_loc_id": "L3",
77
+ "requires_strength": false,
78
+ "leaves_trace": ""
79
+ },
80
+ "suspects": [
81
+ {
82
+ "sus_id": "S1",
83
+ "name": "Arthur Thornwell",
84
+ "role": "Lover, 30s",
85
+ "persona_summary": "The charming and romantic Arthur is often mistaken for the man of the house due to his impeccable taste.",
86
+ "demeanour": "hostile and defensive, bristling at any hint of suspicion",
87
+ "is_culprit": true,
88
+ "physical_capability": {
89
+ "strength": true,
90
+ "mobility": true
91
+ },
92
+ "personality": {
93
+ "composure": 0.55,
94
+ "aggression": 0.88,
95
+ "evasiveness": 0.4
96
+ },
97
+ "tells": [
98
+ "I did everything I could to protect her from the dangers of this job."
99
+ ],
100
+ "knows_facts": [
101
+ "F_scene",
102
+ "F_sec1"
103
+ ],
104
+ "secrets": [
105
+ "A long-standing secret between him and Eleanor regarding her past as a prisoner before joining the staff."
106
+ ],
107
+ "true_whereabouts": [
108
+ {
109
+ "window": {
110
+ "start_min": 1260,
111
+ "end_min": 1280
112
+ },
113
+ "loc_id": "L4",
114
+ "activity": "mingling in plain sight",
115
+ "co_present_sus_ids": []
116
+ },
117
+ {
118
+ "window": {
119
+ "start_min": 1280,
120
+ "end_min": 1310
121
+ },
122
+ "loc_id": "L3",
123
+ "activity": "alone with the victim",
124
+ "co_present_sus_ids": []
125
+ }
126
+ ],
127
+ "stated_alibi": {
128
+ "claim_text": "I was in the Bedroom all night long; I never left it.",
129
+ "claimed_segments": [
130
+ {
131
+ "window": {
132
+ "start_min": 1260,
133
+ "end_min": 1320
134
+ },
135
+ "loc_id": "L4",
136
+ "witness_sus_ids": []
137
+ }
138
+ ]
139
+ },
140
+ "must_lie_about": [
141
+ "F_scene"
142
+ ],
143
+ "anchored_lies": [
144
+ {
145
+ "lie_id": "LIE_alibi",
146
+ "topic": "where you were during the murder",
147
+ "claimed": "I was in the Bedroom all night long; I never left it.",
148
+ "truth_ref": "F_scene",
149
+ "breaks_on": [
150
+ "C_b1",
151
+ "C_b2"
152
+ ],
153
+ "fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
154
+ }
155
+ ],
156
+ "voice": null,
157
+ "visual": {
158
+ "subject_type": "suspect",
159
+ "palette": "noir",
160
+ "gender": "male",
161
+ "age_band": "30s",
162
+ "build": null,
163
+ "hair": null,
164
+ "attire": "Dapper suit, white shirt, silver jewelry",
165
+ "mood": "guarded",
166
+ "accent_color": "#b8860b",
167
+ "location_tags": [],
168
+ "prop_tags": [],
169
+ "prompt_hint": "Elegant but slightly intimidating due to his aristocratic demeanor and sharp wit., Dapper suit, white shirt, silver jewelry"
170
+ }
171
+ },
172
+ {
173
+ "sus_id": "S2",
174
+ "name": "Elizabeth 'Lizzy' Drake",
175
+ "role": "Rival, 40s",
176
+ "persona_summary": "Elizabeth is a relentless competitor who's always one step ahead. She saw Eleanor as an obstacle in their careers.",
177
+ "demeanour": "composed and cooperative on the surface, carefully measured",
178
+ "is_culprit": false,
179
+ "physical_capability": {
180
+ "strength": true,
181
+ "mobility": true
182
+ },
183
+ "personality": {
184
+ "composure": 0.72,
185
+ "aggression": 0.4,
186
+ "evasiveness": 0.3
187
+ },
188
+ "tells": [
189
+ "I can’t believe it was Lizzy who did this."
190
+ ],
191
+ "knows_facts": [
192
+ "F_sec2"
193
+ ],
194
+ "secrets": [
195
+ "Their rivalry has been simmering for years and now finally boils over with the murder."
196
+ ],
197
+ "true_whereabouts": [
198
+ {
199
+ "window": {
200
+ "start_min": 1260,
201
+ "end_min": 1320
202
+ },
203
+ "loc_id": "L2",
204
+ "activity": "going about the evening",
205
+ "co_present_sus_ids": []
206
+ }
207
+ ],
208
+ "stated_alibi": {
209
+ "claim_text": "I was in Kitchen the whole time.",
210
+ "claimed_segments": [
211
+ {
212
+ "window": {
213
+ "start_min": 1260,
214
+ "end_min": 1320
215
+ },
216
+ "loc_id": "L2",
217
+ "witness_sus_ids": []
218
+ }
219
+ ]
220
+ },
221
+ "must_lie_about": [
222
+ "F_sec2"
223
+ ],
224
+ "anchored_lies": [
225
+ {
226
+ "lie_id": "LIE_sec2",
227
+ "topic": "Their rivalry has been simmering for years and n",
228
+ "claimed": "Eleanor was jealous of Lizzy’s success, which led to her decision to strangle her on a dare.",
229
+ "truth_ref": "F_sec2",
230
+ "breaks_on": [
231
+ "C_h2"
232
+ ],
233
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
234
+ }
235
+ ],
236
+ "voice": null,
237
+ "visual": {
238
+ "subject_type": "suspect",
239
+ "palette": "noir",
240
+ "gender": "female",
241
+ "age_band": "40s",
242
+ "build": null,
243
+ "hair": null,
244
+ "attire": "Professional blazer, tailored skirt, heels",
245
+ "mood": "tense",
246
+ "accent_color": "#3a6ea5",
247
+ "location_tags": [],
248
+ "prop_tags": [],
249
+ "prompt_hint": "Businesswoman, smartly dressed in professional attire with a sharp edge in her eye., Professional blazer, tailored skirt, heels"
250
+ }
251
+ },
252
+ {
253
+ "sus_id": "S3",
254
+ "name": "George 'Gee' Smith",
255
+ "role": "Business Partner, 50s",
256
+ "persona_summary": "Georgie is known for his strict rules and demanding business practices. He’s been trying to push Eleanor out of her position.",
257
+ "demeanour": "rattled and evasive, voice tightening under pressure",
258
+ "is_culprit": false,
259
+ "physical_capability": {
260
+ "strength": true,
261
+ "mobility": true
262
+ },
263
+ "personality": {
264
+ "composure": 0.32,
265
+ "aggression": 0.55,
266
+ "evasiveness": 0.68
267
+ },
268
+ "tells": [
269
+ "I’m too focused on work to think about this."
270
+ ],
271
+ "knows_facts": [
272
+ "F_sec3"
273
+ ],
274
+ "secrets": [
275
+ "Their partnership was more about power than mutual respect due to a falling out over an investment deal that went badly."
276
+ ],
277
+ "true_whereabouts": [
278
+ {
279
+ "window": {
280
+ "start_min": 1260,
281
+ "end_min": 1320
282
+ },
283
+ "loc_id": "L4",
284
+ "activity": "going about the evening",
285
+ "co_present_sus_ids": []
286
+ }
287
+ ],
288
+ "stated_alibi": {
289
+ "claim_text": "I was in Bedroom the whole time.",
290
+ "claimed_segments": [
291
+ {
292
+ "window": {
293
+ "start_min": 1260,
294
+ "end_min": 1320
295
+ },
296
+ "loc_id": "L4",
297
+ "witness_sus_ids": []
298
+ }
299
+ ]
300
+ },
301
+ "must_lie_about": [
302
+ "F_sec3"
303
+ ],
304
+ "anchored_lies": [
305
+ {
306
+ "lie_id": "LIE_sec3",
307
+ "topic": "Their partnership was more about power than mutu",
308
+ "claimed": "Eleanor wanted him to approve the project, but he never did, leading to their breakup and subsequent murder.",
309
+ "truth_ref": "F_sec3",
310
+ "breaks_on": [
311
+ "C_h3"
312
+ ],
313
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
314
+ }
315
+ ],
316
+ "voice": null,
317
+ "visual": {
318
+ "subject_type": "suspect",
319
+ "palette": "noir",
320
+ "gender": "male",
321
+ "age_band": "50s",
322
+ "build": null,
323
+ "hair": null,
324
+ "attire": "Business suit, polished shoes, pocket watch",
325
+ "mood": "guarded",
326
+ "accent_color": "#9a9aa0",
327
+ "location_tags": [],
328
+ "prop_tags": [],
329
+ "prompt_hint": "Professional, well-dressed in a suit jacket and tie with his typical professional demeanor., Business suit, polished shoes, pocket watch"
330
+ }
331
+ },
332
+ {
333
+ "sus_id": "S4",
334
+ "name": "William 'Billy' Jackson",
335
+ "role": "Rival's Lover, 45s",
336
+ "persona_summary": "Billy is known for his cunning ways and was always close to Eleanor due to their shared love of adventure. Now they’re both suspects.",
337
+ "demeanour": "guarded and weary, giving away as little as possible",
338
+ "is_culprit": false,
339
+ "physical_capability": {
340
+ "strength": true,
341
+ "mobility": true
342
+ },
343
+ "personality": {
344
+ "composure": 0.6,
345
+ "aggression": 0.25,
346
+ "evasiveness": 0.55
347
+ },
348
+ "tells": [
349
+ "I don’t remember being here."
350
+ ],
351
+ "knows_facts": [
352
+ "F_sec4"
353
+ ],
354
+ "secrets": [
355
+ "They have a past relationship that wasn’t officially documented but grew into something deeper than expected."
356
+ ],
357
+ "true_whereabouts": [
358
+ {
359
+ "window": {
360
+ "start_min": 1260,
361
+ "end_min": 1320
362
+ },
363
+ "loc_id": "L1",
364
+ "activity": "going about the evening",
365
+ "co_present_sus_ids": []
366
+ }
367
+ ],
368
+ "stated_alibi": {
369
+ "claim_text": "I was in Entrance Hall the whole time.",
370
+ "claimed_segments": [
371
+ {
372
+ "window": {
373
+ "start_min": 1260,
374
+ "end_min": 1320
375
+ },
376
+ "loc_id": "L1",
377
+ "witness_sus_ids": []
378
+ }
379
+ ]
380
+ },
381
+ "must_lie_about": [
382
+ "F_sec4"
383
+ ],
384
+ "anchored_lies": [
385
+ {
386
+ "lie_id": "LIE_sec4",
387
+ "topic": "They have a past relationship that wasn’t offici",
388
+ "claimed": "He saw her as the perfect partner in crime, leading him to commit this act to protect her from falling out of favor with his boss.",
389
+ "truth_ref": "F_sec4",
390
+ "breaks_on": [
391
+ "C_h4"
392
+ ],
393
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
394
+ }
395
+ ],
396
+ "voice": null,
397
+ "visual": {
398
+ "subject_type": "suspect",
399
+ "palette": "noir",
400
+ "gender": "male",
401
+ "age_band": "45s",
402
+ "build": null,
403
+ "hair": null,
404
+ "attire": "Casual, bright colors, a handkerchief tied around his neck for added flair.",
405
+ "mood": "guarded",
406
+ "accent_color": "#6b8f71",
407
+ "location_tags": [],
408
+ "prop_tags": [],
409
+ "prompt_hint": "Cheerful and charming but slightly off-kilter in his mannerisms due to his history with Eleanor., Casual, bright colors, a handkerchief tied around his neck for added flair."
410
+ }
411
+ }
412
+ ],
413
+ "culprit": {
414
+ "sus_id": "S1",
415
+ "true_motive": {
416
+ "motive_id": "M1",
417
+ "category": "revenge",
418
+ "summary": "Arthur Thornwell murdered Eleanor Blakeley to exact revenge for her betrayal of his love."
419
+ },
420
+ "method_narrative": "The killer, Arthur Thornwell, chose the Library as a secluded space where he could carry out the murder without witnesses. He used a ribbon to cover the crime scene and then claimed that he was in the Bedroom when the murder occurred.",
421
+ "alibi_lie": {
422
+ "claimed_loc_id": "L4",
423
+ "actual_loc_id": "L3",
424
+ "contradicted_by_clue_ids": [
425
+ "C_b1",
426
+ "C_b2"
427
+ ]
428
+ }
429
+ },
430
+ "relationships": [],
431
+ "facts": [
432
+ {
433
+ "fact_id": "F_scene",
434
+ "statement": "Arthur Thornwell was in Library during the murder.",
435
+ "true_value": true,
436
+ "loc_id": "L3",
437
+ "at_min": 1280
438
+ },
439
+ {
440
+ "fact_id": "F_sec1",
441
+ "statement": "A long-standing secret between him and Eleanor regarding her past as a prisoner before joining the staff.",
442
+ "true_value": true,
443
+ "loc_id": null,
444
+ "at_min": null
445
+ },
446
+ {
447
+ "fact_id": "F_sec2",
448
+ "statement": "Their rivalry has been simmering for years and now finally boils over with the murder.",
449
+ "true_value": true,
450
+ "loc_id": null,
451
+ "at_min": null
452
+ },
453
+ {
454
+ "fact_id": "F_sec3",
455
+ "statement": "Their partnership was more about power than mutual respect due to a falling out over an investment deal that went badly.",
456
+ "true_value": true,
457
+ "loc_id": null,
458
+ "at_min": null
459
+ },
460
+ {
461
+ "fact_id": "F_sec4",
462
+ "statement": "They have a past relationship that wasn’t officially documented but grew into something deeper than expected.",
463
+ "true_value": true,
464
+ "loc_id": null,
465
+ "at_min": null
466
+ }
467
+ ],
468
+ "clues": [
469
+ {
470
+ "clue_id": "C_b1",
471
+ "name": "Partial fingerprint",
472
+ "reveal_text": "A fresh partial fingerprint on the ribbon, unaccounted for among the guests.",
473
+ "discoverable_at_loc_id": "L3",
474
+ "discovery_method": "forensic",
475
+ "supports_fact_id": "F_scene",
476
+ "points_to_sus_id": "S1",
477
+ "contradicts_alibi_of": "S1",
478
+ "is_red_herring": false,
479
+ "weight": 1.0
480
+ },
481
+ {
482
+ "clue_id": "C_b2",
483
+ "name": "Snagged thread",
484
+ "reveal_text": "A torn thread of dark cloth snagged on the library doorframe.",
485
+ "discoverable_at_loc_id": "L1",
486
+ "discovery_method": "forensic",
487
+ "supports_fact_id": "F_scene",
488
+ "points_to_sus_id": "S1",
489
+ "contradicts_alibi_of": "S1",
490
+ "is_red_herring": false,
491
+ "weight": 0.7
492
+ },
493
+ {
494
+ "clue_id": "C_h2",
495
+ "name": "Pawn ticket",
496
+ "reveal_text": "A pawn ticket for a family heirloom, recently surrendered.",
497
+ "discoverable_at_loc_id": "L2",
498
+ "discovery_method": "search",
499
+ "supports_fact_id": "F_sec2",
500
+ "points_to_sus_id": "S2",
501
+ "contradicts_alibi_of": null,
502
+ "is_red_herring": true,
503
+ "weight": 0.3
504
+ },
505
+ {
506
+ "clue_id": "C_h3",
507
+ "name": "A torn letter with his handwriting showing he had given approval for the risky investment.",
508
+ "reveal_text": "The torn letter reveals how they both knew the investment was reckless and Eleanor’s fear that it would ruin her career.",
509
+ "discoverable_at_loc_id": "L4",
510
+ "discovery_method": "search",
511
+ "supports_fact_id": "F_sec3",
512
+ "points_to_sus_id": "S3",
513
+ "contradicts_alibi_of": null,
514
+ "is_red_herring": true,
515
+ "weight": 0.3
516
+ },
517
+ {
518
+ "clue_id": "C_h4",
519
+ "name": "A stained handkerchief found near Eleanor's body, indicating it was used for a romantic gesture.",
520
+ "reveal_text": "The handkerchief reveals the depth of Billy’s feelings and their plan to commit this crime together.",
521
+ "discoverable_at_loc_id": "L1",
522
+ "discovery_method": "search",
523
+ "supports_fact_id": "F_sec4",
524
+ "points_to_sus_id": "S4",
525
+ "contradicts_alibi_of": null,
526
+ "is_red_herring": true,
527
+ "weight": 0.3
528
+ }
529
+ ],
530
+ "solution": {
531
+ "culprit_sus_id": "S1",
532
+ "weapon_id": "W1",
533
+ "motive_id": "M1",
534
+ "minimal_clue_set": [
535
+ "C_b1"
536
+ ],
537
+ "deduction_chain": [
538
+ "The false alibi of being in the Bedroom contradicts the time frame of the murder, suggesting that Arthur Thornwell was present at the Library.",
539
+ "The presence of a ribbon covering the crime scene indicates that someone tampered with it after the murder. This suggests that the killer had access to the Library and could have committed the act there.",
540
+ "The fact that Eleanor Blakeley's body was found in the Library, not in her bedroom as claimed by Arthur Thornwell, further supports the idea that he was present at the Library during the murder."
541
+ ]
542
+ }
543
+ }
cases/prebaked/CASE-0004.json ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_id": "CASE-0004",
3
+ "seed": 43017,
4
+ "schema_version": "1.0",
5
+ "title": "The Living Room Affair",
6
+ "briefing": "A mysterious murder occurred in the Whispers Lounge around midnight last night.",
7
+ "knobs": {
8
+ "setting_hint": "",
9
+ "era_hint": "",
10
+ "tone_hint": "",
11
+ "n_suspects": 4,
12
+ "n_red_herrings": 2,
13
+ "alibi_tightness": 0.6,
14
+ "difficulty": "standard"
15
+ },
16
+ "setting": {
17
+ "name": "",
18
+ "description": "",
19
+ "locations": [
20
+ {
21
+ "loc_id": "L1",
22
+ "name": "living room",
23
+ "description": "",
24
+ "adjacent_to": [
25
+ "L2",
26
+ "L3",
27
+ "L4"
28
+ ]
29
+ },
30
+ {
31
+ "loc_id": "L2",
32
+ "name": "dining room",
33
+ "description": "",
34
+ "adjacent_to": [
35
+ "L1"
36
+ ]
37
+ },
38
+ {
39
+ "loc_id": "L3",
40
+ "name": "kitchen",
41
+ "description": "",
42
+ "adjacent_to": [
43
+ "L1"
44
+ ]
45
+ },
46
+ {
47
+ "loc_id": "L4",
48
+ "name": "bathroom",
49
+ "description": "",
50
+ "adjacent_to": [
51
+ "L1"
52
+ ]
53
+ }
54
+ ],
55
+ "murder_window": {
56
+ "start_min": 1260,
57
+ "end_min": 1320
58
+ }
59
+ },
60
+ "victim": {
61
+ "vic_id": "V1",
62
+ "name": "Isabella Carter",
63
+ "role": "a skilled software engineer at TechCorp",
64
+ "found_at_loc_id": "L1",
65
+ "found_at_min": 1325,
66
+ "cause_of_death": "stab wound to the heart from a concealed switchblade in the living room",
67
+ "time_of_death": {
68
+ "start_min": 1280,
69
+ "end_min": 1310
70
+ }
71
+ },
72
+ "weapon": {
73
+ "weapon_id": "W1",
74
+ "name": "switchblade",
75
+ "kind": "single-edged",
76
+ "origin_loc_id": "L1",
77
+ "requires_strength": false,
78
+ "leaves_trace": ""
79
+ },
80
+ "suspects": [
81
+ {
82
+ "sus_id": "S1",
83
+ "name": "Jesse Harper",
84
+ "role": "business partner at TechCorp, former IT security expert and hacker",
85
+ "persona_summary": "A ruthless man obsessed with hacking.",
86
+ "demeanour": "composed and cooperative on the surface, carefully measured",
87
+ "is_culprit": false,
88
+ "physical_capability": {
89
+ "strength": true,
90
+ "mobility": true
91
+ },
92
+ "personality": {
93
+ "composure": 0.72,
94
+ "aggression": 0.4,
95
+ "evasiveness": 0.3
96
+ },
97
+ "tells": [
98
+ "I'm not the one who would kill anyone, especially at such a late hour."
99
+ ],
100
+ "knows_facts": [
101
+ "F_sec1"
102
+ ],
103
+ "secrets": [
104
+ "His wife cheated on him months ago. He doesn't want to confront the truth about her infidelity because it will ruin his career."
105
+ ],
106
+ "true_whereabouts": [
107
+ {
108
+ "window": {
109
+ "start_min": 1260,
110
+ "end_min": 1320
111
+ },
112
+ "loc_id": "L2",
113
+ "activity": "going about the evening",
114
+ "co_present_sus_ids": []
115
+ }
116
+ ],
117
+ "stated_alibi": {
118
+ "claim_text": "I was in dining room the whole time.",
119
+ "claimed_segments": [
120
+ {
121
+ "window": {
122
+ "start_min": 1260,
123
+ "end_min": 1320
124
+ },
125
+ "loc_id": "L2",
126
+ "witness_sus_ids": []
127
+ }
128
+ ]
129
+ },
130
+ "must_lie_about": [
131
+ "F_sec1"
132
+ ],
133
+ "anchored_lies": [
134
+ {
135
+ "lie_id": "LIE_sec1",
136
+ "topic": "His wife cheated on him months ago. He doesn't w",
137
+ "claimed": "He's a software engineer, but he prefers to work from home at TechCorp.",
138
+ "truth_ref": "F_sec1",
139
+ "breaks_on": [
140
+ "C_h1"
141
+ ],
142
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
143
+ }
144
+ ],
145
+ "voice": null,
146
+ "visual": {
147
+ "subject_type": "suspect",
148
+ "palette": "noir",
149
+ "gender": "male",
150
+ "age_band": "50s",
151
+ "build": null,
152
+ "hair": null,
153
+ "attire": "business attire",
154
+ "mood": "guarded",
155
+ "accent_color": "#b8860b",
156
+ "location_tags": [],
157
+ "prop_tags": [],
158
+ "prompt_hint": "Jesse Harper: 45 years old, male, dressed in a suit and tie with a sharp white beard, clean-shaven face, confident demeanor., business attire"
159
+ }
160
+ },
161
+ {
162
+ "sus_id": "S2",
163
+ "name": "Emily Carter",
164
+ "role": "Isabella's lover and coworker, known for her fiery personality",
165
+ "persona_summary": "A passionate but somewhat dramatic woman.",
166
+ "demeanour": "rattled and evasive, voice tightening under pressure",
167
+ "is_culprit": true,
168
+ "physical_capability": {
169
+ "strength": true,
170
+ "mobility": true
171
+ },
172
+ "personality": {
173
+ "composure": 0.32,
174
+ "aggression": 0.55,
175
+ "evasiveness": 0.68
176
+ },
177
+ "tells": [
178
+ "I wouldn't do anything that could get me arrested or have consequences for my work."
179
+ ],
180
+ "knows_facts": [
181
+ "F_scene",
182
+ "F_sec2"
183
+ ],
184
+ "secrets": [
185
+ "She was coerced into an affair by Isabella due to financial troubles."
186
+ ],
187
+ "true_whereabouts": [
188
+ {
189
+ "window": {
190
+ "start_min": 1260,
191
+ "end_min": 1280
192
+ },
193
+ "loc_id": "L2",
194
+ "activity": "mingling in plain sight",
195
+ "co_present_sus_ids": []
196
+ },
197
+ {
198
+ "window": {
199
+ "start_min": 1280,
200
+ "end_min": 1310
201
+ },
202
+ "loc_id": "L1",
203
+ "activity": "alone with the victim",
204
+ "co_present_sus_ids": []
205
+ }
206
+ ],
207
+ "stated_alibi": {
208
+ "claim_text": "I was in the dining room all day long.",
209
+ "claimed_segments": [
210
+ {
211
+ "window": {
212
+ "start_min": 1260,
213
+ "end_min": 1320
214
+ },
215
+ "loc_id": "L2",
216
+ "witness_sus_ids": []
217
+ }
218
+ ]
219
+ },
220
+ "must_lie_about": [
221
+ "F_scene"
222
+ ],
223
+ "anchored_lies": [
224
+ {
225
+ "lie_id": "LIE_alibi",
226
+ "topic": "where you were during the murder",
227
+ "claimed": "I was in the dining room all day long.",
228
+ "truth_ref": "F_scene",
229
+ "breaks_on": [
230
+ "C_b1",
231
+ "C_b2"
232
+ ],
233
+ "fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
234
+ }
235
+ ],
236
+ "voice": null,
237
+ "visual": {
238
+ "subject_type": "suspect",
239
+ "palette": "noir",
240
+ "gender": "female",
241
+ "age_band": "20s",
242
+ "build": null,
243
+ "hair": null,
244
+ "attire": "business casual but stylish",
245
+ "mood": "guarded",
246
+ "accent_color": "#3a6ea5",
247
+ "location_tags": [],
248
+ "prop_tags": [],
249
+ "prompt_hint": "Emily Carter: 29 years old, female, wearing a pair of high heels and a short dress with bright lipstick and eyeshadow, playful demeanor., business casual but stylish"
250
+ }
251
+ },
252
+ {
253
+ "sus_id": "S3",
254
+ "name": "Thomas Johnson",
255
+ "role": "a rival software engineer at TechCorp, known for his ruthless ambition",
256
+ "persona_summary": "He's a jack-of-all-trades who often goes above and beyond to protect his company’s interests.",
257
+ "demeanour": "guarded and weary, giving away as little as possible",
258
+ "is_culprit": false,
259
+ "physical_capability": {
260
+ "strength": true,
261
+ "mobility": true
262
+ },
263
+ "personality": {
264
+ "composure": 0.6,
265
+ "aggression": 0.25,
266
+ "evasiveness": 0.55
267
+ },
268
+ "tells": [
269
+ "I'm the one who was there at night, but I wasn't the only one."
270
+ ],
271
+ "knows_facts": [
272
+ "F_sec3"
273
+ ],
274
+ "secrets": [
275
+ "He secretly monitored Isabella’s online activities with the help of an app installed on her phone. He saw she was working late at night due to stress about their relationship, which was causing tension between them."
276
+ ],
277
+ "true_whereabouts": [
278
+ {
279
+ "window": {
280
+ "start_min": 1260,
281
+ "end_min": 1320
282
+ },
283
+ "loc_id": "L4",
284
+ "activity": "going about the evening",
285
+ "co_present_sus_ids": []
286
+ }
287
+ ],
288
+ "stated_alibi": {
289
+ "claim_text": "I was in bathroom the whole time.",
290
+ "claimed_segments": [
291
+ {
292
+ "window": {
293
+ "start_min": 1260,
294
+ "end_min": 1320
295
+ },
296
+ "loc_id": "L4",
297
+ "witness_sus_ids": []
298
+ }
299
+ ]
300
+ },
301
+ "must_lie_about": [
302
+ "F_sec3"
303
+ ],
304
+ "anchored_lies": [
305
+ {
306
+ "lie_id": "LIE_sec3",
307
+ "topic": "He secretly monitored Isabella’s online activiti",
308
+ "claimed": "Tom has a competitive nature but doesn't like people knowing he's always watching others.",
309
+ "truth_ref": "F_sec3",
310
+ "breaks_on": [
311
+ "C_h3"
312
+ ],
313
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
314
+ }
315
+ ],
316
+ "voice": null,
317
+ "visual": {
318
+ "subject_type": "suspect",
319
+ "palette": "noir",
320
+ "gender": "male",
321
+ "age_band": "50s",
322
+ "build": null,
323
+ "hair": null,
324
+ "attire": "professional attire with jewelry",
325
+ "mood": "tense",
326
+ "accent_color": "#9a9aa0",
327
+ "location_tags": [],
328
+ "prop_tags": [],
329
+ "prompt_hint": "Thomas Johnson: 47 years old, male, dressed in a business suit with a stern look and a gold chain around his neck, aggressive demeanor., professional attire with jewelry"
330
+ }
331
+ },
332
+ {
333
+ "sus_id": "S4",
334
+ "name": "Lucas Green",
335
+ "role": "the servant in the household, known for his mild demeanor and reliability",
336
+ "persona_summary": "A kind-hearted man with a keen sense of duty.",
337
+ "demeanour": "cool and self-assured, almost amused by the questioning",
338
+ "is_culprit": false,
339
+ "physical_capability": {
340
+ "strength": true,
341
+ "mobility": true
342
+ },
343
+ "personality": {
344
+ "composure": 0.88,
345
+ "aggression": 0.3,
346
+ "evasiveness": 0.35
347
+ },
348
+ "tells": [
349
+ "I didn't do anything that would get me fired or in trouble."
350
+ ],
351
+ "knows_facts": [
352
+ "F_sec4"
353
+ ],
354
+ "secrets": [
355
+ "He secretly took care of Isabella's family, including her children, due to financial difficulties. He was paid by Thomas Johnson who wanted to keep an eye on the house but could not because it belonged to TechCorp."
356
+ ],
357
+ "true_whereabouts": [
358
+ {
359
+ "window": {
360
+ "start_min": 1260,
361
+ "end_min": 1320
362
+ },
363
+ "loc_id": "L2",
364
+ "activity": "going about the evening",
365
+ "co_present_sus_ids": []
366
+ }
367
+ ],
368
+ "stated_alibi": {
369
+ "claim_text": "I was in dining room the whole time.",
370
+ "claimed_segments": [
371
+ {
372
+ "window": {
373
+ "start_min": 1260,
374
+ "end_min": 1320
375
+ },
376
+ "loc_id": "L2",
377
+ "witness_sus_ids": []
378
+ }
379
+ ]
380
+ },
381
+ "must_lie_about": [
382
+ "F_sec4"
383
+ ],
384
+ "anchored_lies": [
385
+ {
386
+ "lie_id": "LIE_sec4",
387
+ "topic": "He secretly took care of Isabella's family, incl",
388
+ "claimed": "Lucas is a very discreet person and doesn't like to speak about his past or responsibilities.",
389
+ "truth_ref": "F_sec4",
390
+ "breaks_on": [
391
+ "C_h4"
392
+ ],
393
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
394
+ }
395
+ ],
396
+ "voice": null,
397
+ "visual": {
398
+ "subject_type": "suspect",
399
+ "palette": "noir",
400
+ "gender": "male",
401
+ "age_band": "middle-aged",
402
+ "build": null,
403
+ "hair": null,
404
+ "attire": "clean, formal attire",
405
+ "mood": "tense",
406
+ "accent_color": "#6b8f71",
407
+ "location_tags": [],
408
+ "prop_tags": [],
409
+ "prompt_hint": "Lucas Green: 35 years old, male, dressed in clean work clothes with no visible signs of emotion, mild demeanor and steady eyes., clean, formal attire"
410
+ }
411
+ }
412
+ ],
413
+ "culprit": {
414
+ "sus_id": "S2",
415
+ "true_motive": {
416
+ "motive_id": "M1",
417
+ "category": "revenge",
418
+ "summary": "Emily Carter murdered Isabella Carter out of jealousy over her success and love for the victim."
419
+ },
420
+ "method_narrative": "The killer, Emily Carter, used a switchblade to commit the murder in the living room between 21:00 and 22:00. She then claimed she was in the dining room during the crime.",
421
+ "alibi_lie": {
422
+ "claimed_loc_id": "L2",
423
+ "actual_loc_id": "L1",
424
+ "contradicted_by_clue_ids": [
425
+ "C_b1",
426
+ "C_b2"
427
+ ]
428
+ }
429
+ },
430
+ "relationships": [],
431
+ "facts": [
432
+ {
433
+ "fact_id": "F_scene",
434
+ "statement": "Emily Carter was in living room during the murder.",
435
+ "true_value": true,
436
+ "loc_id": "L1",
437
+ "at_min": 1280
438
+ },
439
+ {
440
+ "fact_id": "F_sec1",
441
+ "statement": "His wife cheated on him months ago. He doesn't want to confront the truth about her infidelity because it will ruin his career.",
442
+ "true_value": true,
443
+ "loc_id": null,
444
+ "at_min": null
445
+ },
446
+ {
447
+ "fact_id": "F_sec2",
448
+ "statement": "She was coerced into an affair by Isabella due to financial troubles.",
449
+ "true_value": true,
450
+ "loc_id": null,
451
+ "at_min": null
452
+ },
453
+ {
454
+ "fact_id": "F_sec3",
455
+ "statement": "He secretly monitored Isabella’s online activities with the help of an app installed on her phone. He saw she was working late at night due to stress about their relationship, which was causing tension between them.",
456
+ "true_value": true,
457
+ "loc_id": null,
458
+ "at_min": null
459
+ },
460
+ {
461
+ "fact_id": "F_sec4",
462
+ "statement": "He secretly took care of Isabella's family, including her children, due to financial difficulties. He was paid by Thomas Johnson who wanted to keep an eye on the house but could not because it belonged to TechCorp.",
463
+ "true_value": true,
464
+ "loc_id": null,
465
+ "at_min": null
466
+ }
467
+ ],
468
+ "clues": [
469
+ {
470
+ "clue_id": "C_b1",
471
+ "name": "Smudged tumbler",
472
+ "reveal_text": "A tumbler left where the body fell, its rim marked with a recent lip-print.",
473
+ "discoverable_at_loc_id": "L1",
474
+ "discovery_method": "forensic",
475
+ "supports_fact_id": "F_scene",
476
+ "points_to_sus_id": "S2",
477
+ "contradicts_alibi_of": "S2",
478
+ "is_red_herring": false,
479
+ "weight": 1.0
480
+ },
481
+ {
482
+ "clue_id": "C_b2",
483
+ "name": "Snagged thread",
484
+ "reveal_text": "A torn thread of dark cloth snagged on the living room doorframe.",
485
+ "discoverable_at_loc_id": "L2",
486
+ "discovery_method": "forensic",
487
+ "supports_fact_id": "F_scene",
488
+ "points_to_sus_id": "S2",
489
+ "contradicts_alibi_of": "S2",
490
+ "is_red_herring": false,
491
+ "weight": 0.7
492
+ },
493
+ {
494
+ "clue_id": "C_h1",
495
+ "name": "a receipt for a high-profile hack",
496
+ "reveal_text": "The evidence is an invoice showing he received $20k in fees for a sophisticated phishing operation. He was hacking into corporate email accounts of major clients.",
497
+ "discoverable_at_loc_id": "L3",
498
+ "discovery_method": "search",
499
+ "supports_fact_id": "F_sec1",
500
+ "points_to_sus_id": "S1",
501
+ "contradicts_alibi_of": null,
502
+ "is_red_herring": true,
503
+ "weight": 0.3
504
+ },
505
+ {
506
+ "clue_id": "C_h3",
507
+ "name": "a pawn ticket from the department store where Isabella bought her evening dress",
508
+ "reveal_text": "The pawn ticket is for an engagement ring worth $30k, which was purchased as collateral by Thomas to keep an eye on Isabella. He needed the money urgently because his company had just filed bankruptcy.",
509
+ "discoverable_at_loc_id": "L4",
510
+ "discovery_method": "search",
511
+ "supports_fact_id": "F_sec3",
512
+ "points_to_sus_id": "S3",
513
+ "contradicts_alibi_of": null,
514
+ "is_red_herring": true,
515
+ "weight": 0.3
516
+ },
517
+ {
518
+ "clue_id": "C_h4",
519
+ "name": "a stain of Isabella's perfume found on the carpet near her bed",
520
+ "reveal_text": "The stain was left by Lucas during his routine cleaning duties. He confessed that he had been hired by Thomas Johnson, who offered him a job as a nanny if he kept an eye on the house for free.",
521
+ "discoverable_at_loc_id": "L2",
522
+ "discovery_method": "search",
523
+ "supports_fact_id": "F_sec4",
524
+ "points_to_sus_id": "S4",
525
+ "contradicts_alibi_of": null,
526
+ "is_red_herring": true,
527
+ "weight": 0.3
528
+ }
529
+ ],
530
+ "solution": {
531
+ "culprit_sus_id": "S2",
532
+ "weapon_id": "W1",
533
+ "motive_id": "M1",
534
+ "minimal_clue_set": [
535
+ "C_b1"
536
+ ],
537
+ "deduction_chain": [
538
+ "The switchblade murder weapon and a torn piece of Isabella's dress were found on Emily Carter's person, indicating she committed the crime.",
539
+ "The alibi claim about being in the dining room is false because the body was found in the living room where Emily Carter had access to the switchblade.",
540
+ "The presence of her fingerprints on the murder weapon and a torn piece of Isabella's dress suggests she was present during the murder."
541
+ ]
542
+ }
543
+ }
cases/prebaked/CASE-0005.json ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_id": "CASE-0005",
3
+ "seed": 43021,
4
+ "schema_version": "1.0",
5
+ "title": "The Unlucky Door",
6
+ "briefing": "Detective investigating a double murder at The Unlucky Door, a renowned theater in the heart of downtown. The victims were found in Room 3 during a suspicious disappearance investigation.",
7
+ "knobs": {
8
+ "setting_hint": "",
9
+ "era_hint": "",
10
+ "tone_hint": "",
11
+ "n_suspects": 4,
12
+ "n_red_herrings": 2,
13
+ "alibi_tightness": 0.6,
14
+ "difficulty": "standard"
15
+ },
16
+ "setting": {
17
+ "name": "The Unlucky Door",
18
+ "description": "",
19
+ "locations": [
20
+ {
21
+ "loc_id": "L1",
22
+ "name": "Main Lobby",
23
+ "description": "",
24
+ "adjacent_to": [
25
+ "L2",
26
+ "L3",
27
+ "L4"
28
+ ]
29
+ },
30
+ {
31
+ "loc_id": "L2",
32
+ "name": "Actor's Studio",
33
+ "description": "",
34
+ "adjacent_to": [
35
+ "L1"
36
+ ]
37
+ },
38
+ {
39
+ "loc_id": "L3",
40
+ "name": "Manager's Office",
41
+ "description": "",
42
+ "adjacent_to": [
43
+ "L1"
44
+ ]
45
+ },
46
+ {
47
+ "loc_id": "L4",
48
+ "name": "Director's Study",
49
+ "description": "",
50
+ "adjacent_to": [
51
+ "L1"
52
+ ]
53
+ }
54
+ ],
55
+ "murder_window": {
56
+ "start_min": 1260,
57
+ "end_min": 1320
58
+ }
59
+ },
60
+ "victim": {
61
+ "vic_id": "V1",
62
+ "name": "Jasmine Lee",
63
+ "role": "Actress and Manager of the theater",
64
+ "found_at_loc_id": "L1",
65
+ "found_at_min": 1325,
66
+ "cause_of_death": "Accidental overdose with lethal amount of sleeping pills in Room 3",
67
+ "time_of_death": {
68
+ "start_min": 1280,
69
+ "end_min": 1310
70
+ }
71
+ },
72
+ "weapon": {
73
+ "weapon_id": "W1",
74
+ "name": "Bottle opener",
75
+ "kind": "surgical scalpel found embedded in her shoulder wound",
76
+ "origin_loc_id": "L1",
77
+ "requires_strength": false,
78
+ "leaves_trace": ""
79
+ },
80
+ "suspects": [
81
+ {
82
+ "sus_id": "S1",
83
+ "name": "Victor Chen",
84
+ "role": "Jasmine's ex-boyfriend and business partner",
85
+ "persona_summary": "An envious businessman, Victor is obsessed with Jasmine's success but secretly jealous of their close relationship. He was caught shoplifting a valuable diamond ring from her jewelry box in Room 4.",
86
+ "demeanour": "visibly frightened and on edge, dreading every question",
87
+ "is_culprit": false,
88
+ "physical_capability": {
89
+ "strength": true,
90
+ "mobility": true
91
+ },
92
+ "personality": {
93
+ "composure": 0.2,
94
+ "aggression": 0.3,
95
+ "evasiveness": 0.7
96
+ },
97
+ "tells": [
98
+ "I was just checking on her from upstairs."
99
+ ],
100
+ "knows_facts": [
101
+ "F_sec1"
102
+ ],
103
+ "secrets": [
104
+ "The real reason he was there to see her die, as she was the key to securing his share of her fortune, which includes her beloved dog Coco (a black and white poodle)"
105
+ ],
106
+ "true_whereabouts": [
107
+ {
108
+ "window": {
109
+ "start_min": 1260,
110
+ "end_min": 1320
111
+ },
112
+ "loc_id": "L2",
113
+ "activity": "going about the evening",
114
+ "co_present_sus_ids": []
115
+ }
116
+ ],
117
+ "stated_alibi": {
118
+ "claim_text": "I was in Actor's Studio the whole time.",
119
+ "claimed_segments": [
120
+ {
121
+ "window": {
122
+ "start_min": 1260,
123
+ "end_min": 1320
124
+ },
125
+ "loc_id": "L2",
126
+ "witness_sus_ids": []
127
+ }
128
+ ]
129
+ },
130
+ "must_lie_about": [
131
+ "F_sec1"
132
+ ],
133
+ "anchored_lies": [
134
+ {
135
+ "lie_id": "LIE_sec1",
136
+ "topic": "The real reason he was there to see her die, as ",
137
+ "claimed": "He was merely looking for an opportunity to claim his half of her inheritance before her disappearance.",
138
+ "truth_ref": "F_sec1",
139
+ "breaks_on": [
140
+ "C_h1"
141
+ ],
142
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
143
+ }
144
+ ],
145
+ "voice": null,
146
+ "visual": {
147
+ "subject_type": "suspect",
148
+ "palette": "noir",
149
+ "gender": "male",
150
+ "age_band": "40s",
151
+ "build": null,
152
+ "hair": null,
153
+ "attire": "Black tie evening dress complete with top hat and monocle as he pretends to be an esteemed theater owner while plotting the murder of his business partner's love interest.",
154
+ "mood": "tense",
155
+ "accent_color": "#b8860b",
156
+ "location_tags": [],
157
+ "prop_tags": [],
158
+ "prompt_hint": "Tall and imposing in a tailored suit with slicked-back hair and a gold chain around his neck, he is confident but somewhat aggressive., Black tie evening dress complete with top hat and monocle as he pretends to be an esteemed theater owner while plotting the murder of his business partner's love interest."
159
+ }
160
+ },
161
+ {
162
+ "sus_id": "S2",
163
+ "name": "Liliana Rodriguez",
164
+ "role": "Jasmine's lover and friend",
165
+ "persona_summary": "A charming but flirtatious woman with a sharp tongue, Liliana had been trying to break up with Jasmine for weeks. Her secret: she was jealous of their close relationship and secretly obsessed with finding the diamonds that could help her get custody of Coco (which had not happened)",
166
+ "demeanour": "hostile and defensive, bristling at any hint of suspicion",
167
+ "is_culprit": true,
168
+ "physical_capability": {
169
+ "strength": true,
170
+ "mobility": true
171
+ },
172
+ "personality": {
173
+ "composure": 0.55,
174
+ "aggression": 0.88,
175
+ "evasiveness": 0.4
176
+ },
177
+ "tells": [
178
+ "She was just checking on Jasmine because she had information for her boss."
179
+ ],
180
+ "knows_facts": [
181
+ "F_scene",
182
+ "F_sec2"
183
+ ],
184
+ "secrets": [
185
+ "The real reason for her jealousy was to frame Jasmine as a liar in front of others, including her boss Victor"
186
+ ],
187
+ "true_whereabouts": [
188
+ {
189
+ "window": {
190
+ "start_min": 1260,
191
+ "end_min": 1280
192
+ },
193
+ "loc_id": "L2",
194
+ "activity": "mingling in plain sight",
195
+ "co_present_sus_ids": []
196
+ },
197
+ {
198
+ "window": {
199
+ "start_min": 1280,
200
+ "end_min": 1310
201
+ },
202
+ "loc_id": "L1",
203
+ "activity": "alone with the victim",
204
+ "co_present_sus_ids": []
205
+ }
206
+ ],
207
+ "stated_alibi": {
208
+ "claim_text": "I was in the Actor's Studio at all times and have no knowledge of Jasmine Lee's murder.",
209
+ "claimed_segments": [
210
+ {
211
+ "window": {
212
+ "start_min": 1260,
213
+ "end_min": 1320
214
+ },
215
+ "loc_id": "L2",
216
+ "witness_sus_ids": []
217
+ }
218
+ ]
219
+ },
220
+ "must_lie_about": [
221
+ "F_scene"
222
+ ],
223
+ "anchored_lies": [
224
+ {
225
+ "lie_id": "LIE_alibi",
226
+ "topic": "where you were during the murder",
227
+ "claimed": "I was in the Actor's Studio at all times and have no knowledge of Jasmine Lee's murder.",
228
+ "truth_ref": "F_scene",
229
+ "breaks_on": [
230
+ "C_b1",
231
+ "C_b2"
232
+ ],
233
+ "fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
234
+ }
235
+ ],
236
+ "voice": null,
237
+ "visual": {
238
+ "subject_type": "suspect",
239
+ "palette": "noir",
240
+ "gender": "female",
241
+ "age_band": "20s",
242
+ "build": null,
243
+ "hair": null,
244
+ "attire": "Flapper dress with a small cocktail dress underlayer for added allure, complete with high heels and a pair of gloves to match her flirtatious persona.",
245
+ "mood": "tense",
246
+ "accent_color": "#3a6ea5",
247
+ "location_tags": [],
248
+ "prop_tags": [],
249
+ "prompt_hint": "Radiant and flirtatious with long black hair tied in a bun, she has perfect dental braces and a bright smile but hides a secret ring on her finger that matches the diamonds she was obsessed with finding., Flapper dress with a small cocktail dress underlayer for added allure, complete with high heels and a pair of gloves to match her flirtatious persona."
250
+ }
251
+ },
252
+ {
253
+ "sus_id": "S3",
254
+ "name": "Dr. Elena Sanchez",
255
+ "role": "Manager of the theater's security department, a woman with no connection to the murder except for a grudging respect for Jasmine's reputation in the industry",
256
+ "persona_summary": "A highly competent and discreet doctor who has only ever been present when Jazzy was performing or at her manager's office.",
257
+ "demeanour": "composed and cooperative on the surface, carefully measured",
258
+ "is_culprit": false,
259
+ "physical_capability": {
260
+ "strength": true,
261
+ "mobility": true
262
+ },
263
+ "personality": {
264
+ "composure": 0.72,
265
+ "aggression": 0.4,
266
+ "evasiveness": 0.3
267
+ },
268
+ "tells": [
269
+ "I was just checking on the security protocols as she was conducting her daily rounds."
270
+ ],
271
+ "knows_facts": [
272
+ "F_sec3"
273
+ ],
274
+ "secrets": [
275
+ "She had an old habit of adding blood samples from actors' wounds to her medical files, hoping to improve their recovery rates. Her latest sample in Room 3 included a faint hint of Jasmine’s victim's blood."
276
+ ],
277
+ "true_whereabouts": [
278
+ {
279
+ "window": {
280
+ "start_min": 1260,
281
+ "end_min": 1320
282
+ },
283
+ "loc_id": "L4",
284
+ "activity": "going about the evening",
285
+ "co_present_sus_ids": []
286
+ }
287
+ ],
288
+ "stated_alibi": {
289
+ "claim_text": "I was in Director's Study the whole time.",
290
+ "claimed_segments": [
291
+ {
292
+ "window": {
293
+ "start_min": 1260,
294
+ "end_min": 1320
295
+ },
296
+ "loc_id": "L4",
297
+ "witness_sus_ids": []
298
+ }
299
+ ]
300
+ },
301
+ "must_lie_about": [
302
+ "F_sec3"
303
+ ],
304
+ "anchored_lies": [
305
+ {
306
+ "lie_id": "LIE_sec3",
307
+ "topic": "She had an old habit of adding blood samples fro",
308
+ "claimed": "She was merely taking notes on Jazzy's daily practice and recovery process for the theater's performance staff.",
309
+ "truth_ref": "F_sec3",
310
+ "breaks_on": [
311
+ "C_h3"
312
+ ],
313
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
314
+ }
315
+ ],
316
+ "voice": null,
317
+ "visual": {
318
+ "subject_type": "suspect",
319
+ "palette": "noir",
320
+ "gender": "female",
321
+ "age_band": "50s",
322
+ "build": null,
323
+ "hair": null,
324
+ "attire": "White coat and surgical mask as an investigator might expect during the autopsy process but maintains professionalism by wearing a dark suit over it, with a clean appearance despite the stress of the investigation.",
325
+ "mood": "tense",
326
+ "accent_color": "#9a9aa0",
327
+ "location_tags": [],
328
+ "prop_tags": [],
329
+ "prompt_hint": "Serene and composed in a professional white coat with neatly pressed hair, she has sharp green eyes that seem to pierce everyone around her. She exudes a calm demeanor even under suspicion., White coat and surgical mask as an investigator might expect during the autopsy process but maintains professionalism by wearing a dark suit over it, with a clean appearance despite the stress of the investigation."
330
+ }
331
+ },
332
+ {
333
+ "sus_id": "S4",
334
+ "name": "James Baker",
335
+ "role": "Victor's employee, a male friend of both Victor and Jasmine who had been working there for years with no criminal history or conflict.",
336
+ "persona_summary": "A hardworking but reserved man with no secrets from anyone at work. His only interest was helping his boss solve problems.",
337
+ "demeanour": "rattled and evasive, voice tightening under pressure",
338
+ "is_culprit": false,
339
+ "physical_capability": {
340
+ "strength": true,
341
+ "mobility": true
342
+ },
343
+ "personality": {
344
+ "composure": 0.32,
345
+ "aggression": 0.55,
346
+ "evasiveness": 0.68
347
+ },
348
+ "tells": [
349
+ "I was just looking for information about our department’s procedures."
350
+ ],
351
+ "knows_facts": [
352
+ "F_sec4"
353
+ ],
354
+ "secrets": [
355
+ "He knew the diamonds were stolen but had never found a reason to inform on Victor, afraid of hurting Jasmine's reputation as an actress and manager"
356
+ ],
357
+ "true_whereabouts": [
358
+ {
359
+ "window": {
360
+ "start_min": 1260,
361
+ "end_min": 1320
362
+ },
363
+ "loc_id": "L2",
364
+ "activity": "going about the evening",
365
+ "co_present_sus_ids": []
366
+ }
367
+ ],
368
+ "stated_alibi": {
369
+ "claim_text": "I was in Actor's Studio the whole time.",
370
+ "claimed_segments": [
371
+ {
372
+ "window": {
373
+ "start_min": 1260,
374
+ "end_min": 1320
375
+ },
376
+ "loc_id": "L2",
377
+ "witness_sus_ids": []
378
+ }
379
+ ]
380
+ },
381
+ "must_lie_about": [
382
+ "F_sec4"
383
+ ],
384
+ "anchored_lies": [
385
+ {
386
+ "lie_id": "LIE_sec4",
387
+ "topic": "He knew the diamonds were stolen but had never f",
388
+ "claimed": "James merely wanted to help clear the rumors about Victor’s past.",
389
+ "truth_ref": "F_sec4",
390
+ "breaks_on": [
391
+ "C_h4"
392
+ ],
393
+ "fallback": "Fine, that part is true - but it has nothing to do with the murder."
394
+ }
395
+ ],
396
+ "voice": null,
397
+ "visual": {
398
+ "subject_type": "suspect",
399
+ "palette": "noir",
400
+ "gender": "male",
401
+ "age_band": "30s",
402
+ "build": null,
403
+ "hair": null,
404
+ "attire": "Business suit complete with dark ties and a pocket watch, giving off an air of seriousness but showing no signs of distress or concern. His lack of interaction suggests he is hiding something from everyone.",
405
+ "mood": "guarded",
406
+ "accent_color": "#6b8f71",
407
+ "location_tags": [],
408
+ "prop_tags": [],
409
+ "prompt_hint": "Reliable and dependable with a straight face as he sorts through papers from Room 4, completely unaware of the mystery unfolding around him., Business suit complete with dark ties and a pocket watch, giving off an air of seriousness but showing no signs of distress or concern. His lack of interaction suggests he is hiding something from everyone."
410
+ }
411
+ }
412
+ ],
413
+ "culprit": {
414
+ "sus_id": "S2",
415
+ "true_motive": {
416
+ "motive_id": "M1",
417
+ "category": "revenge",
418
+ "summary": "Liliana Rodriguez killed Jasmine Lee because she felt threatened by Jasmine's success and wanted to eliminate her rival."
419
+ },
420
+ "method_narrative": "The murder took place in the Main Lobby around 21:30, with Liliana Rodriguez using a bottle opener as their weapon. The body was then moved into the Actor's Studio where they claimed they had never left it.",
421
+ "alibi_lie": {
422
+ "claimed_loc_id": "L2",
423
+ "actual_loc_id": "L1",
424
+ "contradicted_by_clue_ids": [
425
+ "C_b1",
426
+ "C_b2"
427
+ ]
428
+ }
429
+ },
430
+ "relationships": [],
431
+ "facts": [
432
+ {
433
+ "fact_id": "F_scene",
434
+ "statement": "Liliana Rodriguez was in Main Lobby during the murder.",
435
+ "true_value": true,
436
+ "loc_id": "L1",
437
+ "at_min": 1280
438
+ },
439
+ {
440
+ "fact_id": "F_sec1",
441
+ "statement": "The real reason he was there to see her die, as she was the key to securing his share of her fortune, which includes her beloved dog Coco (a black and white poodle)",
442
+ "true_value": true,
443
+ "loc_id": null,
444
+ "at_min": null
445
+ },
446
+ {
447
+ "fact_id": "F_sec2",
448
+ "statement": "The real reason for her jealousy was to frame Jasmine as a liar in front of others, including her boss Victor",
449
+ "true_value": true,
450
+ "loc_id": null,
451
+ "at_min": null
452
+ },
453
+ {
454
+ "fact_id": "F_sec3",
455
+ "statement": "She had an old habit of adding blood samples from actors' wounds to her medical files, hoping to improve their recovery rates. Her latest sample in Room 3 included a faint hint of Jasmine’s victim's blood.",
456
+ "true_value": true,
457
+ "loc_id": null,
458
+ "at_min": null
459
+ },
460
+ {
461
+ "fact_id": "F_sec4",
462
+ "statement": "He knew the diamonds were stolen but had never found a reason to inform on Victor, afraid of hurting Jasmine's reputation as an actress and manager",
463
+ "true_value": true,
464
+ "loc_id": null,
465
+ "at_min": null
466
+ }
467
+ ],
468
+ "clues": [
469
+ {
470
+ "clue_id": "C_b1",
471
+ "name": "Scuffed floorboard",
472
+ "reveal_text": "A freshly scuffed floorboard where someone braced against a struggle.",
473
+ "discoverable_at_loc_id": "L1",
474
+ "discovery_method": "forensic",
475
+ "supports_fact_id": "F_scene",
476
+ "points_to_sus_id": "S2",
477
+ "contradicts_alibi_of": "S2",
478
+ "is_red_herring": false,
479
+ "weight": 1.0
480
+ },
481
+ {
482
+ "clue_id": "C_b2",
483
+ "name": "Snagged thread",
484
+ "reveal_text": "A torn thread of dark cloth snagged on the main lobby doorframe.",
485
+ "discoverable_at_loc_id": "L2",
486
+ "discovery_method": "forensic",
487
+ "supports_fact_id": "F_scene",
488
+ "points_to_sus_id": "S2",
489
+ "contradicts_alibi_of": "S2",
490
+ "is_red_herring": false,
491
+ "weight": 0.7
492
+ },
493
+ {
494
+ "clue_id": "C_h1",
495
+ "name": "Old photograph",
496
+ "reveal_text": "A worn photograph they would rather no one had seen.",
497
+ "discoverable_at_loc_id": "L3",
498
+ "discovery_method": "search",
499
+ "supports_fact_id": "F_sec1",
500
+ "points_to_sus_id": "S1",
501
+ "contradicts_alibi_of": null,
502
+ "is_red_herring": true,
503
+ "weight": 0.3
504
+ },
505
+ {
506
+ "clue_id": "C_h3",
507
+ "name": "A torn piece of paper with bloodstains from an actor's wound, matching her file exactly",
508
+ "reveal_text": "The note is found as she leaves Room 3 after the autopsy, leading investigators to pursue it through security records.",
509
+ "discoverable_at_loc_id": "L4",
510
+ "discovery_method": "search",
511
+ "supports_fact_id": "F_sec3",
512
+ "points_to_sus_id": "S3",
513
+ "contradicts_alibi_of": null,
514
+ "is_red_herring": true,
515
+ "weight": 0.3
516
+ },
517
+ {
518
+ "clue_id": "C_h4",
519
+ "name": "A receipt for diamond purchases matching those stolen from Jasmine’s jewelry box in Room 4",
520
+ "reveal_text": "The receipt is found while James was sorting through files, revealing his involvement in the theft but not knowing the full extent of it.",
521
+ "discoverable_at_loc_id": "L2",
522
+ "discovery_method": "search",
523
+ "supports_fact_id": "F_sec4",
524
+ "points_to_sus_id": "S4",
525
+ "contradicts_alibi_of": null,
526
+ "is_red_herring": true,
527
+ "weight": 0.3
528
+ }
529
+ ],
530
+ "solution": {
531
+ "culprit_sus_id": "S2",
532
+ "weapon_id": "W1",
533
+ "motive_id": "M1",
534
+ "minimal_clue_set": [
535
+ "C_b1"
536
+ ],
537
+ "deduction_chain": [
538
+ "Liliana Rodriguez used the bottle opener as their weapon during the murder.",
539
+ "The stained bottle opener was found in the Main Lobby around 21:30, suggesting a timeframe consistent with the time of death.",
540
+ "A fingerprint on the bottle opener matches mine, implicating me in Jasmine Lee's murder."
541
+ ]
542
+ }
543
+ }
cases/seeds/GRAYMOOR-3107.json ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "GRAYMOOR-3107",
3
+ "city": "GRAYMOOR",
4
+ "district": "HARBOR DISTRICT - 7TH PRECINCT",
5
+ "title": "THE ATRIUM FALL",
6
+ "tagline": "Detective, your presence is required.",
7
+ "weather": "Rain. 4C. Wind off the water.",
8
+ "victim": {
9
+ "name": "MARA VANCE",
10
+ "role": "Principal Architect - Graymoor Maritime Museum",
11
+ "age": 47,
12
+ "sprite": "victim",
13
+ "bio": "Designed half the new waterfront. Tonight the Maritime Museum opened to the city. By midnight she was dead on its atrium floor."
14
+ },
15
+ "scene": "North Atrium - Graymoor Maritime Museum",
16
+ "tod": "11:48 PM",
17
+ "found": "Opening gala. Found by a caterer at 11:52 PM, twelve meters below the mezzanine rail.",
18
+ "cause": "Fall from the third-floor mezzanine. Rail height: 1.1m. She did not climb it alone.",
19
+ "facts": [
20
+ ["CITY", "Graymoor"],
21
+ ["VICTIM", "Mara Vance, 47"],
22
+ ["SCENE", "Maritime Museum, North Atrium"],
23
+ ["TIME OF DEATH", "11:48 PM"],
24
+ ["CAUSE", "Fall - 12m"],
25
+ ["VERDICT", "Homicide (staged)"]
26
+ ],
27
+ "bootLines": [
28
+ "02:14. The phone drags you up out of half a sleep.",
29
+ "Harbor District. The new Maritime Museum, opening night.",
30
+ "A woman went over the mezzanine rail during the gala.",
31
+ "Mara Vance. The architect. She drew the building she died in.",
32
+ "Twelve meters down - past a rail she could not have cleared alone.",
33
+ "Four guests are still inside. None of them are leaving.",
34
+ "The rain hasn't let up in three days.",
35
+ "They're holding the scene. It's yours now, detective."
36
+ ],
37
+ "storyBeats": [
38
+ { "scene": "skyline", "kicker": "GRAYMOOR - 11:52 PM", "title": "A city that never dries out",
39
+ "text": "Three days of rain off the harbor, and tonight the whole city came to the waterfront anyway - to see the room Graymoor had been promising itself for a decade." },
40
+ { "scene": "atrium", "kicker": "THE MARITIME MUSEUM", "title": "Opening night",
41
+ "text": "Glass and steel rising over black water. Champagne on the atrium floor, donors on the mezzanine. The proudest new room in Graymoor, and every important name was in it." },
42
+ { "scene": "atrium", "kicker": "THE ARCHITECT", "title": "Mara Vance",
43
+ "text": "She designed all of it - half the new waterfront bears her hand. Tonight was supposed to be her crown. At midnight she was meant to take the podium and say something the whole city would remember." },
44
+ { "scene": "mezzanine", "kicker": "11:48 PM", "title": "The fall",
45
+ "text": "She never reached the podium. At 11:48 she went over the third-floor mezzanine rail - twelve meters down to the atrium floor. Under the music, the gala never even heard it." },
46
+ { "scene": "interro", "kicker": "FOUR STAYED", "title": "The ones who didn't run",
47
+ "text": "When the sirens came, the crowd scattered into the rain. Four did not. A financier. Her partner of twenty years. The night-security man. The museum's curator. Each had a reason to be near that rail." },
48
+ { "scene": "seawall", "kicker": "YOUR CASE NOW", "title": "Detective",
49
+ "text": "They're holding the scene for you. The rail didn't push her - someone did. One of those four is going to lie straight to your face. Find the crack, and follow it all the way down." }
50
+ ],
51
+ "suspects": [
52
+ {
53
+ "id": "wexler", "name": "DESMOND WEXLER", "role": "Board Chairman & Financier", "age": 58,
54
+ "sprite": "wexler", "gender": "male", "tag": "THE MONEY", "suspicion": 30,
55
+ "motive": "Vance traced a hole in the construction fund straight to his accounts.",
56
+ "alibi": "Says he was on the gala floor giving the patron toast until midnight.",
57
+ "quote": "I built this museum with my checkbook. Why would I throw my own architect off it?",
58
+ "greet": "Detective. Make it quick - I have a board to reassure and a city watching.",
59
+ "default": "I answer to a foundation, not to you. Ask something that matters.",
60
+ "questions": [
61
+ { "id": "q0", "q": "Where were you at 11:48?", "a": "On the floor, glass in hand. Three hundred people saw Desmond Wexler smiling. That is called an alibi.", "d": 2 },
62
+ { "id": "q1", "q": "You and Vance - money trouble?", "a": "Creative people don't understand budgets. We had... disagreements. Disagreements are not murder.", "d": 9 },
63
+ { "id": "q2", "q": "What was she going to announce at midnight?", "a": "The endowment, the donors, the usual theatre. Nothing about me. Nothing.", "d": 12 },
64
+ { "id": "q3", "q": "Who wanted her gone?", "a": "Ask Calloway. The partnership was a sinking boat and Mara was the one bailing water.", "d": -3 },
65
+ { "id": "q4", "q": "You seem very calm for a man whose architect just died.", "a": "I have buried partners before, detective. Grief is a private line item.", "d": 6 }
66
+ ],
67
+ "present": {
68
+ "keycard": { "a": "My card never left the gala floor. Check it. You'll see I'm telling the truth - for once someone is.", "d": 4 },
69
+ "thread": { "a": "Those are Mara's words, not mine. She accused everyone that last week. Paranoid.", "d": 8 },
70
+ "receipt": { "a": "The bar? I don't drink at my own openings. That's not my signature.", "d": 2 },
71
+ "cctv": { "a": "That coat isn't mine. I wore black tie. Three hundred witnesses, remember.", "d": 1 },
72
+ "voicemail": { "a": "She left that for Calloway, not me. Listen to who she's threatening. It isn't Desmond Wexler.", "d": -2 },
73
+ "photo": { "a": "A drafting compass. Hers. She carried it everywhere - habit from the drawing board.", "d": 1 }
74
+ }
75
+ },
76
+ {
77
+ "id": "iris", "name": "IRIS CALLOWAY", "role": "Co-Architect & Partner", "age": 41,
78
+ "sprite": "iris", "gender": "female", "tag": "THE PARTNER", "suspicion": 25,
79
+ "motive": "Vance was dissolving the firm at midnight - and taking sole credit for the museum.",
80
+ "alibi": "Claims she left the gala at 11:20 and walked the seawall to clear her head.",
81
+ "quote": "Twenty years I drew beside her. You think I'd erase that with one push?",
82
+ "greet": "I keep seeing her on the floor when I close my eyes. Ask me before I lose the nerve.",
83
+ "default": "I don't- I can't think straight. Please. What do you actually want to know?",
84
+ "questions": [
85
+ { "id": "q0", "q": "Where were you at 11:48?", "a": "On the seawall. Alone. I needed air. I know how that sounds. I know.", "d": 8 },
86
+ { "id": "q1", "q": "You left at 11:20 - you're sure?", "a": "Around then. Maybe later. It was a blur. The champagne, the noise...", "d": 14 },
87
+ { "id": "q2", "q": "What happens to the firm now?", "a": "Don't. Don't make tonight about the firm. ... It was being dissolved. There. You happy?", "d": 16 },
88
+ { "id": "q3", "q": "Did you go up to the mezzanine?", "a": "No. Why would I- no. I was never up there. I told you. The seawall.", "d": 18 },
89
+ { "id": "q4", "q": "What did her last message say?", "a": "I didn't read it in time. I swear I didn't read it until after.", "d": 12 }
90
+ ],
91
+ "present": {
92
+ "receipt": { "a": "That- that's from the bar, yes, but I left right after. One drink. I told you, one drink and I left.", "d": 16 },
93
+ "cctv": { "a": "That coat- lots of people have a pale coat. That isn't- you can't even see a face. That proves nothing.", "d": 20 },
94
+ "keycard": { "a": "My card at the service door at 11:41? No. Someone took it. Someone must have- I dropped it, maybe-", "d": 26 },
95
+ "voicemail": { "a": "(she goes very still) ...\"Meet me on the mezzanine at midnight. We end this properly.\" I never went. I never-", "d": 24 },
96
+ "thread": { "a": "We fought. Partners fight. You don't kill someone over a building, detective.", "d": 14 },
97
+ "photo": { "a": "Her compass. She'd have wanted that buried with her. ... Why is it on the floor and not in her bag?", "d": 10 }
98
+ }
99
+ },
100
+ {
101
+ "id": "teo", "name": "TEO MARCHETTI", "role": "Night Security Supervisor", "age": 35,
102
+ "sprite": "teo", "gender": "male", "tag": "THE EX", "suspicion": 35,
103
+ "motive": "He and Vance were involved. She ended it cold three weeks ago.",
104
+ "alibi": "Says he was in the security office watching the camera bank all night.",
105
+ "quote": "I loved her. That's not a motive, that's a sentence I'm already serving.",
106
+ "greet": "You're going to look at me first. The ex, the guard with the keys. Go on. Get it over with.",
107
+ "default": "I watched those monitors all night. I watch everything. That's the job.",
108
+ "questions": [
109
+ { "id": "q0", "q": "Where were you at 11:48?", "a": "Security office. The whole wall of screens was mine. I saw the gala. I didn't see her fall - wrong camera.", "d": 6 },
110
+ { "id": "q1", "q": "You and Vance were together?", "a": "Were. Past tense, she made sure of that. Three weeks ago. A text. Twenty years she gave the firm and me a text.", "d": 11 },
111
+ { "id": "q2", "q": "Who has keycard access to the mezzanine?", "a": "Staff. Me. The architects. It's a service door - half the building can open it.", "d": 4 },
112
+ { "id": "q3", "q": "The atrium camera - what did it catch?", "a": "It... glitched. 11:40 to 11:50. Ten minutes of static. I logged it. I swear I logged it.", "d": 16 },
113
+ { "id": "q4", "q": "Did you go near the atrium?", "a": "No. I stayed at my post. I had no reason to see her. Seeing her is the thing I'm trying to stop doing.", "d": 9 }
114
+ ],
115
+ "present": {
116
+ "cctv": { "a": "See? The one still that survived. A pale coat. That's not me - I was in uniform. Black. Check the still.", "d": 6 },
117
+ "keycard": { "a": "My card logs all night at the office door. I never went up. The 11:41 swipe isn't mine - different door, different card.", "d": 4 },
118
+ "voicemail": { "a": "That wasn't left for me. She stopped leaving me anything weeks ago.", "d": 5 },
119
+ "thread": { "a": "Read your own evidence. Those texts aren't my number. She was fighting with her partner, not with me.", "d": 3 },
120
+ "receipt": { "a": "The bar receipt's signed I.C. That's not Teo Marchetti. I was sober at my post.", "d": 2 },
121
+ "photo": { "a": "Her compass on the floor. She never dropped that. Whoever was up there knocked it from her hand.", "d": 7 }
122
+ }
123
+ },
124
+ {
125
+ "id": "frost", "name": "DR. HELENA FROST", "role": "Museum Director & Curator", "age": 49,
126
+ "sprite": "frost", "gender": "female", "tag": "THE CURATOR", "suspicion": 28,
127
+ "motive": "Vance discovered forged provenance on the museum's centerpiece collection.",
128
+ "alibi": "Says she was greeting donors at the east entrance, never left the ground floor.",
129
+ "quote": "I curate the truth of objects, detective. I would never let a lie hang in my own house.",
130
+ "greet": "Twelve years assembling this collection, and the city will remember it as the night someone died. Ask.",
131
+ "default": "I deal in provenance - where a thing came from, who can vouch for it. So vouch for your questions.",
132
+ "questions": [
133
+ { "id": "q0", "q": "Where were you at 11:48?", "a": "East entrance, receiving the cultural minister. A dozen donors will confirm it. I never touched the north stair.", "d": 4 },
134
+ { "id": "q1", "q": "The collection - any disputes with Vance?", "a": "Architects and curators always disagree about light and labels. Nothing that ends in a fall.", "d": 10 },
135
+ { "id": "q2", "q": "Did she question any of the artifacts?", "a": "(a pause) She asked about provenance on the flagship pieces. I gave her documents. She was... unsatisfied.", "d": 14 },
136
+ { "id": "q3", "q": "What would exposure have cost you?", "a": "My reputation is the museum's reputation. But I had paperwork. Paper beats accusation, detective.", "d": 12 },
137
+ { "id": "q4", "q": "Where is that paperwork now?", "a": "Filed. Safe. ... Some of it I was still - assembling. These things take time to verify properly.", "d": 13 }
138
+ ],
139
+ "present": {
140
+ "photo": { "a": "A drafting compass. Hers, obviously. I fail to see what an architect's tool tells you about a curator.", "d": 5 },
141
+ "thread": { "a": "Those messages are between Vance and her partner. I am nowhere in them. Read carefully.", "d": 3 },
142
+ "voicemail": { "a": "A midnight meeting on the mezzanine. Not with me - I was at the east door all night. Ask the minister.", "d": 4 },
143
+ "keycard": { "a": "My access is logged to the gallery wing, not the atrium service door. I curate; I don't prowl stairwells.", "d": 3 },
144
+ "cctv": { "a": "A pale figure. I wore charcoal. The coat in that still is too long for me. Look at the proportions.", "d": 4 },
145
+ "receipt": { "a": "I don't frequent the bar during a gala I'm hosting. And that signature is not mine.", "d": 2 }
146
+ }
147
+ }
148
+ ],
149
+ "evidence": [
150
+ {
151
+ "id": "thread", "name": "MESSAGE THREAD", "type": "PHONE", "icon": "phone", "time": "11:02 PM",
152
+ "found": "Recovered from victim's phone",
153
+ "summary": "Heated exchange between Vance and \"I.C.\" in the hour before her death.",
154
+ "thread": [
155
+ { "from": "them", "who": "I.C.", "t": "10:51", "m": "You can't announce the dissolution tonight. Not like this. Not in front of the whole city." },
156
+ { "from": "me", "who": "VANCE", "t": "10:53", "m": "It's done, Iris. The papers are signed. The museum is mine alone now." },
157
+ { "from": "them", "who": "I.C.", "t": "10:55", "m": "Twenty years. You'd erase twenty years at a podium with champagne in your hand." },
158
+ { "from": "me", "who": "VANCE", "t": "10:58", "m": "Come up to the mezzanine before midnight. I'll let you hear it from me first. That's the courtesy I owe you." },
159
+ { "from": "them", "who": "I.C.", "t": "11:02", "m": "Don't do this to me. Please. I'm begging you, Mara." }
160
+ ]
161
+ },
162
+ {
163
+ "id": "receipt", "name": "TORN BAR RECEIPT", "type": "PAPER", "icon": "receipt", "time": "11:09 PM",
164
+ "found": "Found crumpled in atrium planter",
165
+ "summary": "Atrium Bar tab. Two glasses, signed I.C. Timestamp contradicts her stated exit.",
166
+ "detail": "ATRIUM BAR - GALA\n2x Amaro, neat\n23:09\nSIGNED: I. Calloway\n- she said she left at 11:20, sober, alone."
167
+ },
168
+ {
169
+ "id": "cctv", "name": "CCTV STILL", "type": "IMAGE", "icon": "cctv", "time": "11:43 PM",
170
+ "found": "North mezzanine cam - sole surviving frame",
171
+ "summary": "A pale-coated figure at the mezzanine rail, five minutes before the fall.",
172
+ "detail": "CAM 7 / NORTH MEZZANINE\n23:43:11\n[feed corrupt 23:40-23:50 - 1 frame recovered]\nFigure: pale long coat, at the rail. Face not visible."
173
+ },
174
+ {
175
+ "id": "voicemail", "name": "LAST VOICEMAIL", "type": "AUDIO", "icon": "voicemail", "time": "11:31 PM",
176
+ "found": "Left on I. Calloway's phone - unheard until 12:14 AM",
177
+ "summary": "Vance's final recorded words. A request to meet on the mezzanine at midnight.",
178
+ "transcript": "\"Iris, it's me. Meet me on the mezzanine at midnight - before the announcement. We end this properly, you and I. ... I owe you that much. Come alone.\"",
179
+ "dur": "0:19"
180
+ },
181
+ {
182
+ "id": "keycard", "name": "KEYCARD LOG", "type": "DATA", "icon": "keycard", "time": "11:41 PM",
183
+ "found": "Building access export",
184
+ "summary": "Mezzanine service door, swiped at 11:41 PM. The card belongs to I. Calloway.",
185
+ "rows": [
186
+ ["23:18", "EAST ENTRANCE", "H. FROST", "ok"],
187
+ ["23:20", "ATRIUM FLOOR", "I. CALLOWAY", "ok"],
188
+ ["23:41", "MEZZ SERVICE DOOR", "I. CALLOWAY", "flag"],
189
+ ["23:44", "SECURITY OFFICE", "T. MARCHETTI", "ok"],
190
+ ["23:59", "GALA FLOOR", "D. WEXLER", "ok"]
191
+ ]
192
+ },
193
+ {
194
+ "id": "photo", "name": "SCENE PHOTO", "type": "IMAGE", "icon": "photoEv", "time": "11:55 PM",
195
+ "found": "Forensics - atrium floor",
196
+ "summary": "A brass drafting compass beside the body - knocked from her hand, not dropped.",
197
+ "detail": "ATRIUM FLOOR / EVIDENCE MARKER 3\nBrass drafting compass, victim's.\nLanded 2.1m from body - consistent with a struggle at the rail, not a fall."
198
+ }
199
+ ],
200
+ "timeline": [
201
+ { "time": "9:00", "label": "Gala opens. Vance gives no public remarks.", "locked": true },
202
+ { "time": "10:51", "label": "Vance & Calloway begin arguing by message.", "ev": "thread" },
203
+ { "time": "11:09", "label": "Calloway at the Atrium Bar - two drinks.", "ev": "receipt" },
204
+ { "time": "11:20", "label": "Calloway's card opens the atrium floor.", "ev": "keycard" },
205
+ { "time": "11:31", "label": "Vance leaves voicemail: meet at midnight.", "ev": "voicemail" },
206
+ { "time": "11:41", "label": "Mezzanine service door - Calloway's card.", "ev": "keycard", "conflict": true },
207
+ { "time": "11:43", "label": "Pale-coated figure at the rail (CCTV).", "ev": "cctv", "conflict": true },
208
+ { "time": "11:48", "label": "Mara Vance falls. Time of death.", "locked": true },
209
+ { "time": "11:55", "label": "Compass found 2m from body - struggle.", "ev": "photo" }
210
+ ],
211
+ "flashback": {
212
+ "title": "THE MEZZANINE - TWO ACCOUNTS",
213
+ "a": { "who": "IRIS CALLOWAY SAYS", "scene": "seawall", "lines": [
214
+ "I left at 11:20. I walked the seawall alone.",
215
+ "I never went up. I never saw her again.",
216
+ "The card at the service door wasn't me."
217
+ ], "flags": [] },
218
+ "b": { "who": "THE EVIDENCE SAYS", "scene": "mezzanine", "lines": [
219
+ "Her card opened the mezzanine door at 11:41.",
220
+ "A pale coat stood at the rail at 11:43.",
221
+ "Her compass landed two meters out - a struggle."
222
+ ], "flags": [1, 2] }
223
+ },
224
+ "motives": [
225
+ { "id": "m_credit", "text": "To stop the firm's dissolution and the erasure of her career" },
226
+ { "id": "m_money", "text": "To bury evidence of skimming the construction fund" },
227
+ { "id": "m_love", "text": "Jealousy - a relationship ended coldly" },
228
+ { "id": "m_forgery", "text": "To silence exposure of forged provenance" }
229
+ ],
230
+ "sealed": {
231
+ "killer": "iris",
232
+ "correctMotive": "m_credit",
233
+ "keyEvidence": ["keycard", "voicemail", "cctv"],
234
+ "truth": "It was Iris Calloway. Twenty years of partnership, dissolved at a podium with the city watching. She answered the voicemail, climbed the service stair at 11:41, and met Mara at the rail. The argument that began in messages ended there. The compass fell from Mara's hand as she reached for the woman who'd drawn beside her half her life."
235
+ }
236
+ }
cases/seeds/tutorial.json ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_id": "tutorial-gilded-aerie",
3
+ "seed": 1989,
4
+ "schema_version": "1.0",
5
+ "title": "The Gilded Aerie",
6
+ "briefing": "1924. On the rooftop of the Gilded Aerie supper club, owner Cornelius Vane is found dead in his office, struck down with his own brass award. The club was full; everyone had a reason to resent him. Four people could have slipped away during the murder hour. Question them, search the rooms, and present what you find. One of them is lying about where they were - catch that lie, and you have your killer.",
7
+ "knobs": {
8
+ "setting_hint": "rooftop supper club",
9
+ "era_hint": "1924",
10
+ "tone_hint": "jazz-age noir",
11
+ "n_suspects": 4,
12
+ "n_red_herrings": 3,
13
+ "alibi_tightness": 0.6,
14
+ "difficulty": "gentle"
15
+ },
16
+ "setting": {
17
+ "name": "The Gilded Aerie",
18
+ "description": "A rooftop supper club above the city.",
19
+ "locations": [
20
+ {
21
+ "loc_id": "L1",
22
+ "name": "The Lounge",
23
+ "description": "The main club floor.",
24
+ "adjacent_to": [
25
+ "L2",
26
+ "L3",
27
+ "L5"
28
+ ]
29
+ },
30
+ {
31
+ "loc_id": "L2",
32
+ "name": "The Kitchen",
33
+ "description": "",
34
+ "adjacent_to": [
35
+ "L1"
36
+ ]
37
+ },
38
+ {
39
+ "loc_id": "L3",
40
+ "name": "The Terrace",
41
+ "description": "The open rooftop stage.",
42
+ "adjacent_to": [
43
+ "L1"
44
+ ]
45
+ },
46
+ {
47
+ "loc_id": "L4",
48
+ "name": "The Office",
49
+ "description": "Vane's private office.",
50
+ "adjacent_to": [
51
+ "L5"
52
+ ]
53
+ },
54
+ {
55
+ "loc_id": "L5",
56
+ "name": "The Cloakroom",
57
+ "description": "",
58
+ "adjacent_to": [
59
+ "L1",
60
+ "L4"
61
+ ]
62
+ }
63
+ ],
64
+ "murder_window": {
65
+ "start_min": 1260,
66
+ "end_min": 1320
67
+ }
68
+ },
69
+ "victim": {
70
+ "vic_id": "V1",
71
+ "name": "Cornelius Vane",
72
+ "role": "club owner",
73
+ "found_at_loc_id": "L4",
74
+ "found_at_min": 1325,
75
+ "cause_of_death": "blunt force from a brass statuette",
76
+ "time_of_death": {
77
+ "start_min": 1280,
78
+ "end_min": 1310
79
+ }
80
+ },
81
+ "weapon": {
82
+ "weapon_id": "W1",
83
+ "name": "brass award statuette",
84
+ "kind": "blunt object",
85
+ "origin_loc_id": "L4",
86
+ "requires_strength": false,
87
+ "leaves_trace": "blood and a dented base"
88
+ },
89
+ "suspects": [
90
+ {
91
+ "sus_id": "S1",
92
+ "name": "Margot Vane",
93
+ "role": "the widow and co-owner",
94
+ "persona_summary": "Poised and imperious in mourning black; clipped, controlled speech, and she resents every question.",
95
+ "is_culprit": true,
96
+ "physical_capability": {
97
+ "strength": true,
98
+ "mobility": true
99
+ },
100
+ "personality": {
101
+ "composure": 0.8,
102
+ "aggression": 0.5,
103
+ "evasiveness": 0.7
104
+ },
105
+ "tells": [
106
+ "a too-steady voice",
107
+ "smoothing her gloves"
108
+ ],
109
+ "knows_facts": [
110
+ "F1",
111
+ "F2"
112
+ ],
113
+ "secrets": [
114
+ "Cornelius was about to cut you out of the club; his death secures everything."
115
+ ],
116
+ "true_whereabouts": [
117
+ {
118
+ "window": {
119
+ "start_min": 1260,
120
+ "end_min": 1280
121
+ },
122
+ "loc_id": "L1",
123
+ "activity": "greeting patrons in the lounge",
124
+ "co_present_sus_ids": [
125
+ "S2"
126
+ ]
127
+ },
128
+ {
129
+ "window": {
130
+ "start_min": 1280,
131
+ "end_min": 1310
132
+ },
133
+ "loc_id": "L4",
134
+ "activity": "confronting Cornelius in the office",
135
+ "co_present_sus_ids": []
136
+ },
137
+ {
138
+ "window": {
139
+ "start_min": 1310,
140
+ "end_min": 1320
141
+ },
142
+ "loc_id": "L5",
143
+ "activity": "composing herself in the cloakroom",
144
+ "co_present_sus_ids": []
145
+ }
146
+ ],
147
+ "stated_alibi": {
148
+ "claim_text": "I was in the lounge the entire evening, in plain sight of a dozen guests.",
149
+ "claimed_segments": [
150
+ {
151
+ "window": {
152
+ "start_min": 1260,
153
+ "end_min": 1320
154
+ },
155
+ "loc_id": "L1",
156
+ "witness_sus_ids": [
157
+ "S2"
158
+ ]
159
+ }
160
+ ]
161
+ },
162
+ "must_lie_about": [
163
+ "F1"
164
+ ],
165
+ "anchored_lies": [
166
+ {
167
+ "lie_id": "LIE_alibi",
168
+ "topic": "where you were during the murder",
169
+ "claimed": "I never once left the lounge; ask any of the patrons.",
170
+ "truth_ref": "F1",
171
+ "breaks_on": [
172
+ "C1",
173
+ "C2"
174
+ ],
175
+ "fallback": "Fine - I stepped out to the cloakroom for my wrap, that is all. I never went near the office."
176
+ }
177
+ ],
178
+ "voice": null,
179
+ "visual": {
180
+ "subject_type": "suspect",
181
+ "palette": "noir",
182
+ "gender": "female",
183
+ "age_band": "50s",
184
+ "build": null,
185
+ "hair": null,
186
+ "attire": "a black beaded evening gown",
187
+ "mood": "imperious",
188
+ "accent_color": "#b8860b",
189
+ "location_tags": [],
190
+ "prop_tags": [],
191
+ "prompt_hint": ""
192
+ }
193
+ },
194
+ {
195
+ "sus_id": "S2",
196
+ "name": "Eddie Marsh",
197
+ "role": "the bartender",
198
+ "persona_summary": "Quick and affable, sweats easily, and keeps glancing at the till.",
199
+ "is_culprit": false,
200
+ "physical_capability": {
201
+ "strength": true,
202
+ "mobility": true
203
+ },
204
+ "personality": {
205
+ "composure": 0.3,
206
+ "aggression": 0.2,
207
+ "evasiveness": 0.6
208
+ },
209
+ "tells": [
210
+ "wiping a bar that is already clean"
211
+ ],
212
+ "knows_facts": [
213
+ "F3"
214
+ ],
215
+ "secrets": [
216
+ "You have been skimming from the till for months."
217
+ ],
218
+ "true_whereabouts": [
219
+ {
220
+ "window": {
221
+ "start_min": 1260,
222
+ "end_min": 1320
223
+ },
224
+ "loc_id": "L1",
225
+ "activity": "tending the bar",
226
+ "co_present_sus_ids": [
227
+ "S1",
228
+ "S4"
229
+ ]
230
+ }
231
+ ],
232
+ "stated_alibi": {
233
+ "claim_text": "I never left the bar; somebody always wanted a drink.",
234
+ "claimed_segments": [
235
+ {
236
+ "window": {
237
+ "start_min": 1260,
238
+ "end_min": 1320
239
+ },
240
+ "loc_id": "L1",
241
+ "witness_sus_ids": [
242
+ "S1",
243
+ "S4"
244
+ ]
245
+ }
246
+ ]
247
+ },
248
+ "must_lie_about": [
249
+ "F3"
250
+ ],
251
+ "anchored_lies": [
252
+ {
253
+ "lie_id": "LIE_till",
254
+ "topic": "the till and the money",
255
+ "claimed": "The till always comes up square; I would never touch it.",
256
+ "truth_ref": "F3",
257
+ "breaks_on": [
258
+ "C3"
259
+ ],
260
+ "fallback": "All right, I have borrowed against tips. It has nothing to do with Cornelius."
261
+ }
262
+ ],
263
+ "voice": null,
264
+ "visual": {
265
+ "subject_type": "suspect",
266
+ "palette": "noir",
267
+ "gender": "male",
268
+ "age_band": "30s",
269
+ "build": null,
270
+ "hair": null,
271
+ "attire": "shirtsleeves and a bar apron",
272
+ "mood": "nervous",
273
+ "accent_color": "#3a6ea5",
274
+ "location_tags": [],
275
+ "prop_tags": [],
276
+ "prompt_hint": ""
277
+ }
278
+ },
279
+ {
280
+ "sus_id": "S3",
281
+ "name": "Lillian Frost",
282
+ "role": "the headline singer",
283
+ "persona_summary": "Theatrical and guarded about her private life, mourning beneath the glamour.",
284
+ "is_culprit": false,
285
+ "physical_capability": {
286
+ "strength": true,
287
+ "mobility": true
288
+ },
289
+ "personality": {
290
+ "composure": 0.6,
291
+ "aggression": 0.4,
292
+ "evasiveness": 0.5
293
+ },
294
+ "tells": [
295
+ "a brittle laugh"
296
+ ],
297
+ "knows_facts": [
298
+ "F4"
299
+ ],
300
+ "secrets": [
301
+ "You were secretly in love with Cornelius and quarreled with him."
302
+ ],
303
+ "true_whereabouts": [
304
+ {
305
+ "window": {
306
+ "start_min": 1260,
307
+ "end_min": 1320
308
+ },
309
+ "loc_id": "L3",
310
+ "activity": "performing two sets on the terrace",
311
+ "co_present_sus_ids": []
312
+ }
313
+ ],
314
+ "stated_alibi": {
315
+ "claim_text": "I was on the terrace, singing, the whole hour. Two hundred people saw me.",
316
+ "claimed_segments": [
317
+ {
318
+ "window": {
319
+ "start_min": 1260,
320
+ "end_min": 1320
321
+ },
322
+ "loc_id": "L3",
323
+ "witness_sus_ids": []
324
+ }
325
+ ]
326
+ },
327
+ "must_lie_about": [
328
+ "F4"
329
+ ],
330
+ "anchored_lies": [
331
+ {
332
+ "lie_id": "LIE_affair",
333
+ "topic": "your relationship with Cornelius",
334
+ "claimed": "Cornelius and I were strictly professional.",
335
+ "truth_ref": "F4",
336
+ "breaks_on": [
337
+ "C4"
338
+ ],
339
+ "fallback": "We were close. I loved him. But I would never hurt him."
340
+ }
341
+ ],
342
+ "voice": null,
343
+ "visual": {
344
+ "subject_type": "suspect",
345
+ "palette": "noir",
346
+ "gender": "female",
347
+ "age_band": "20s",
348
+ "build": null,
349
+ "hair": null,
350
+ "attire": "a silver sequined stage dress",
351
+ "mood": "guarded",
352
+ "accent_color": "#9a9aa0",
353
+ "location_tags": [],
354
+ "prop_tags": [],
355
+ "prompt_hint": ""
356
+ }
357
+ },
358
+ {
359
+ "sus_id": "S4",
360
+ "name": "Septimus Boone",
361
+ "role": "the club's accountant",
362
+ "persona_summary": "Meticulous and nervous, speaks in numbers, and is terrified of an audit.",
363
+ "is_culprit": false,
364
+ "physical_capability": {
365
+ "strength": true,
366
+ "mobility": true
367
+ },
368
+ "personality": {
369
+ "composure": 0.4,
370
+ "aggression": 0.2,
371
+ "evasiveness": 0.7
372
+ },
373
+ "tells": [
374
+ "polishing his spectacles"
375
+ ],
376
+ "knows_facts": [
377
+ "F5"
378
+ ],
379
+ "secrets": [
380
+ "You falsified the ledgers to hide your own embezzlement."
381
+ ],
382
+ "true_whereabouts": [
383
+ {
384
+ "window": {
385
+ "start_min": 1260,
386
+ "end_min": 1320
387
+ },
388
+ "loc_id": "L1",
389
+ "activity": "going over receipts at a corner table",
390
+ "co_present_sus_ids": [
391
+ "S2"
392
+ ]
393
+ }
394
+ ],
395
+ "stated_alibi": {
396
+ "claim_text": "I sat at my usual corner table in the lounge with my books all night.",
397
+ "claimed_segments": [
398
+ {
399
+ "window": {
400
+ "start_min": 1260,
401
+ "end_min": 1320
402
+ },
403
+ "loc_id": "L1",
404
+ "witness_sus_ids": [
405
+ "S2"
406
+ ]
407
+ }
408
+ ]
409
+ },
410
+ "must_lie_about": [
411
+ "F5"
412
+ ],
413
+ "anchored_lies": [
414
+ {
415
+ "lie_id": "LIE_ledger",
416
+ "topic": "the ledgers and the books",
417
+ "claimed": "The books are in perfect order, I assure you.",
418
+ "truth_ref": "F5",
419
+ "breaks_on": [
420
+ "C5"
421
+ ],
422
+ "fallback": "There may be irregularities - mine, not murder. I was nowhere near him."
423
+ }
424
+ ],
425
+ "voice": null,
426
+ "visual": {
427
+ "subject_type": "suspect",
428
+ "palette": "noir",
429
+ "gender": "male",
430
+ "age_band": "40s",
431
+ "build": null,
432
+ "hair": null,
433
+ "attire": "a rumpled three-piece suit",
434
+ "mood": "anxious",
435
+ "accent_color": "#6b8f71",
436
+ "location_tags": [],
437
+ "prop_tags": [],
438
+ "prompt_hint": ""
439
+ }
440
+ }
441
+ ],
442
+ "culprit": {
443
+ "sus_id": "S1",
444
+ "true_motive": {
445
+ "motive_id": "M1",
446
+ "category": "greed",
447
+ "summary": "Cornelius was about to cut Margot out of the club; his death secures her inheritance."
448
+ },
449
+ "method_narrative": "You followed Cornelius to the office, argued about the will, and struck him with the brass statuette.",
450
+ "alibi_lie": {
451
+ "claimed_loc_id": "L1",
452
+ "actual_loc_id": "L4",
453
+ "contradicted_by_clue_ids": [
454
+ "C1",
455
+ "C2"
456
+ ]
457
+ }
458
+ },
459
+ "relationships": [
460
+ {
461
+ "from_sus_id": "S1",
462
+ "to_sus_id": "S2",
463
+ "kind": "employer of",
464
+ "sentiment": 0.1,
465
+ "known_publicly": true
466
+ },
467
+ {
468
+ "from_sus_id": "S4",
469
+ "to_sus_id": "S2",
470
+ "kind": "friendly with",
471
+ "sentiment": 0.3,
472
+ "known_publicly": true
473
+ }
474
+ ],
475
+ "facts": [
476
+ {
477
+ "fact_id": "F1",
478
+ "statement": "Margot was in the office during the murder window.",
479
+ "true_value": true,
480
+ "loc_id": "L4",
481
+ "at_min": 1290
482
+ },
483
+ {
484
+ "fact_id": "F2",
485
+ "statement": "Margot smokes Gauloises cigarettes.",
486
+ "true_value": true,
487
+ "loc_id": null,
488
+ "at_min": null
489
+ },
490
+ {
491
+ "fact_id": "F3",
492
+ "statement": "Eddie has been skimming from the till.",
493
+ "true_value": true,
494
+ "loc_id": null,
495
+ "at_min": null
496
+ },
497
+ {
498
+ "fact_id": "F4",
499
+ "statement": "Lillian was secretly in love with Cornelius.",
500
+ "true_value": true,
501
+ "loc_id": null,
502
+ "at_min": null
503
+ },
504
+ {
505
+ "fact_id": "F5",
506
+ "statement": "Boone falsified the club's ledgers.",
507
+ "true_value": true,
508
+ "loc_id": null,
509
+ "at_min": null
510
+ }
511
+ ],
512
+ "clues": [
513
+ {
514
+ "clue_id": "C1",
515
+ "name": "Cloakroom ticket (9:48 PM)",
516
+ "reveal_text": "A cloakroom claim ticket stamped 9:48 PM lies near the body - someone fetched a wrap mid-evening and ended up here.",
517
+ "discoverable_at_loc_id": "L4",
518
+ "discovery_method": "forensic",
519
+ "supports_fact_id": "F1",
520
+ "points_to_sus_id": "S1",
521
+ "contradicts_alibi_of": "S1",
522
+ "is_red_herring": false,
523
+ "weight": 1.0
524
+ },
525
+ {
526
+ "clue_id": "C2",
527
+ "name": "Gauloises ash",
528
+ "reveal_text": "Grey ash from a Gauloises cigarette on the office rug - an unusual brand.",
529
+ "discoverable_at_loc_id": "L4",
530
+ "discovery_method": "forensic",
531
+ "supports_fact_id": "F2",
532
+ "points_to_sus_id": "S1",
533
+ "contradicts_alibi_of": "S1",
534
+ "is_red_herring": false,
535
+ "weight": 0.7
536
+ },
537
+ {
538
+ "clue_id": "C3",
539
+ "name": "Doctored bar tab",
540
+ "reveal_text": "A bar ledger showing Eddie quietly forgiving his own tabs.",
541
+ "discoverable_at_loc_id": "L1",
542
+ "discovery_method": "document",
543
+ "supports_fact_id": "F3",
544
+ "points_to_sus_id": "S2",
545
+ "contradicts_alibi_of": null,
546
+ "is_red_herring": true,
547
+ "weight": 0.3
548
+ },
549
+ {
550
+ "clue_id": "C4",
551
+ "name": "Torn love letter",
552
+ "reveal_text": "A torn letter in Lillian's hand to Cornelius - equal parts love and fury.",
553
+ "discoverable_at_loc_id": "L5",
554
+ "discovery_method": "search",
555
+ "supports_fact_id": "F4",
556
+ "points_to_sus_id": "S3",
557
+ "contradicts_alibi_of": null,
558
+ "is_red_herring": true,
559
+ "weight": 0.3
560
+ },
561
+ {
562
+ "clue_id": "C5",
563
+ "name": "Altered ledger page",
564
+ "reveal_text": "A ledger page with figures scratched out and rewritten in Boone's hand.",
565
+ "discoverable_at_loc_id": "L4",
566
+ "discovery_method": "document",
567
+ "supports_fact_id": "F5",
568
+ "points_to_sus_id": "S4",
569
+ "contradicts_alibi_of": null,
570
+ "is_red_herring": true,
571
+ "weight": 0.3
572
+ }
573
+ ],
574
+ "solution": {
575
+ "culprit_sus_id": "S1",
576
+ "weapon_id": "W1",
577
+ "motive_id": "M1",
578
+ "minimal_clue_set": [
579
+ "C1"
580
+ ],
581
+ "deduction_chain": [
582
+ "Cornelius was killed in the office between 9:20 and 9:50.",
583
+ "Every suspect but Margot has a witness for that window.",
584
+ "Margot swears she never left the lounge - yet her cloakroom ticket, stamped 9:48, was found beside the body.",
585
+ "Her alibi is a lie; she was in the office. Margot Vane is the killer."
586
+ ]
587
+ }
588
+ }
docs/FIELD_NOTES.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Field Notes: building a detective game where the AI *is* the game
2
+
3
+ *Build Small Hackathon - "Small models, big adventure"*
4
+
5
+ ## The pitch
6
+
7
+ Case Zero is a murder-mystery game with no scripted cases and no content library. A single
8
+ **1.5B-parameter model** (Qwen2.5-1.5B-Instruct, Q4_K_M GGUF) invents the entire mystery
9
+ every time you play - the victim, the suspects, their secrets and motives, the timeline, the
10
+ weapon, the evidence, and the one who did it - and then **role-plays every suspect live**.
11
+ They remember what you asked. They lie. And when you present the piece of evidence that
12
+ contradicts an alibi, the lie cracks on screen.
13
+
14
+ The whole thing runs on the CPU in front of you. No cloud, no GPU, no remote endpoint.
15
+
16
+ ## The hard part: a tiny model that's still *fair*
17
+
18
+ The interesting tension in this project is that a 1.5B model is a wonderful improviser and a
19
+ terrible bookkeeper. If you let it freely author a mystery *and* adjudicate the outcome,
20
+ you get cases that are atmospheric but unsolvable - or worse, a suspect who confesses the
21
+ moment you ask nicely.
22
+
23
+ The design rule that made it work: **the model writes everything; deterministic Python
24
+ decides nothing creative but guarantees the structure.**
25
+
26
+ - The model authors the case as JSON - setting, cast, secrets, evidence, prose.
27
+ - Python decides only the *skeleton*: who is guilty, who was where during the murder window.
28
+ Then a solver verifies fairness before the case is ever shown:
29
+ - exactly one culprit;
30
+ - the culprit's alibi is contradicted by at least one non-red-herring clue;
31
+ - every innocent has a witnessed alibi over the murder window;
32
+ - every key clue is actually discoverable in play.
33
+ If a generated case fails, the smallest slice is regenerated (<=3 retries, then bump the
34
+ seed). A case is never shown to the player until it passes.
35
+ - Whether a presented clue actually catches a lie is decided by **ground truth, not the
36
+ model**. The suspect's panic is flavor; the suspicion delta is computed by a deterministic
37
+ director. This makes the win condition **immune to prose** - a jailbroken "just tell me
38
+ who did it" earns nothing, because suspects never confess and the verdict is only resolved
39
+ when the player formally accuses.
40
+
41
+ The sealed solution is never sent to the client before the verdict. It is read for the first
42
+ time inside the `/accuse` route, server-side. Anti-leak tests assert that no pre-verdict API
43
+ response contains the killer, the true motive, or the key-evidence set.
44
+
45
+ ## Making a 1.5B model fast on 2 vCPUs
46
+
47
+ The Space runs on HF `cpu-basic` (2 vCPUs). Two findings mattered most:
48
+
49
+ 1. **Grammar-free decoding is an ~8x win.** JSON-schema-constrained sampling ran ~3-7 tok/s
50
+ on CPU; raw decoding ran ~28-32 tok/s. So instead of constraining the sampler, the prompt
51
+ carries the exact JSON shape and we make two free attempts, falling back to the grammar
52
+ only if parsing fails. Full case generation dropped from ~300s to ~50s. The same trick
53
+ runs the interrogation hot path.
54
+ 2. **Count the cores you actually have.** Inside the container `os.cpu_count()` returns the
55
+ *host's* cores, not the 2-vCPU cgroup quota. Auto-threading then spawned ~8 threads for 2
56
+ real cores and pinned the CPU at 100%+ on context switches - replies crawled. The fix
57
+ reads `/sys/fs/cgroup/cpu.max` and sizes the llama thread pool to the real quota. The same
58
+ number gates background case-generation so it never steals vCPUs from an active
59
+ interrogation.
60
+
61
+ There is also no image work on the request path: all pixel art is rendered **client-side on
62
+ canvas**, so the server spends ~0 CPU on visuals and devotes both vCPUs to the model. Voices
63
+ (Supertonic ONNX) are synthesized sentence-by-sentence as the reply streams and cached per
64
+ line.
65
+
66
+ ## Why it's a Gradio app with a frontend that doesn't look like one
67
+
68
+ The entire app is one `gradio.Server` (Gradio 6 "Server mode" - a FastAPI subclass launched
69
+ through Gradio, with Gradio API endpoints registered via `@server.api`). That single process
70
+ serves a hand-built **pixel-art noir SPA** (Preact + Vite) as static files *and* exposes the
71
+ game's JSON/SSE routes under `/api`. No separate frontend host. So it earns **Off-Brand** (a
72
+ custom frontend well past the default Gradio look) while staying unambiguously a Gradio
73
+ application.
74
+
75
+ ## Shipping it
76
+
77
+ - **Off the Grid:** the open Qwen GGUF and Supertonic ONNX are baked into the Docker image at
78
+ build time, so the running container makes zero AI network calls. `scripts/net_audit.py`
79
+ runs a full playthrough under a socket guard and asserts zero non-loopback connections.
80
+ - **Llama Champion:** the model runs in-process through `llama-cpp-python`.
81
+ - **Docker SDK, not Gradio SDK:** llama-cpp-python ships only an sdist; the prebuilt linux
82
+ wheels are musl (they SIGILL on HF's glibc). The Dockerfile compiles llama.cpp from source
83
+ on `python:3.12-slim` (bookworm/gcc-12) with `-DGGML_NATIVE=OFF` for a portable build, and
84
+ bakes the weights.
85
+
86
+ ## What I'd do next
87
+
88
+ - A small **fine-tune** of the 1.5B on noir-suspect dialogue and strict-JSON case authoring,
89
+ published to the Hub (the "Well-Tuned" badge), to tighten persona consistency and cut the
90
+ occasional malformed-JSON retry.
91
+ - A daily seeded case and a shareable "case file" card.
92
+
93
+ ## Try it
94
+
95
+ - Space: `build-small-hackathon/case0`
96
+ - Stack: Qwen2.5-1.5B-Instruct (Apache-2.0) via llama.cpp, Supertonic ONNX voices, Preact
97
+ pixel-art SPA, all served by one `gradio.Server`.
docs/sample_agent_trace.json ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "what": "Case Zero - live agent trace. A 1.5B model (Qwen2.5-1.5B-Instruct via llama.cpp, on CPU, in-process) invents a whole murder mystery, then role-plays a suspect under interrogation. Suspicion is decided server-side by a deterministic director, not by the model; suspects never confess - you win by presenting the right evidence and accusing.",
3
+ "model": "Qwen2.5-1.5B-Instruct Q4_K_M (llama.cpp, CPU, in-process)",
4
+ "caseId": "gen-721096",
5
+ "generationSeconds": 77.6,
6
+ "title": "The Red Room",
7
+ "victim": "Sophia Johnson",
8
+ "suspects": [
9
+ {
10
+ "id": "S1",
11
+ "name": "Dr. Marcus Reeves",
12
+ "role": "Security Guard"
13
+ },
14
+ {
15
+ "id": "S2",
16
+ "name": "Ava Martinez",
17
+ "role": "Sophia Johnson’s Rival Artist"
18
+ },
19
+ {
20
+ "id": "S3",
21
+ "name": "Richard White",
22
+ "role": "Sophia’s Rival Artist’s Lover"
23
+ },
24
+ {
25
+ "id": "S4",
26
+ "name": "Ethan Thompson",
27
+ "role": "Sophia’s Business Partner"
28
+ }
29
+ ],
30
+ "turns": [
31
+ {
32
+ "suspect": "Dr. Marcus Reeves",
33
+ "detective_asks": "Where were you when it happened?",
34
+ "suspect_reply": "Oh my God. It was an accident then?",
35
+ "suspicion": 39,
36
+ "delta": 0,
37
+ "flags": {
38
+ "rattled": false,
39
+ "contradictionExposed": false,
40
+ "cornered": false
41
+ }
42
+ },
43
+ {
44
+ "suspect": "Dr. Marcus Reeves",
45
+ "detective_asks": "What was your relationship with the victim?",
46
+ "suspect_reply": "There's nothing more to it than that.",
47
+ "suspicion": 39,
48
+ "delta": 0,
49
+ "flags": {
50
+ "rattled": false,
51
+ "contradictionExposed": false,
52
+ "cornered": false
53
+ }
54
+ },
55
+ {
56
+ "suspect": "Dr. Marcus Reeves",
57
+ "detective_asks": "Did you see anyone near the scene?",
58
+ "suspect_reply": "You're reaching, Detective.",
59
+ "suspicion": 39,
60
+ "delta": 0,
61
+ "flags": {
62
+ "rattled": false,
63
+ "contradictionExposed": false,
64
+ "cornered": false
65
+ }
66
+ },
67
+ {
68
+ "suspect": "Dr. Marcus Reeves",
69
+ "detective_asks": "Is there anything you haven't told me?",
70
+ "suspect_reply": "Oh, Detective... It wasn't an accident after all.",
71
+ "suspicion": 39,
72
+ "delta": 0,
73
+ "flags": {
74
+ "rattled": false,
75
+ "contradictionExposed": false,
76
+ "cornered": false
77
+ }
78
+ },
79
+ {
80
+ "suspect": "Dr. Marcus Reeves",
81
+ "detective_presents": "PARTIAL FINGERPRINT",
82
+ "suspect_reply": "That doesn't prove a thing, and you know it.",
83
+ "suspicion": 81,
84
+ "delta": 42,
85
+ "flags": {
86
+ "rattled": true,
87
+ "contradictionExposed": true,
88
+ "cornered": true
89
+ }
90
+ },
91
+ {
92
+ "suspect": "Dr. Marcus Reeves",
93
+ "detective_presents": "SNAGGED THREAD",
94
+ "suspect_reply": "You can stare all you like - it wasn't me.",
95
+ "suspicion": 100,
96
+ "delta": 19,
97
+ "flags": {
98
+ "rattled": true,
99
+ "contradictionExposed": false,
100
+ "cornered": false
101
+ }
102
+ }
103
+ ]
104
+ }
pyproject.toml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "case-zero"
3
+ version = "0.1.0"
4
+ description = "Case Zero - a fully AI-driven detective game running on a single local small model (<=4B)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12,<3.13"
7
+ license = { text = "Apache-2.0" }
8
+ authors = [{ name = "Hussein Eid" }]
9
+ dependencies = [
10
+ "gradio>=6.16,<7",
11
+ "pydantic>=2.13,<3",
12
+ "pydantic-settings>=2.3",
13
+ "pillow>=11.3,<13",
14
+ "numpy>=1.26",
15
+ # llama-cpp-python and supertonic are installed separately (heavy / platform wheels).
16
+ # See requirements.txt for the full pinned runtime set used on the HF Space.
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/case_zero"]
25
+
26
+ [tool.ruff]
27
+ line-length = 100
28
+ target-version = "py312"
29
+ src = ["src"]
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
33
+ ignore = ["E501"]
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Case Zero - Hugging Face Space (Docker SDK, CPU). Python 3.12.
2
+ # Every model is open-weights and self-run. Total runtime params ~1.6B
3
+ # (Qwen2.5-1.5B + Supertonic ~0.1B), far under the 32B cap (<=4B Tiny Titan).
4
+
5
+ # LLM runtime: in-process llama.cpp, compiled from source in the Docker build (PyPI ships
6
+ # only an sdist; the prebuilt linux wheels are musl-only and fail on glibc). The Dockerfile
7
+ # supplies gcc + cmake and a portable (non-native) build; the compiled layer is cached.
8
+ llama-cpp-python==0.3.26
9
+
10
+ # gradio 6 ships `gradio.Server` (FastAPI-based custom-frontend / "Server mode"), which
11
+ # serves the Preact pixel-art SPA + the /api routes from one process. It relaxes the old
12
+ # pydantic<2.12 cap.
13
+ gradio>=6.16,<7
14
+ pydantic>=2.13,<3
15
+ pydantic-settings>=2.3,<3
16
+ pillow>=11.3,<13
17
+ numpy==2.2.6
18
+
19
+ # TTS: Supertonic (on-device ONNX voices). Pulls onnxruntime.
20
+ supertonic==1.3.1
21
+
22
+ # Used at startup to fetch the open GGUF weights onto the Space.
23
+ huggingface_hub==0.36.0
scripts/build_tutorial_case.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Author the hand-crafted tutorial case and write cases/seeds/tutorial.json.
2
+
3
+ The tutorial doubles as: the onboarding case that teaches the loop, a deterministic
4
+ demo case, and a golden test fixture. It is fully solvable by construction; the
5
+ solvability checker is run before it is written.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ sys.path.insert(0, str(ROOT / "src"))
15
+
16
+ from case_zero.constants import SEED_CASES_DIR # noqa: E402
17
+ from case_zero.persistence.case_store import save_case # noqa: E402
18
+ from case_zero.schemas import ( # noqa: E402
19
+ AlibiLie,
20
+ AlibiSegment,
21
+ AnchoredLie,
22
+ CaseFile,
23
+ Clue,
24
+ Culprit,
25
+ Difficulty,
26
+ DiscoveryMethod,
27
+ Fact,
28
+ GenerationKnobs,
29
+ Location,
30
+ Motive,
31
+ MotiveCategory,
32
+ PersonalityAxes,
33
+ PhysicalCapability,
34
+ Relationship,
35
+ Setting,
36
+ Solution,
37
+ StatedAlibi,
38
+ Suspect,
39
+ TimeWindow,
40
+ Victim,
41
+ VisualDescriptor,
42
+ Weapon,
43
+ WhereaboutsSegment,
44
+ )
45
+ from case_zero.schemas.enums import SubjectType # noqa: E402
46
+
47
+
48
+ def m(hour: int, minute: int) -> int:
49
+ return hour * 60 + minute
50
+
51
+
52
+ WINDOW = TimeWindow(start_min=m(21, 0), end_min=m(22, 0))
53
+
54
+
55
+ def _locations() -> tuple[Location, ...]:
56
+ return (
57
+ Location(loc_id="L1", name="The Lounge", description="The main club floor.",
58
+ adjacent_to=("L2", "L3", "L5")),
59
+ Location(loc_id="L2", name="The Kitchen", adjacent_to=("L1",)),
60
+ Location(loc_id="L3", name="The Terrace", description="The open rooftop stage.",
61
+ adjacent_to=("L1",)),
62
+ Location(loc_id="L4", name="The Office", description="Vane's private office.",
63
+ adjacent_to=("L5",)),
64
+ Location(loc_id="L5", name="The Cloakroom", adjacent_to=("L1", "L4")),
65
+ )
66
+
67
+
68
+ def _facts() -> tuple[Fact, ...]:
69
+ return (
70
+ Fact(fact_id="F1", statement="Margot was in the office during the murder window.",
71
+ true_value=True, loc_id="L4", at_min=m(21, 30)),
72
+ Fact(fact_id="F2", statement="Margot smokes Gauloises cigarettes.", true_value=True),
73
+ Fact(fact_id="F3", statement="Eddie has been skimming from the till.", true_value=True),
74
+ Fact(fact_id="F4", statement="Lillian was secretly in love with Cornelius.", true_value=True),
75
+ Fact(fact_id="F5", statement="Boone falsified the club's ledgers.", true_value=True),
76
+ )
77
+
78
+
79
+ def _clues() -> tuple[Clue, ...]:
80
+ return (
81
+ Clue(clue_id="C1", name="Cloakroom ticket (9:48 PM)",
82
+ reveal_text="A cloakroom claim ticket stamped 9:48 PM lies near the body - someone "
83
+ "fetched a wrap mid-evening and ended up here.",
84
+ discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC,
85
+ supports_fact_id="F1", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=1.0),
86
+ Clue(clue_id="C2", name="Gauloises ash",
87
+ reveal_text="Grey ash from a Gauloises cigarette on the office rug - an unusual brand.",
88
+ discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC,
89
+ supports_fact_id="F2", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=0.7),
90
+ Clue(clue_id="C3", name="Doctored bar tab",
91
+ reveal_text="A bar ledger showing Eddie quietly forgiving his own tabs.",
92
+ discoverable_at_loc_id="L1", discovery_method=DiscoveryMethod.DOCUMENT,
93
+ supports_fact_id="F3", points_to_sus_id="S2", is_red_herring=True, weight=0.3),
94
+ Clue(clue_id="C4", name="Torn love letter",
95
+ reveal_text="A torn letter in Lillian's hand to Cornelius - equal parts love and fury.",
96
+ discoverable_at_loc_id="L5", discovery_method=DiscoveryMethod.SEARCH,
97
+ supports_fact_id="F4", points_to_sus_id="S3", is_red_herring=True, weight=0.3),
98
+ Clue(clue_id="C5", name="Altered ledger page",
99
+ reveal_text="A ledger page with figures scratched out and rewritten in Boone's hand.",
100
+ discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.DOCUMENT,
101
+ supports_fact_id="F5", points_to_sus_id="S4", is_red_herring=True, weight=0.3),
102
+ )
103
+
104
+
105
+ def _visual(age: str, attire: str, mood: str, accent: str, gender: str) -> VisualDescriptor:
106
+ return VisualDescriptor(subject_type=SubjectType.SUSPECT, palette="noir", age_band=age,
107
+ attire=attire, mood=mood, accent_color=accent, gender=gender)
108
+
109
+
110
+ def _suspects() -> tuple[Suspect, ...]:
111
+ margot = Suspect(
112
+ sus_id="S1", name="Margot Vane", role="the widow and co-owner",
113
+ persona_summary="Poised and imperious in mourning black; clipped, controlled speech, and "
114
+ "she resents every question.",
115
+ is_culprit=True,
116
+ physical_capability=PhysicalCapability(strength=True, mobility=True),
117
+ personality=PersonalityAxes(composure=0.8, aggression=0.5, evasiveness=0.7),
118
+ tells=("a too-steady voice", "smoothing her gloves"),
119
+ knows_facts=("F1", "F2"),
120
+ secrets=("Cornelius was about to cut you out of the club; his death secures everything.",),
121
+ true_whereabouts=(
122
+ WhereaboutsSegment(window=TimeWindow(start_min=m(21, 0), end_min=m(21, 20)),
123
+ loc_id="L1", activity="greeting patrons in the lounge",
124
+ co_present_sus_ids=("S2",)),
125
+ WhereaboutsSegment(window=TimeWindow(start_min=m(21, 20), end_min=m(21, 50)),
126
+ loc_id="L4", activity="confronting Cornelius in the office"),
127
+ WhereaboutsSegment(window=TimeWindow(start_min=m(21, 50), end_min=m(22, 0)),
128
+ loc_id="L5", activity="composing herself in the cloakroom"),
129
+ ),
130
+ stated_alibi=StatedAlibi(
131
+ claim_text="I was in the lounge the entire evening, in plain sight of a dozen guests.",
132
+ claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),),
133
+ ),
134
+ must_lie_about=("F1",),
135
+ anchored_lies=(
136
+ AnchoredLie(
137
+ lie_id="LIE_alibi", topic="where you were during the murder",
138
+ claimed="I never once left the lounge; ask any of the patrons.",
139
+ truth_ref="F1", breaks_on=("C1", "C2"),
140
+ fallback="Fine - I stepped out to the cloakroom for my wrap, that is all. I never "
141
+ "went near the office.",
142
+ ),
143
+ ),
144
+ visual=_visual("50s", "a black beaded evening gown", "imperious", "#b8860b", "female"),
145
+ )
146
+
147
+ eddie = Suspect(
148
+ sus_id="S2", name="Eddie Marsh", role="the bartender",
149
+ persona_summary="Quick and affable, sweats easily, and keeps glancing at the till.",
150
+ personality=PersonalityAxes(composure=0.3, aggression=0.2, evasiveness=0.6),
151
+ tells=("wiping a bar that is already clean",),
152
+ knows_facts=("F3",),
153
+ secrets=("You have been skimming from the till for months.",),
154
+ true_whereabouts=(
155
+ WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="tending the bar",
156
+ co_present_sus_ids=("S1", "S4")),
157
+ ),
158
+ stated_alibi=StatedAlibi(
159
+ claim_text="I never left the bar; somebody always wanted a drink.",
160
+ claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S1", "S4")),),
161
+ ),
162
+ must_lie_about=("F3",),
163
+ anchored_lies=(
164
+ AnchoredLie(lie_id="LIE_till", topic="the till and the money",
165
+ claimed="The till always comes up square; I would never touch it.",
166
+ truth_ref="F3", breaks_on=("C3",),
167
+ fallback="All right, I have borrowed against tips. It has nothing to do "
168
+ "with Cornelius."),
169
+ ),
170
+ visual=_visual("30s", "shirtsleeves and a bar apron", "nervous", "#3a6ea5", "male"),
171
+ )
172
+
173
+ lillian = Suspect(
174
+ sus_id="S3", name="Lillian Frost", role="the headline singer",
175
+ persona_summary="Theatrical and guarded about her private life, mourning beneath the glamour.",
176
+ personality=PersonalityAxes(composure=0.6, aggression=0.4, evasiveness=0.5),
177
+ tells=("a brittle laugh",),
178
+ knows_facts=("F4",),
179
+ secrets=("You were secretly in love with Cornelius and quarreled with him.",),
180
+ true_whereabouts=(
181
+ WhereaboutsSegment(window=WINDOW, loc_id="L3", activity="performing two sets on the terrace"),
182
+ ),
183
+ stated_alibi=StatedAlibi(
184
+ claim_text="I was on the terrace, singing, the whole hour. Two hundred people saw me.",
185
+ claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L3"),),
186
+ ),
187
+ must_lie_about=("F4",),
188
+ anchored_lies=(
189
+ AnchoredLie(lie_id="LIE_affair", topic="your relationship with Cornelius",
190
+ claimed="Cornelius and I were strictly professional.",
191
+ truth_ref="F4", breaks_on=("C4",),
192
+ fallback="We were close. I loved him. But I would never hurt him."),
193
+ ),
194
+ visual=_visual("20s", "a silver sequined stage dress", "guarded", "#9a9aa0", "female"),
195
+ )
196
+
197
+ boone = Suspect(
198
+ sus_id="S4", name="Septimus Boone", role="the club's accountant",
199
+ persona_summary="Meticulous and nervous, speaks in numbers, and is terrified of an audit.",
200
+ personality=PersonalityAxes(composure=0.4, aggression=0.2, evasiveness=0.7),
201
+ tells=("polishing his spectacles",),
202
+ knows_facts=("F5",),
203
+ secrets=("You falsified the ledgers to hide your own embezzlement.",),
204
+ true_whereabouts=(
205
+ WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="going over receipts at a corner table",
206
+ co_present_sus_ids=("S2",)),
207
+ ),
208
+ stated_alibi=StatedAlibi(
209
+ claim_text="I sat at my usual corner table in the lounge with my books all night.",
210
+ claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),),
211
+ ),
212
+ must_lie_about=("F5",),
213
+ anchored_lies=(
214
+ AnchoredLie(lie_id="LIE_ledger", topic="the ledgers and the books",
215
+ claimed="The books are in perfect order, I assure you.",
216
+ truth_ref="F5", breaks_on=("C5",),
217
+ fallback="There may be irregularities - mine, not murder. I was nowhere "
218
+ "near him."),
219
+ ),
220
+ visual=_visual("40s", "a rumpled three-piece suit", "anxious", "#6b8f71", "male"),
221
+ )
222
+
223
+ return (margot, eddie, lillian, boone)
224
+
225
+
226
+ def build_tutorial_case() -> CaseFile:
227
+ return CaseFile(
228
+ case_id="tutorial-gilded-aerie",
229
+ seed=1989,
230
+ title="The Gilded Aerie",
231
+ briefing=(
232
+ "1924. On the rooftop of the Gilded Aerie supper club, owner Cornelius Vane is found "
233
+ "dead in his office, struck down with his own brass award. The club was full; everyone "
234
+ "had a reason to resent him. Four people could have slipped away during the murder hour. "
235
+ "Question them, search the rooms, and present what you find. One of them is lying about "
236
+ "where they were - catch that lie, and you have your killer."
237
+ ),
238
+ knobs=GenerationKnobs(setting_hint="rooftop supper club", era_hint="1924",
239
+ tone_hint="jazz-age noir", n_suspects=4, n_red_herrings=3,
240
+ difficulty=Difficulty.GENTLE),
241
+ setting=Setting(name="The Gilded Aerie", description="A rooftop supper club above the city.",
242
+ locations=_locations(), murder_window=WINDOW),
243
+ victim=Victim(vic_id="V1", name="Cornelius Vane", role="club owner", found_at_loc_id="L4",
244
+ found_at_min=m(22, 5), cause_of_death="blunt force from a brass statuette",
245
+ time_of_death=TimeWindow(start_min=m(21, 20), end_min=m(21, 50))),
246
+ weapon=Weapon(weapon_id="W1", name="brass award statuette", kind="blunt object",
247
+ origin_loc_id="L4", requires_strength=False, leaves_trace="blood and a dented base"),
248
+ suspects=_suspects(),
249
+ culprit=Culprit(
250
+ sus_id="S1",
251
+ true_motive=Motive(motive_id="M1", category=MotiveCategory.GREED,
252
+ summary="Cornelius was about to cut Margot out of the club; his death "
253
+ "secures her inheritance."),
254
+ method_narrative="You followed Cornelius to the office, argued about the will, and "
255
+ "struck him with the brass statuette.",
256
+ alibi_lie=AlibiLie(claimed_loc_id="L1", actual_loc_id="L4",
257
+ contradicted_by_clue_ids=("C1", "C2")),
258
+ ),
259
+ relationships=(
260
+ Relationship(from_sus_id="S1", to_sus_id="S2", kind="employer of", sentiment=0.1),
261
+ Relationship(from_sus_id="S4", to_sus_id="S2", kind="friendly with", sentiment=0.3),
262
+ ),
263
+ facts=_facts(),
264
+ clues=_clues(),
265
+ solution=Solution(
266
+ culprit_sus_id="S1", weapon_id="W1", motive_id="M1", minimal_clue_set=("C1",),
267
+ deduction_chain=(
268
+ "Cornelius was killed in the office between 9:20 and 9:50.",
269
+ "Every suspect but Margot has a witness for that window.",
270
+ "Margot swears she never left the lounge - yet her cloakroom ticket, stamped 9:48, "
271
+ "was found beside the body.",
272
+ "Her alibi is a lie; she was in the office. Margot Vane is the killer.",
273
+ ),
274
+ ),
275
+ )
276
+
277
+
278
+ def main() -> None:
279
+ case = build_tutorial_case()
280
+ out = SEED_CASES_DIR / "tutorial.json"
281
+ save_case(case, out)
282
+ print(f"wrote {out} ({len(case.suspects)} suspects, {len(case.clues)} clues)")
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()
scripts/curate_prebaked.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Re-filter, title-case, and renumber the pre-baked pool so only clean, exciting cases ship.
2
+
3
+ Run after ``prebake_cases.py``. Drops any case that fails the (hardened) quality filter,
4
+ tidies the title's capitalisation, and rewrites the survivors as a contiguous CASE-0001..N.
5
+ No model needed - this only reads and rewrites JSON.
6
+
7
+ python scripts/curate_prebaked.py
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ sys.path.insert(0, str(ROOT / "src"))
17
+ sys.path.insert(0, str(ROOT / "scripts"))
18
+
19
+ from prebake_cases import _is_exciting # noqa: E402
20
+
21
+ from case_zero.persistence.case_store import load_case, save_case # noqa: E402
22
+ from case_zero.persistence.paths import prebaked_cases_dir # noqa: E402
23
+
24
+ _SMALL = {"a", "an", "the", "of", "in", "at", "on", "and", "or", "to", "for", "with", "by", "from"}
25
+
26
+
27
+ def _titlecase(t: str) -> str:
28
+ words = t.split()
29
+ out = []
30
+ for i, w in enumerate(words):
31
+ lw = w.lower()
32
+ if i != 0 and lw in _SMALL:
33
+ out.append(lw)
34
+ else:
35
+ out.append(w[:1].upper() + w[1:] if w else w)
36
+ return " ".join(out)
37
+
38
+
39
+ def main() -> int:
40
+ d = prebaked_cases_dir()
41
+ files = sorted(d.glob("CASE-*.json"))
42
+ survivors = []
43
+ for p in files:
44
+ case = load_case(p)
45
+ ok, why = _is_exciting(case)
46
+ if not ok:
47
+ print(f"DROP {p.stem}: {why} -- '{case.title}'")
48
+ continue
49
+ survivors.append(case)
50
+
51
+ for p in files:
52
+ p.unlink()
53
+ for i, case in enumerate(survivors, 1):
54
+ cid = f"CASE-{i:04d}"
55
+ case = case.model_copy(update={"case_id": cid, "title": _titlecase(case.title)})
56
+ save_case(case, d / f"{cid}.json")
57
+ cast = ", ".join(f"{s.name} ({s.visual.gender[:1].upper()})" for s in case.suspects)
58
+ print(f"KEEP {cid}: '{case.title}' - victim {case.victim.name} | {cast}")
59
+
60
+ print(f"\nFINAL POOL: {len(survivors)} cases")
61
+ return 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
scripts/fetch_models.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Open-weights fetcher. Downloads the models once so the runtime can run locally:
2
+ - the LLM GGUF (Qwen2.5-1.5B-Instruct, Q4_K_M) into models/llm/;
3
+ - the Supertonic ONNX TTS model into its cache.
4
+
5
+ Run once locally (or it is invoked automatically at app startup if the GGUF is missing).
6
+
7
+ python scripts/fetch_models.py
8
+ python scripts/fetch_models.py --llm-only
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ ROOT = Path(__file__).resolve().parent.parent
18
+ sys.path.insert(0, str(ROOT / "src"))
19
+
20
+ LLM_DIR = ROOT / "models" / "llm"
21
+ # The single model the game runs on: Qwen2.5-1.5B (fast, <=4B Tiny Titan).
22
+ LLM_CANDIDATES = (
23
+ ("Qwen/Qwen2.5-1.5B-Instruct-GGUF", "qwen2.5-1.5b-instruct-q4_k_m.gguf",
24
+ LLM_DIR / "qwen2.5-1.5b-instruct-q4_k_m.gguf"),
25
+ )
26
+
27
+ def _prefetch_supertonic() -> bool:
28
+ """Cache the Supertonic ONNX model so the runtime never downloads (off-grid)."""
29
+ try:
30
+ from supertonic import TTS
31
+
32
+ TTS(auto_download=True)
33
+ print("[fetch] Supertonic model cached")
34
+ return True
35
+ except Exception as exc:
36
+ print(f"[fetch] Supertonic prefetch failed: {exc}", file=sys.stderr)
37
+ return False
38
+
39
+
40
+ def _download(repo: str, filename: str, dest: Path) -> bool:
41
+ from huggingface_hub import hf_hub_download
42
+
43
+ try:
44
+ path = hf_hub_download(repo_id=repo, filename=filename)
45
+ dest.parent.mkdir(parents=True, exist_ok=True)
46
+ dest.write_bytes(Path(path).read_bytes())
47
+ print(f"[fetch] {repo}/{filename} -> {dest} ({dest.stat().st_size} bytes)")
48
+ return True
49
+ except Exception as exc:
50
+ print(f"[fetch] FAILED {repo}/{filename}: {exc}", file=sys.stderr)
51
+ return False
52
+
53
+
54
+ def main() -> int:
55
+ parser = argparse.ArgumentParser(description="Fetch open weights for Case Zero")
56
+ parser.add_argument("--llm-only", action="store_true")
57
+ parser.add_argument("--tts-only", action="store_true")
58
+ args = parser.parse_args()
59
+
60
+ ok = True
61
+ if not args.tts_only:
62
+ llm_ok = False
63
+ for repo, filename, dest in LLM_CANDIDATES:
64
+ if _download(repo, filename, dest):
65
+ llm_ok = True
66
+ break
67
+ ok &= llm_ok
68
+ if not args.llm_only:
69
+ ok &= _prefetch_supertonic()
70
+ return 0 if ok else 1
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())
scripts/net_audit.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Off-grid proof: run a full playthrough with a socket guard and assert that no
2
+ non-loopback network connection is attempted.
3
+
4
+ python scripts/net_audit.py
5
+
6
+ Exits non-zero if any outbound connection is made. This backs the "Off the Grid"
7
+ claim: the served game (case load, interrogation, evidence, accusation, and the
8
+ on-device voice) reaches the network zero times.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import os
15
+ import socket
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ ROOT = Path(__file__).resolve().parent.parent
20
+ sys.path.insert(0, str(ROOT / "src"))
21
+
22
+ # Mirror the deployed container: weights are baked in, analytics off, hub offline -
23
+ # any accidental download would surface as a violation rather than silently succeed.
24
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
25
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
26
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
27
+
28
+ _violations: list[str] = []
29
+ _orig_connect = socket.socket.connect
30
+
31
+
32
+ def _is_loopback(host: str) -> bool:
33
+ return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
34
+
35
+
36
+ def _guard(self, address): # type: ignore[no-untyped-def]
37
+ host = address[0] if isinstance(address, tuple) else str(address)
38
+ if not _is_loopback(str(host)):
39
+ _violations.append(str(host))
40
+ raise OSError(f"net_audit: blocked non-loopback connect to {host}")
41
+ return _orig_connect(self, address)
42
+
43
+
44
+ def main() -> int:
45
+ socket.socket.connect = _guard # type: ignore[method-assign]
46
+
47
+ from starlette.testclient import TestClient
48
+
49
+ from case_zero.api import build_server
50
+
51
+ client = TestClient(build_server())
52
+
53
+ # A full play loop over the golden case - load, interrogate, present the breaking
54
+ # exhibit, read a hint, then accuse - all server-authoritative, no network.
55
+ run = client.post("/api/case", json={"caseId": "GRAYMOOR-3107"}).json()["runId"]
56
+ client.post(f"/api/run/{run}/interrogate/iris", json={"questionId": "q3"})
57
+ client.post(f"/api/run/{run}/interrogate/iris", json={"presentEvidenceId": "keycard"})
58
+ client.get(f"/api/run/{run}/hint", params={"screen": "interro"})
59
+ client.post(
60
+ f"/api/run/{run}/accuse",
61
+ json={
62
+ "suspectId": "iris",
63
+ "motiveId": "m_credit",
64
+ "evidenceIds": ["keycard", "voicemail", "cctv"],
65
+ },
66
+ )
67
+
68
+ # Exercise the on-device voice path too (local Supertonic ONNX). Best-effort: a missing
69
+ # voice model is fine; a *network* attempt would already be recorded as a violation.
70
+ with contextlib.suppress(Exception):
71
+ client.post(f"/api/run/{run}/tts/iris", json={"text": "I was in the library all night."})
72
+
73
+ if _violations:
74
+ print(f"FAIL: {len(_violations)} outbound connection(s): {sorted(set(_violations))}")
75
+ return 1
76
+ print("PASS: full playthrough made zero non-loopback connections (Off the Grid verified).")
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ raise SystemExit(main())
scripts/prebake_cases.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-bake a pool of full, model-authored cases for instant New Case serving.
2
+
3
+ Generation on a 2-vCPU Space takes ~1-2 minutes, so the player would otherwise stare at a
4
+ loading screen. This script runs the SAME in-process llama.cpp generator offline, keeps only
5
+ solvable, well-formed, "exciting" cases (distinct human suspects, a real motive, no
6
+ detective/officer suspects, a gender mix), assigns each a stable Case ID, and writes the full
7
+ sealed CaseFile JSON to ``cases/prebaked/``. The Space ships these and serves one instantly on
8
+ New Case while still running every interrogation live (and generating fresh cases when the
9
+ hardware allows). The pre-baked cases are authored by the local model - no cloud, still
10
+ Off-the-Grid.
11
+
12
+ python scripts/prebake_cases.py [target_count] [start_seed]
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ ROOT = Path(__file__).resolve().parent.parent
22
+ sys.path.insert(0, str(ROOT / "src"))
23
+
24
+ from case_zero.config import get_settings # noqa: E402
25
+ from case_zero.generator.pipeline import generate_case # noqa: E402
26
+ from case_zero.llm.backend import make_backend # noqa: E402
27
+ from case_zero.persistence.case_store import save_case # noqa: E402
28
+ from case_zero.persistence.paths import prebaked_cases_dir # noqa: E402
29
+ from case_zero.schemas.case import CaseFile # noqa: E402
30
+
31
+ _BAD_ROLE = re.compile(
32
+ r"\b(detective|officer|investigator|police|inspector|sergeant|constable|cop|agent)\b",
33
+ re.IGNORECASE,
34
+ )
35
+ # Filler names a small model reaches for - they read as obviously fake and kill the mood.
36
+ _PLACEHOLDER_NAMES = {
37
+ "john doe", "jane doe", "john smith", "jane smith", "joe bloggs", "richard roe",
38
+ "mary major", "john q public", "tom johnson", "tom smith", "jack smith", "jane roe",
39
+ "john brown", "bob smith", "foo bar", "first last", "name surname",
40
+ }
41
+ def _name_malformed(n: str) -> bool:
42
+ # The model sometimes bakes a gender/age/label into the name: "John Smith, Male",
43
+ # "Lara White, 45". Reject anything with a comma, digits, or a gender word.
44
+ return bool("," in n or any(c.isdigit() for c in n) or re.search(r"\b(male|female)\b", n, re.I))
45
+
46
+
47
+ def _name_prefix(n: str) -> str:
48
+ return " ".join(n.lower().replace(",", " ").split()[:2])
49
+
50
+
51
+ def _is_exciting(case: CaseFile) -> tuple[bool, str]:
52
+ """Reject bland or malformed cases; keep ones that will read well to a judge."""
53
+ title = (case.title or "").strip()
54
+ if len(title) < 5:
55
+ return False, "weak title"
56
+ vname = case.victim.name.strip()
57
+ if not vname or " " not in vname or _name_malformed(vname):
58
+ return False, f"victim needs a clean full name: '{vname}'"
59
+ names = [s.name.strip() for s in case.suspects]
60
+ low = [n.lower() for n in names]
61
+ if len(set(low)) != len(names):
62
+ return False, "duplicate suspect names"
63
+ if any(len(n) < 3 or " " not in n for n in names):
64
+ return False, "suspect needs a full name"
65
+ if any(_name_malformed(n) for n in names):
66
+ return False, f"malformed name (comma/digit/gender): {names}"
67
+ if any(_name_prefix(n) in _PLACEHOLDER_NAMES for n in names):
68
+ return False, f"placeholder name: {names}"
69
+ roles = [s.role.strip().lower() for s in case.suspects]
70
+ if len(set(roles)) < len(roles):
71
+ return False, "duplicate suspect roles"
72
+ for s in case.suspects:
73
+ if _BAD_ROLE.search(s.role) or _BAD_ROLE.search(s.name):
74
+ return False, f"detective-like suspect: {s.name} ({s.role})"
75
+ if not any((s.visual.gender or "").lower().startswith("f") for s in case.suspects):
76
+ return False, "no female suspect"
77
+ if not any((s.visual.gender or "").lower().startswith("m") for s in case.suspects):
78
+ return False, "no male suspect"
79
+ # A real culprit with a written motive and method makes the mystery land.
80
+ if not (case.culprit.method_narrative or "").strip():
81
+ return False, "no method narrative"
82
+ return True, "ok"
83
+
84
+
85
+ def main() -> int:
86
+ target = int(sys.argv[1]) if len(sys.argv) > 1 else 10
87
+ start_seed = int(sys.argv[2]) if len(sys.argv) > 2 else 42000
88
+ max_attempts = target * 4 + 8
89
+
90
+ backend = make_backend(get_settings())
91
+ out_dir = prebaked_cases_dir()
92
+ out_dir.mkdir(parents=True, exist_ok=True)
93
+
94
+ kept: list[CaseFile] = []
95
+ seed = start_seed
96
+ attempts = 0
97
+ while len(kept) < target and attempts < max_attempts:
98
+ attempts += 1
99
+ try:
100
+ result = generate_case(backend, seed=seed)
101
+ except Exception as exc: # generation hiccup - skip this seed
102
+ print(f"[seed {seed}] generation error: {exc}")
103
+ seed += 1
104
+ continue
105
+ seed += 1
106
+ if not result.report.ok:
107
+ print(f"[seed {seed - 1}] unsolvable, skipped")
108
+ continue
109
+ ok, why = _is_exciting(result.case)
110
+ if not ok:
111
+ print(f"[seed {seed - 1}] rejected: {why} -- '{result.case.title}'")
112
+ continue
113
+ case_id = f"CASE-{len(kept) + 1:04d}"
114
+ case = result.case.model_copy(update={"case_id": case_id})
115
+ save_case(case, out_dir / f"{case_id}.json")
116
+ kept.append(case)
117
+ cast = ", ".join(f"{s.name} ({s.visual.gender[:1].upper()})" for s in case.suspects)
118
+ print(f"[KEEP {case_id}] '{case.title}' - victim {case.victim.name} | {cast}")
119
+
120
+ print(f"\nDONE: kept {len(kept)}/{target} in {attempts} attempts -> {out_dir}")
121
+ return 0 if kept else 1
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
src/case_zero/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Case Zero - a fully AI-driven detective game on a single local small model.
2
+
3
+ The local LLM generates the entire mystery and drives every suspect. Deterministic
4
+ code is only a fairness referee and a reliability layer; it never authors content.
5
+ """
6
+
7
+ __version__ = "0.1.0"
src/case_zero/api/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP API layer for Case Zero.
2
+
3
+ The whole game is served by a single ``gradio.Server`` (a FastAPI subclass): it mounts
4
+ the built Preact pixel-art frontend as static files at ``/`` and exposes the game's JSON
5
+ / SSE endpoints under ``/api``. Nothing is hosted outside this one Gradio process, so the
6
+ app stays a self-contained Off-the-Grid Hugging Face Space.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .server import build_server
12
+
13
+ __all__ = ["build_server"]
src/case_zero/api/case_adapter.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
2
+ golden case uses). Deterministic: it synthesizes the display surface from the case's
3
+ own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
4
+ is exposed; the true motive's *text* appears only as one option among the decoys.
5
+
6
+ Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
7
+ (threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..schemas.case import CaseFile
13
+ from ..schemas.clue import Clue
14
+ from .public_view import (
15
+ FlashbackAccount,
16
+ PublicCase,
17
+ PublicEvidence,
18
+ PublicFlashback,
19
+ PublicMotive,
20
+ PublicSuspect,
21
+ PublicVictim,
22
+ StoryBeat,
23
+ SuggestedQuestion,
24
+ TimelineBeat,
25
+ )
26
+ from .questions import FIXED_QUESTIONS
27
+
28
+ _SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
29
+ _WEATHER = (
30
+ "Rain, and a wind off the water.", "Fog thick enough to lean on.",
31
+ "A dry cold that gets into the joints.", "Sleet against every window.",
32
+ )
33
+ _GREETS = (
34
+ "Detective. Let's get this over with.",
35
+ "I already told the others everything I know.",
36
+ "Ask what you came to ask.",
37
+ "I have nothing to hide, if that's what you think.",
38
+ )
39
+ _APPARENT = (
40
+ "Was close enough to the victim to have a reason - and a chance.",
41
+ "Had more to lose tonight than they're letting on.",
42
+ "Their story has a seam in it, if you pull the right thread.",
43
+ "Stood to gain from a death no one saw coming.",
44
+ )
45
+ _DECOY_MOTIVES = (
46
+ "To bury a debt that was about to surface.",
47
+ "Jealousy that had festered for years.",
48
+ "To keep a buried secret buried.",
49
+ "To settle an old score, finally.",
50
+ "Fear of being exposed and ruined.",
51
+ )
52
+ _ICONS = ("photoEv", "receipt", "keycard", "compass", "phone", "cctv")
53
+ # Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
54
+ _CITIES = (
55
+ "Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
56
+ "Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
57
+ "Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
58
+ )
59
+
60
+
61
+ def _hash(s: str) -> int:
62
+ h = 0
63
+ for ch in s:
64
+ h = (h * 31 + ord(ch)) & 0x7FFFFFFF
65
+ return h
66
+
67
+
68
+ def _clock(minute: int) -> str:
69
+ h = (minute // 60) % 24
70
+ m = minute % 60
71
+ suffix = "AM" if h < 12 else "PM"
72
+ h12 = h % 12 or 12
73
+ return f"{h12}:{m:02d} {suffix}"
74
+
75
+
76
+ def _loc_name(case: CaseFile, loc_id: str) -> str:
77
+ for loc in case.setting.locations:
78
+ if loc.loc_id == loc_id:
79
+ return loc.name
80
+ return loc_id
81
+
82
+
83
+ def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
84
+ s = case.suspects[idx]
85
+ seed = _hash(s.sus_id)
86
+ role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
87
+ tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
88
+ quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
89
+ gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
90
+ return PublicSuspect(
91
+ id=s.sus_id,
92
+ name=s.name,
93
+ role=s.role,
94
+ age=30 + (seed % 35),
95
+ sprite=s.sus_id,
96
+ gender=gender,
97
+ tag=tag[:22],
98
+ baseline_suspicion=25 + (seed % 16),
99
+ motive=_APPARENT[idx % len(_APPARENT)],
100
+ alibi=s.stated_alibi.claim_text,
101
+ quote=quote,
102
+ greet=_GREETS[idx % len(_GREETS)],
103
+ suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
104
+ )
105
+
106
+
107
+ def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
108
+ at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
109
+ time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
110
+ return PublicEvidence(
111
+ id=clue.clue_id,
112
+ name=clue.name.upper(),
113
+ type=clue.discovery_method.value.upper(),
114
+ icon=_ICONS[idx % len(_ICONS)],
115
+ time=time,
116
+ found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
117
+ summary=clue.reveal_text,
118
+ detail=clue.reveal_text,
119
+ )
120
+
121
+
122
+ def _timeline(case: CaseFile) -> tuple[TimelineBeat, ...]:
123
+ beats: list[TimelineBeat] = []
124
+ w = case.setting.murder_window
125
+ beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
126
+ culprit = case.culprit.sus_id
127
+ for i, clue in enumerate(case.clues):
128
+ at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
129
+ t = at if at is not None else w.start_min + 7 * (i + 1)
130
+ conflict = clue.contradicts_alibi_of == culprit
131
+ beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
132
+ beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=f"{case.victim.name} is killed. Time of death.", locked=True))
133
+ beats.sort(key=lambda b: b.time)
134
+ return tuple(beats)
135
+
136
+
137
+ def _flashback(case: CaseFile) -> PublicFlashback:
138
+ culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
139
+ claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
140
+ crime = _loc_name(case, case.victim.found_at_loc_id)
141
+ breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
142
+ return PublicFlashback(
143
+ title="TWO ACCOUNTS",
144
+ a=FlashbackAccount(
145
+ who=f"{culprit.name} SAYS",
146
+ scene=claimed,
147
+ lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
148
+ flags=(),
149
+ ),
150
+ b=FlashbackAccount(
151
+ who="THE EVIDENCE SAYS",
152
+ scene=crime,
153
+ lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
154
+ flags=tuple(range(len(breakers))),
155
+ ),
156
+ )
157
+
158
+
159
+ def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
160
+ true_text = case.culprit.true_motive.summary
161
+ decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
162
+ chosen = [PublicMotive(id="M1", text=true_text)]
163
+ for i in range(3):
164
+ chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
165
+ # stable shuffle by seed so the true option isn't always first
166
+ rot = seed % len(chosen)
167
+ return tuple(chosen[rot:] + chosen[:rot])
168
+
169
+
170
+ def _story_beats(case: CaseFile) -> tuple[StoryBeat, ...]:
171
+ v = case.victim
172
+ return (
173
+ StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
174
+ StoryBeat(scene="atrium", kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
175
+ StoryBeat(scene="interro", kicker="THOSE WHO STAYED", title="Persons of interest",
176
+ text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
177
+ StoryBeat(scene="seawall", kicker="YOUR CASE NOW", title="Detective",
178
+ text="One of them is lying to your face. Find the crack in the account and follow it down."),
179
+ )
180
+
181
+
182
+ def casefile_to_public(case: CaseFile) -> PublicCase:
183
+ seed = case.seed
184
+ tod = case.victim.time_of_death.start_min
185
+ building = case.setting.name
186
+ room = _loc_name(case, case.victim.found_at_loc_id)
187
+ scene = f"{building} — {room}" # building + room; the painter keys off the room
188
+ city = _CITIES[seed % len(_CITIES)]
189
+ return PublicCase(
190
+ id=case.case_id,
191
+ city=city,
192
+ district=building,
193
+ title=case.title,
194
+ tagline="Detective, your presence is required.",
195
+ weather=_WEATHER[seed % len(_WEATHER)],
196
+ victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
197
+ scene=scene,
198
+ tod=_clock(tod),
199
+ found=f"Found in {scene} at {_clock(case.victim.found_at_min)}.",
200
+ cause=case.victim.cause_of_death,
201
+ facts=(
202
+ ("CITY", city),
203
+ ("VICTIM", f"{case.victim.name}"),
204
+ ("SCENE", scene),
205
+ ("TIME OF DEATH", _clock(tod)),
206
+ ("CAUSE", case.victim.cause_of_death),
207
+ ("VERDICT", "Homicide"),
208
+ ),
209
+ boot_lines=(
210
+ "The phone drags you up out of half a sleep.",
211
+ f"{case.setting.name}. A death no one saw coming.",
212
+ f"{case.victim.name} - {case.victim.role}.",
213
+ "They're holding the scene. It's yours now, detective.",
214
+ ),
215
+ story_beats=_story_beats(case),
216
+ suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
217
+ evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
218
+ timeline=_timeline(case),
219
+ flashback=_flashback(case),
220
+ motives=_motives(case, seed),
221
+ )
src/case_zero/api/dto.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wire DTOs for the public game API.
2
+
3
+ These are the exact shapes the Preact client exchanges with the server. The client
4
+ speaks camelCase; pydantic aliases map that to snake_case internally. The sealed
5
+ ``CaseFile``/``Solution`` never appears here - only the public ``PlayerCaseView``
6
+ projection is ever returned before the verdict.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from .public_view import PublicCase
14
+
15
+
16
+ class _CamelModel(BaseModel):
17
+ """Base: accept snake_case or camelCase on input, emit camelCase on output."""
18
+
19
+ model_config = ConfigDict(populate_by_name=True)
20
+
21
+
22
+ class NewCaseRequest(_CamelModel):
23
+ seed: int | None = None
24
+ case_id: str | None = Field(default=None, alias="caseId")
25
+ difficulty: str | None = None
26
+
27
+
28
+ class CaseResponse(_CamelModel):
29
+ case_id: str = Field(alias="caseId")
30
+ run_id: str = Field(alias="runId")
31
+ case: PublicCase
src/case_zero/api/hints.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spoiler-safe contextual hints (Det. Hale). Server-side and case-aware, but they never
2
+ name the killer, motive, or key-evidence chain - only the screen and the player's visible
3
+ progress shape the guidance.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ _BASE = {
9
+ "briefing": "Read the file before you read people. Note the time of death - everything "
10
+ "turns on who could be where, and when.",
11
+ "story": "Don't look for the killer yet. The story tells you who was in the building and "
12
+ "why; the lies come when you start asking questions.",
13
+ "board": "Pick a suspect on the right and interrogate. Drag the exhibits as you think, "
14
+ "and examine anything that doesn't sit right.",
15
+ "evidence": "Examine each exhibit, then pin the ones that contradict a statement.",
16
+ "timeline": "Find the two events that fight each other. Where a statement collides with a "
17
+ "fact, an alibi falls apart.",
18
+ "flashback": "Where an account and the evidence diverge in oxblood is where the lie lives.",
19
+ "notes": "Line up motive against opportunity. Who could be where the evidence says, when "
20
+ "it says - and who had the most to lose?",
21
+ "accuse": "Don't guess. Means, then motive, then attach the evidence chain that proves it.",
22
+ }
23
+
24
+
25
+ def hint_for(screen: str, suspicion: dict[str, int]) -> str:
26
+ if screen == "interro":
27
+ hot = any(v >= 65 for v in suspicion.values())
28
+ if hot:
29
+ return (
30
+ "Their composure is cracking - press the contradiction now and don't let them "
31
+ "retreat to their alibi."
32
+ )
33
+ return (
34
+ "Get their story on record with the soft questions, then present the exhibit that "
35
+ "contradicts it and watch their composure move."
36
+ )
37
+ return _BASE.get(screen, "Take the case, detective. Start with the file, then the people.")
src/case_zero/api/public_view.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The PUBLIC wire contract: the exact case shape the Preact client receives.
2
+
3
+ This mirrors the prototype's ``prototype/js/case.jsx`` (the data-model-by-example) in
4
+ camelCase. It is the ONLY case representation that ever reaches the browser before the
5
+ verdict. The sealed solution - killer, true motive, key-evidence chain - and every
6
+ per-question/per-evidence suspicion *delta* and scripted *answer* are deliberately absent:
7
+ those are produced live, server-side (the deltas alone would reveal the killer).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+ from pydantic.alias_generators import to_camel
14
+
15
+
16
+ class _Wire(BaseModel):
17
+ """camelCase on the wire; snake_case in Python. Frozen - a view is never mutated."""
18
+
19
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, frozen=True)
20
+
21
+
22
+ class PublicVictim(_Wire):
23
+ name: str
24
+ role: str
25
+ age: int
26
+ sprite: str
27
+ bio: str
28
+
29
+
30
+ class SuggestedQuestion(_Wire):
31
+ id: str
32
+ q: str
33
+
34
+
35
+ class PublicSuspect(_Wire):
36
+ id: str
37
+ name: str
38
+ role: str
39
+ age: int
40
+ sprite: str
41
+ gender: str = "male"
42
+ tag: str
43
+ baseline_suspicion: int
44
+ motive: str # apparent motive shown in the dossier - NOT proof
45
+ alibi: str
46
+ quote: str
47
+ greet: str
48
+ suggested_questions: tuple[SuggestedQuestion, ...]
49
+
50
+
51
+ class ThreadMessage(_Wire):
52
+ from_: str = Field(alias="from")
53
+ who: str
54
+ t: str
55
+ m: str
56
+
57
+
58
+ class PublicEvidence(_Wire):
59
+ id: str
60
+ name: str
61
+ type: str # PHONE | PAPER | IMAGE | AUDIO | DATA
62
+ icon: str
63
+ time: str
64
+ found: str
65
+ summary: str
66
+ # Exactly one of these display payloads is populated, per type.
67
+ thread: tuple[ThreadMessage, ...] | None = None
68
+ detail: str | None = None
69
+ transcript: str | None = None
70
+ dur: str | None = None
71
+ rows: tuple[tuple[str, ...], ...] | None = None
72
+
73
+
74
+ class TimelineBeat(_Wire):
75
+ time: str
76
+ label: str
77
+ locked: bool = False
78
+ ev: str | None = None
79
+ conflict: bool = False
80
+
81
+
82
+ class FlashbackAccount(_Wire):
83
+ who: str
84
+ scene: str
85
+ lines: tuple[str, ...]
86
+ flags: tuple[int, ...] = ()
87
+
88
+
89
+ class PublicFlashback(_Wire):
90
+ title: str
91
+ a: FlashbackAccount
92
+ b: FlashbackAccount
93
+
94
+
95
+ class PublicMotive(_Wire):
96
+ id: str
97
+ text: str
98
+
99
+
100
+ class StoryBeat(_Wire):
101
+ scene: str
102
+ kicker: str
103
+ title: str
104
+ text: str
105
+
106
+
107
+ class PublicCase(_Wire):
108
+ id: str
109
+ city: str
110
+ district: str
111
+ title: str
112
+ tagline: str
113
+ weather: str
114
+ victim: PublicVictim
115
+ scene: str
116
+ tod: str
117
+ found: str
118
+ cause: str
119
+ facts: tuple[tuple[str, str], ...]
120
+ boot_lines: tuple[str, ...]
121
+ story_beats: tuple[StoryBeat, ...]
122
+ suspects: tuple[PublicSuspect, ...]
123
+ evidence: tuple[PublicEvidence, ...]
124
+ timeline: tuple[TimelineBeat, ...]
125
+ flashback: PublicFlashback
126
+ motives: tuple[PublicMotive, ...]
127
+
128
+
129
+ def golden_to_public(data: dict) -> PublicCase:
130
+ """Project a sealed golden/stored case dict into the PUBLIC view.
131
+
132
+ Strips the ``sealed`` block and, per suspect, the scripted ``answer``/``delta`` and
133
+ the ``present``/``default`` reply tables - leaving only question text the player sees.
134
+ """
135
+ suspects = tuple(
136
+ PublicSuspect(
137
+ id=s["id"],
138
+ name=s["name"],
139
+ role=s["role"],
140
+ age=s["age"],
141
+ sprite=s["sprite"],
142
+ gender=s.get("gender", "male"),
143
+ tag=s["tag"],
144
+ baseline_suspicion=s["suspicion"],
145
+ motive=s["motive"],
146
+ alibi=s["alibi"],
147
+ quote=s["quote"],
148
+ greet=s["greet"],
149
+ suggested_questions=tuple(
150
+ SuggestedQuestion(id=q["id"], q=q["q"]) for q in s["questions"]
151
+ ),
152
+ )
153
+ for s in data["suspects"]
154
+ )
155
+ return PublicCase(
156
+ id=data["id"],
157
+ city=data["city"],
158
+ district=data["district"],
159
+ title=data["title"],
160
+ tagline=data["tagline"],
161
+ weather=data["weather"],
162
+ victim=PublicVictim(**data["victim"]),
163
+ scene=data["scene"],
164
+ tod=data["tod"],
165
+ found=data["found"],
166
+ cause=data["cause"],
167
+ facts=tuple(tuple(pair) for pair in data["facts"]),
168
+ boot_lines=tuple(data["bootLines"]),
169
+ story_beats=tuple(StoryBeat(**b) for b in data.get("storyBeats", [])),
170
+ suspects=suspects,
171
+ evidence=tuple(PublicEvidence(**e) for e in data["evidence"]),
172
+ timeline=tuple(TimelineBeat(**b) for b in data["timeline"]),
173
+ flashback=PublicFlashback(
174
+ title=data["flashback"]["title"],
175
+ a=FlashbackAccount(**data["flashback"]["a"]),
176
+ b=FlashbackAccount(**data["flashback"]["b"]),
177
+ ),
178
+ motives=tuple(PublicMotive(**m) for m in data["motives"]),
179
+ )
src/case_zero/api/questions.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generic suggested questions for generated cases. Their *answers* are produced live by
2
+ the interrogation engine, so the same set works for any case; only the text is public.
3
+ The golden case ships its own authored questions instead.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ FIXED_QUESTIONS: tuple[tuple[str, str], ...] = (
9
+ ("q0", "Where were you when it happened?"),
10
+ ("q1", "What was your relationship with the victim?"),
11
+ ("q2", "Did you see anyone near the scene?"),
12
+ ("q3", "Is there anything you haven't told me?"),
13
+ ("q4", "Walk me through your evening."),
14
+ )
15
+
16
+ _BY_ID = dict(FIXED_QUESTIONS)
17
+
18
+
19
+ def question_text(qid: str) -> str | None:
20
+ return _BY_ID.get(qid)
src/case_zero/api/routes_case.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Case lifecycle: create a new case (generate, or load by ID) and fetch a public view.
2
+
3
+ New Case generates a fresh, solvable mystery via the in-process LLM (buffered one-ahead);
4
+ if generation is unavailable (no weights / load failure) it falls back to the GRAYMOOR-3107
5
+ golden case so the loop is always playable. Generated cases are persisted by Case ID, so a
6
+ shared ID reloads the identical mystery. The sealed solution is never in any response here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+
13
+ from ..persistence.golden import golden_exists, load_golden
14
+ from ..persistence.run_store import create_run
15
+ from .dto import CaseResponse, NewCaseRequest
16
+ from .public_view import golden_to_public
17
+ from .runtime import RUNTIME
18
+
19
+ router = APIRouter(prefix="/api", tags=["case"])
20
+
21
+ DEFAULT_CASE_ID = "GRAYMOOR-3107"
22
+
23
+
24
+ def _golden_response(case_id: str) -> CaseResponse:
25
+ run = create_run(case_id)
26
+ return CaseResponse(case_id=case_id, run_id=run.run_id, case=golden_to_public(load_golden(case_id)))
27
+
28
+
29
+ @router.post("/case", response_model=CaseResponse)
30
+ def new_case(req: NewCaseRequest) -> CaseResponse:
31
+ if req.case_id:
32
+ if golden_exists(req.case_id):
33
+ return _golden_response(req.case_id)
34
+ loaded = RUNTIME.load_generated_run(req.case_id)
35
+ if loaded is not None:
36
+ public, run_id = loaded
37
+ return CaseResponse(case_id=public.id, run_id=run_id, case=public)
38
+ return _golden_response(DEFAULT_CASE_ID)
39
+
40
+ generated = RUNTIME.new_generated_run()
41
+ if generated is not None:
42
+ public, run_id = generated
43
+ return CaseResponse(case_id=public.id, run_id=run_id, case=public)
44
+ return _golden_response(DEFAULT_CASE_ID)
45
+
46
+
47
+ @router.get("/case/{case_id}", response_model=CaseResponse)
48
+ def get_case(case_id: str) -> CaseResponse:
49
+ if golden_exists(case_id):
50
+ return _golden_response(case_id)
51
+ loaded = RUNTIME.load_generated_run(case_id)
52
+ if loaded is not None:
53
+ public, run_id = loaded
54
+ return CaseResponse(case_id=public.id, run_id=run_id, case=public)
55
+ raise HTTPException(status_code=404, detail=f"case not found: {case_id}")
src/case_zero/api/routes_run.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-run routes: interrogate (scripted golden engine), hint, and accuse (verdict).
2
+
3
+ The sealed solution is read for the FIRST time at /accuse - never before. Suspicion is
4
+ computed and held server-side; the client only displays it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from fastapi.responses import FileResponse
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from ..persistence.golden import load_golden
14
+ from ..persistence.run_store import get_run
15
+ from ..schemas.suspect import VoiceAssignment
16
+ from ..tts.assignment import assign_voice
17
+ from .hints import hint_for
18
+ from .questions import question_text
19
+ from .runtime import RUNTIME
20
+ from .scripted import CORNERED_DELTA, RATTLED_DELTA, scripted_turn
21
+ from .tts_service import TTS, voice_seed
22
+
23
+ router = APIRouter(prefix="/api/run", tags=["run"])
24
+
25
+
26
+ class _Camel(BaseModel):
27
+ model_config = ConfigDict(populate_by_name=True)
28
+
29
+
30
+ class InterrogateBody(_Camel):
31
+ question_id: str | None = Field(default=None, alias="questionId")
32
+ free_text: str | None = Field(default=None, alias="freeText")
33
+ present_evidence_id: str | None = Field(default=None, alias="presentEvidenceId")
34
+
35
+
36
+ class Flags(_Camel):
37
+ rattled: bool = False
38
+ contradiction_exposed: bool = Field(default=False, alias="contradictionExposed")
39
+ cornered: bool = False
40
+
41
+
42
+ class InterrogateResult(_Camel):
43
+ reply: str
44
+ suspicion_delta: int = Field(alias="suspicionDelta")
45
+ suspicion: int
46
+ flags: Flags
47
+
48
+
49
+ @router.post("/{run_id}/interrogate/{sus_id}", response_model=InterrogateResult)
50
+ def interrogate(run_id: str, sus_id: str, body: InterrogateBody) -> InterrogateResult:
51
+ # Live (generated) run -> the in-process LLM engine.
52
+ live = RUNTIME.get(run_id)
53
+ if live is not None:
54
+ if not any(s.sus_id == sus_id for s in live.case.suspects):
55
+ raise HTTPException(status_code=404, detail="suspect not found")
56
+ if body.present_evidence_id is not None:
57
+ question = body.free_text or "Explain this to me."
58
+ clue_id = body.present_evidence_id
59
+ elif body.question_id is not None:
60
+ question = question_text(body.question_id) or "Tell me what happened."
61
+ clue_id = None
62
+ else:
63
+ question = body.free_text or "Tell me what happened."
64
+ clue_id = None
65
+ out = RUNTIME.interrogate_live(live, sus_id, question, clue_id)
66
+ return InterrogateResult(
67
+ reply=out["reply"],
68
+ suspicion_delta=out["suspicionDelta"],
69
+ suspicion=out["suspicion"],
70
+ flags=Flags(**out["flags"]),
71
+ )
72
+
73
+ # Scripted (golden) run.
74
+ run = get_run(run_id)
75
+ if run is None:
76
+ raise HTTPException(status_code=404, detail="run not found")
77
+ golden = load_golden(run.case_id)
78
+ try:
79
+ reply, delta = scripted_turn(
80
+ golden,
81
+ sus_id,
82
+ question_id=body.question_id,
83
+ present_evidence_id=body.present_evidence_id,
84
+ free_text=body.free_text,
85
+ )
86
+ except KeyError as exc:
87
+ raise HTTPException(status_code=404, detail="suspect not found") from exc
88
+
89
+ cur = run.suspicion.get(sus_id, 0)
90
+ new_value = max(0, min(100, cur + delta))
91
+ run.suspicion[sus_id] = new_value
92
+ flags = Flags(
93
+ rattled=delta >= RATTLED_DELTA,
94
+ contradiction_exposed=delta >= RATTLED_DELTA,
95
+ cornered=delta >= CORNERED_DELTA,
96
+ )
97
+ return InterrogateResult(reply=reply, suspicion_delta=delta, suspicion=new_value, flags=flags)
98
+
99
+
100
+ @router.get("/{run_id}/hint")
101
+ def hint(run_id: str, screen: str = "") -> dict[str, str]:
102
+ live = RUNTIME.get(run_id)
103
+ if live is not None:
104
+ suspicion = {s.id: RUNTIME._suspicion(live, s.id) for s in live.public.suspects}
105
+ return {"hint": hint_for(screen, suspicion)}
106
+ run = get_run(run_id)
107
+ if run is None:
108
+ raise HTTPException(status_code=404, detail="run not found")
109
+ return {"hint": hint_for(screen, run.suspicion)}
110
+
111
+
112
+ class AccuseBody(_Camel):
113
+ suspect_id: str = Field(alias="suspectId")
114
+ motive_id: str = Field(alias="motiveId")
115
+ evidence_ids: list[str] = Field(default_factory=list, alias="evidenceIds")
116
+
117
+
118
+ def _suspect_name(golden: dict, sus_id: str) -> str:
119
+ for s in golden["suspects"]:
120
+ if s["id"] == sus_id:
121
+ return s["name"]
122
+ return "the accused"
123
+
124
+
125
+ @router.post("/{run_id}/accuse")
126
+ def accuse(run_id: str, body: AccuseBody) -> dict:
127
+ live = RUNTIME.get(run_id)
128
+ if live is not None:
129
+ return RUNTIME.accuse_live(live, body.suspect_id, body.motive_id, body.evidence_ids)
130
+
131
+ run = get_run(run_id)
132
+ if run is None:
133
+ raise HTTPException(status_code=404, detail="run not found")
134
+ golden = load_golden(run.case_id)
135
+ sealed = golden["sealed"]
136
+
137
+ killer_id = sealed["killer"]
138
+ killer_name = _suspect_name(golden, killer_id)
139
+ killer_correct = body.suspect_id == killer_id
140
+ motive_correct = body.motive_id == sealed["correctMotive"]
141
+ key = set(sealed.get("keyEvidence", []))
142
+ hits = len(key.intersection(body.evidence_ids))
143
+
144
+ # Graded: killer is the bulk; motive + a complete evidence chain round it out.
145
+ points = (60 if killer_correct else 0)
146
+ points += 20 if motive_correct else 0
147
+ points += round((hits / len(key)) * 20) if key else 0
148
+
149
+ if killer_correct:
150
+ truth = sealed.get("truth") or f"It was {killer_name}. The evidence held."
151
+ else:
152
+ accused = _suspect_name(golden, body.suspect_id)
153
+ truth = (
154
+ f"You charged {accused}. The case held for a night - but the keycard, the camera, "
155
+ f"and the voicemail all led past {accused} to {killer_name}, who walked out into the rain."
156
+ )
157
+
158
+ return {
159
+ "correct": killer_correct,
160
+ "verdict": {
161
+ "stamp": "CASE CLOSED" if killer_correct else "MISTRIAL",
162
+ "killerId": killer_id,
163
+ "killerName": killer_name,
164
+ "truth": truth,
165
+ },
166
+ "score": {
167
+ "points": points,
168
+ "max": 100,
169
+ "killerCorrect": killer_correct,
170
+ "motiveCorrect": motive_correct,
171
+ "evidenceHits": hits,
172
+ },
173
+ "stats": [],
174
+ }
175
+
176
+
177
+ class TtsBody(_Camel):
178
+ text: str = ""
179
+
180
+
181
+ def _voice_for(run_id: str, sus_id: str) -> VoiceAssignment:
182
+ live = RUNTIME.get(run_id)
183
+ if live is not None:
184
+ for s in live.case.suspects:
185
+ if s.sus_id == sus_id:
186
+ return assign_voice(s)
187
+ run = get_run(run_id)
188
+ if run is not None:
189
+ golden = load_golden(run.case_id)
190
+ s = next((x for x in golden["suspects"] if x["id"] == sus_id), None)
191
+ female = bool(s and (s.get("gender", "") or "").lower().startswith("f"))
192
+ return voice_seed(sus_id, female=female)
193
+ return voice_seed(sus_id)
194
+
195
+
196
+ @router.post("/{run_id}/tts/{sus_id}")
197
+ def tts(run_id: str, sus_id: str, body: TtsBody) -> FileResponse:
198
+ """Synthesize the suspect's spoken reply with their on-device Supertonic voice."""
199
+ path = TTS.synth(body.text, _voice_for(run_id, sus_id))
200
+ if path is None:
201
+ raise HTTPException(status_code=503, detail="voice unavailable")
202
+ return FileResponse(path, media_type="audio/wav")
src/case_zero/api/runtime.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live game runtime: lazily builds the in-process llama.cpp backend, generates cases,
2
+ and holds live ``Session`` objects per run.
3
+
4
+ Single-flight is MANDATORY: ``llama_cpp.Llama`` is not thread-safe, so every model call
5
+ (generation + interrogation) runs under one lock - never concurrently, on any machine.
6
+ To keep that lock from ever blocking an interrogation, case generation only happens when
7
+ no one is playing yet: one case is prebuilt at startup, and each later New Case generates
8
+ synchronously while the player waits on the loading screen (no background generation runs
9
+ during play). On a 2-vCPU Space this also means the LLM is never oversubscribed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from dataclasses import dataclass
18
+
19
+ from ..config import effective_cpus, get_settings
20
+ from ..engine.session import Session
21
+ from ..generator.pipeline import generate_case
22
+ from ..llm.backend import LLMBackend, LLMError, make_backend
23
+ from ..persistence.case_store import load_case, save_runtime_case
24
+ from ..persistence.paths import prebaked_cases_dir, runtime_cases_dir
25
+ from ..schemas.accusation import Accusation
26
+ from ..schemas.case import CaseFile
27
+ from ..schemas.enums import Relevance
28
+ from .case_adapter import casefile_to_public
29
+ from .public_view import PublicCase
30
+
31
+
32
+ @dataclass
33
+ class LiveRun:
34
+ run_id: str
35
+ case: CaseFile
36
+ session: Session
37
+ public: PublicCase
38
+ baselines: dict[str, int]
39
+
40
+
41
+ class GameRuntime:
42
+ def __init__(self) -> None:
43
+ self._lock = threading.Lock() # MANDATORY single-flight over all model calls
44
+ self._backend: LLMBackend | None = None
45
+ self._backend_failed = False
46
+ self._runs: dict[str, LiveRun] = {}
47
+ self._buffer: CaseFile | None = None
48
+ self._buffer_lock = threading.Lock()
49
+ self._seed = int(time.time()) % 900_000 + 1000
50
+ # Pre-baked pool: full, model-authored cases shipped with the Space, served instantly
51
+ # on New Case so nobody waits ~2 min for live generation. Interrogation is still live.
52
+ self._prebaked: list[CaseFile] = []
53
+ self._prebaked_idx = 0
54
+ self._prebaked_loaded = False
55
+ # Only generate cases ahead-of-time in the background on a box with cores to spare;
56
+ # on the 2-vCPU Space that would steal the single model lock from an interrogation,
57
+ # so there we rely on the pre-baked pool and generate only on demand.
58
+ self._gen_ahead = effective_cpus() > 4
59
+
60
+ # ---- backend ----
61
+ def _get_backend(self) -> LLMBackend | None:
62
+ if self._backend is None and not self._backend_failed:
63
+ try:
64
+ self._backend = make_backend(get_settings())
65
+ except LLMError:
66
+ self._backend_failed = True
67
+ return self._backend
68
+
69
+ def available(self) -> bool:
70
+ return self._get_backend() is not None
71
+
72
+ def _next_seed(self) -> int:
73
+ self._seed += 1
74
+ return self._seed
75
+
76
+ # ---- generation ----
77
+ def _generate(self, seed: int) -> CaseFile:
78
+ backend = self._get_backend()
79
+ if backend is None:
80
+ raise LLMError("no backend")
81
+ with self._lock:
82
+ result = generate_case(backend, seed=seed)
83
+ save_runtime_case(result.case)
84
+ return result.case
85
+
86
+ def _prebuild(self) -> None:
87
+ try:
88
+ case = self._generate(self._next_seed())
89
+ with self._buffer_lock:
90
+ self._buffer = case
91
+ except Exception:
92
+ pass
93
+
94
+ def _load_prebaked(self) -> None:
95
+ if self._prebaked_loaded:
96
+ return
97
+ self._prebaked_loaded = True
98
+ pool_dir = prebaked_cases_dir()
99
+ if not pool_dir.is_dir():
100
+ return
101
+ for path in sorted(pool_dir.glob("*.json")):
102
+ try:
103
+ self._prebaked.append(load_case(path))
104
+ except Exception:
105
+ continue
106
+ # Start the rotation at a varied offset so a Space restart doesn't always serve the
107
+ # first case again (the seed is time-based), keeping New Case fresh across restarts.
108
+ if self._prebaked:
109
+ self._prebaked_idx = self._seed % len(self._prebaked)
110
+
111
+ def start_buffer(self) -> None:
112
+ """Make the first New Case instant: load the shipped pool now and (only on a box with
113
+ cores to spare) prebuild one fresh live case in the background. On the 2-vCPU Space we
114
+ skip the background prebuild so the model lock stays free for the first interrogation -
115
+ the pre-baked pool already gives an instant case."""
116
+ self._load_prebaked()
117
+ if self._gen_ahead and self.available():
118
+ threading.Thread(target=self._prebuild, daemon=True).start()
119
+
120
+ def _take_buffered(self) -> CaseFile | None:
121
+ with self._buffer_lock:
122
+ case = self._buffer
123
+ self._buffer = None
124
+ return case
125
+
126
+ def _take_prebaked(self) -> CaseFile | None:
127
+ self._load_prebaked()
128
+ if not self._prebaked:
129
+ return None
130
+ case = self._prebaked[self._prebaked_idx % len(self._prebaked)]
131
+ self._prebaked_idx += 1
132
+ return case
133
+
134
+ def _maybe_refill(self) -> None:
135
+ """Generate one fresh case in the background - capable hardware only (see _gen_ahead)."""
136
+ if self._gen_ahead and self._buffer is None and self.available():
137
+ threading.Thread(target=self._prebuild, daemon=True).start()
138
+
139
+ def new_generated_run(self) -> tuple[PublicCase, str] | None:
140
+ if not self.available():
141
+ return None
142
+ # Prefer a freshly generated case if one is ready; else serve the pre-baked pool
143
+ # instantly; only with neither do we generate synchronously (first run, no pool).
144
+ case = self._take_buffered() or self._take_prebaked()
145
+ if case is None:
146
+ try:
147
+ case = self._generate(self._next_seed())
148
+ except Exception:
149
+ return None
150
+ self._maybe_refill()
151
+ return self._register(case)
152
+
153
+ def load_generated_run(self, case_id: str) -> tuple[PublicCase, str] | None:
154
+ if not self.available():
155
+ return None
156
+ self._load_prebaked()
157
+ case = next((c for c in self._prebaked if c.case_id == case_id), None)
158
+ if case is None:
159
+ for directory in (prebaked_cases_dir(), runtime_cases_dir()):
160
+ path = directory / f"{case_id}.json"
161
+ if path.exists():
162
+ try:
163
+ case = load_case(path)
164
+ except Exception:
165
+ case = None
166
+ break
167
+ if case is None:
168
+ return None
169
+ return self._register(case)
170
+
171
+ def _register(self, case: CaseFile) -> tuple[PublicCase, str]:
172
+ public = casefile_to_public(case)
173
+ session = Session(case, self._get_backend()) # type: ignore[arg-type]
174
+ run_id = uuid.uuid4().hex
175
+ baselines = {s.id: s.baseline_suspicion for s in public.suspects}
176
+ self._runs[run_id] = LiveRun(run_id, case, session, public, baselines)
177
+ return public, run_id
178
+
179
+ def get(self, run_id: str) -> LiveRun | None:
180
+ return self._runs.get(run_id)
181
+
182
+ # ---- live turn / verdict ----
183
+ def _suspicion(self, run: LiveRun, sus_id: str) -> int:
184
+ st = run.session.state.state_for(sus_id)
185
+ base = run.baselines.get(sus_id, 25)
186
+ val = base + round(st.stress * 55) + (20 if st.broken_lie_ids else 0)
187
+ return max(0, min(100, val))
188
+
189
+ def interrogate_live(
190
+ self, run: LiveRun, sus_id: str, question: str, clue_id: str | None
191
+ ) -> dict:
192
+ prev = self._suspicion(run, sus_id)
193
+ with self._lock:
194
+ final = None
195
+ for ev in run.session.interrogate(sus_id, question, presented_clue_id=clue_id):
196
+ if ev.final is not None:
197
+ final = ev.final
198
+ reply = final.turn.spoken if final else "…I have nothing to say to that."
199
+ after = self._suspicion(run, sus_id)
200
+ adj = final.adjudication if final else None
201
+ rattled = bool(adj and adj.relevance in (Relevance.DIRECT, Relevance.BREAKING))
202
+ cornered = bool(adj and adj.is_contradiction)
203
+ return {
204
+ "reply": reply,
205
+ "suspicionDelta": after - prev,
206
+ "suspicion": after,
207
+ "flags": {"rattled": rattled, "contradictionExposed": cornered, "cornered": cornered},
208
+ }
209
+
210
+ def accuse_live(self, run: LiveRun, suspect_id: str, motive_id: str, evidence_ids: list[str]) -> dict:
211
+ verdict = run.session.accuse(
212
+ Accusation(accused_sus_id=suspect_id, motive_id=motive_id, cited_clue_ids=tuple(evidence_ids))
213
+ )
214
+ culprit_id = run.case.culprit.sus_id
215
+ killer = run.case.suspect(culprit_id)
216
+ if verdict.culprit_correct:
217
+ truth = verdict.rationale or run.case.culprit.method_narrative
218
+ else:
219
+ accused = run.case.suspect(suspect_id).name if any(s.sus_id == suspect_id for s in run.case.suspects) else "the accused"
220
+ truth = (
221
+ f"You charged {accused}. The case held for a night - but the evidence led past "
222
+ f"them to {killer.name}, who walked out into the rain."
223
+ )
224
+ return {
225
+ "correct": verdict.culprit_correct,
226
+ "verdict": {
227
+ "stamp": "CASE CLOSED" if verdict.culprit_correct else "MISTRIAL",
228
+ "killerId": culprit_id,
229
+ "killerName": killer.name,
230
+ "truth": truth,
231
+ },
232
+ "score": {
233
+ "points": verdict.score,
234
+ "max": 100,
235
+ "killerCorrect": verdict.culprit_correct,
236
+ "motiveCorrect": verdict.motive_correct,
237
+ "evidenceHits": len(evidence_ids),
238
+ },
239
+ "stats": [],
240
+ }
241
+
242
+
243
+ RUNTIME = GameRuntime()
src/case_zero/api/scripted.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic scripted interrogation for golden / seed cases (no LLM).
2
+
3
+ Reads the golden's per-suspect answer/delta tables directly, so the golden case plays
4
+ instantly and identically every time - ideal for the demo, tests, and offline CI. The
5
+ live in-process llama.cpp engine replaces this for generated cases (same wire result).
6
+ The deltas never reach the client; only the suspect's spoken reply + the resulting
7
+ server-side suspicion do.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ RATTLED_DELTA = 14
13
+ CORNERED_DELTA = 24
14
+
15
+
16
+ def _suspect(golden: dict, sus_id: str) -> dict:
17
+ for s in golden["suspects"]:
18
+ if s["id"] == sus_id:
19
+ return s
20
+ raise KeyError(sus_id)
21
+
22
+
23
+ def scripted_turn(
24
+ golden: dict,
25
+ sus_id: str,
26
+ *,
27
+ question_id: str | None = None,
28
+ present_evidence_id: str | None = None,
29
+ free_text: str | None = None,
30
+ ) -> tuple[str, int]:
31
+ """Return (spoken_reply, suspicion_delta) for one scripted turn."""
32
+ suspect = _suspect(golden, sus_id)
33
+ default = suspect.get("default", "I've told you what I know, detective.")
34
+
35
+ if present_evidence_id is not None:
36
+ entry = (suspect.get("present") or {}).get(present_evidence_id)
37
+ if entry:
38
+ return entry["a"], int(entry["d"])
39
+ return default, 1
40
+
41
+ if question_id is not None:
42
+ for q in suspect.get("questions", []):
43
+ if q["id"] == question_id:
44
+ return q["a"], int(q["d"])
45
+ return default, 2
46
+
47
+ # free text: a small model would answer live; the scripted engine deflects.
48
+ return default, 2
src/case_zero/api/server.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the single ``gradio.Server`` that powers Case Zero.
2
+
3
+ ``gradio.Server`` is a FastAPI subclass, so all standard FastAPI methods work on it
4
+ (`.include_router`, `.get`, `.mount`, ...) plus a `.api()` decorator that adds Gradio's
5
+ queue + SSE streaming + concurrency control. We:
6
+
7
+ * register the game's JSON/SSE routers under ``/api``;
8
+ * serve the built Preact pixel-art SPA (``web/dist``).
9
+
10
+ The whole app runs 100% inside one Gradio process - no separate frontend host.
11
+
12
+ Static serving uses explicit routes (assets under ``/assets``, index at ``/``) rather than
13
+ a catch-all mount at ``/`` on purpose: a ``Mount("/")`` shadows Gradio's own internal
14
+ ``/gradio_api/*`` routes (including its launch health check) and breaks startup.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from fastapi import HTTPException
22
+ from fastapi.responses import FileResponse
23
+ from fastapi.staticfiles import StaticFiles
24
+ from gradio import Server
25
+
26
+ from ..constants import ASSETS_DIR
27
+ from .routes_case import router as case_router
28
+ from .routes_run import router as run_router
29
+
30
+ # src/case_zero/api/server.py -> repo root is three parents up from this file's dir.
31
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
32
+ WEB_DIST = (_REPO_ROOT / "web" / "dist").resolve()
33
+ _INDEX = WEB_DIST / "index.html"
34
+ _AUDIO_DIR = ASSETS_DIR / "ui"
35
+
36
+
37
+ def build_server() -> Server:
38
+ """Return a configured (not yet launched) ``gradio.Server``."""
39
+ server = Server(title="Case Zero", docs_url=None, redoc_url=None, openapi_url=None)
40
+
41
+ server.include_router(case_router)
42
+ server.include_router(run_router)
43
+
44
+ # Gradio API endpoints (Server mode): the core game actions registered through Gradio's
45
+ # own API engine - queue + concurrency + gradio_client-callable + visible in the API view.
46
+ # This makes the app unambiguously a Gradio application (the custom SPA is the Off-Brand
47
+ # frontend on top). The SPA itself uses the REST routes above; these mirror them.
48
+ from .dto import NewCaseRequest
49
+ from .routes_case import new_case
50
+ from .routes_run import InterrogateBody, interrogate
51
+
52
+ @server.api(name="new_case")
53
+ def gr_new_case(case_id: str = "") -> dict:
54
+ """Generate a fresh case (or load one by id); returns the public case JSON."""
55
+ return new_case(NewCaseRequest(case_id=case_id or None)).model_dump(by_alias=True)
56
+
57
+ @server.api(name="interrogate")
58
+ def gr_interrogate(run_id: str, suspect_id: str, question: str) -> dict:
59
+ """Ask a suspect a free-text question; returns their reply + server suspicion."""
60
+ return interrogate(run_id, suspect_id, InterrogateBody(free_text=question)).model_dump(
61
+ by_alias=True
62
+ )
63
+
64
+ @server.get("/healthz")
65
+ def healthz() -> dict[str, bool]:
66
+ return {"ok": True}
67
+
68
+ # UI audio (sfx + ambient music) - a specific prefix, served locally (Off-the-Grid).
69
+ if _AUDIO_DIR.is_dir():
70
+ server.mount("/audio", StaticFiles(directory=str(_AUDIO_DIR)), name="audio")
71
+
72
+ if WEB_DIST.is_dir():
73
+ # Hashed JS/CSS live under /assets - a specific prefix that never collides with /api.
74
+ server.mount(
75
+ "/assets",
76
+ StaticFiles(directory=str(WEB_DIST / "assets")),
77
+ name="assets",
78
+ )
79
+
80
+ @server.get("/")
81
+ def _index() -> FileResponse:
82
+ return FileResponse(_INDEX)
83
+
84
+ @server.get("/{filename}")
85
+ def _root_or_spa(filename: str) -> FileResponse:
86
+ # Serve real root files (favicon.svg, icons.svg); otherwise fall back to the SPA
87
+ # index so single-segment client routes resolve. Single-segment matching means
88
+ # this never shadows /gradio_api/* or /api/* (those have deeper paths).
89
+ candidate = (WEB_DIST / filename).resolve()
90
+ if candidate.is_file() and candidate.is_relative_to(WEB_DIST):
91
+ return FileResponse(candidate)
92
+ if _INDEX.is_file():
93
+ return FileResponse(_INDEX)
94
+ raise HTTPException(status_code=404)
95
+
96
+ return server
src/case_zero/api/tts_service.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """On-device Supertonic voice synthesis for suspect replies.
2
+
3
+ Lazily loads the provider (or degrades to silent), synthesizes a WAV per reply, and
4
+ caches it on disk keyed by (voice, text) so re-asks are instant. Single-flight: synthesis
5
+ runs under a lock so it never oversubscribes the CPU it shares with the LLM.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import threading
12
+ from pathlib import Path
13
+
14
+ from ..config import get_settings
15
+ from ..constants import PROJECT_ROOT
16
+ from ..schemas.suspect import VoiceAssignment
17
+ from ..tts.provider import make_tts_provider
18
+
19
+ _CACHE_DIR = PROJECT_ROOT / ".cache" / "tts"
20
+
21
+
22
+ class TtsService:
23
+ def __init__(self) -> None:
24
+ self._provider = None
25
+ self._failed = False
26
+ self._lock = threading.Lock()
27
+
28
+ def _get(self):
29
+ if self._provider is None and not self._failed:
30
+ try:
31
+ self._provider = make_tts_provider(get_settings())
32
+ except Exception:
33
+ self._failed = True
34
+ return self._provider
35
+
36
+ def available(self) -> bool:
37
+ p = self._get()
38
+ return bool(p and getattr(p, "available", False))
39
+
40
+ def synth(self, text: str, voice: VoiceAssignment | None) -> Path | None:
41
+ p = self._get()
42
+ if not p or not getattr(p, "available", False) or not text.strip():
43
+ return None
44
+ sid = voice.speaker_id if voice else 0
45
+ scale = voice.length_scale if voice else 1.0
46
+ key = hashlib.sha256(f"{sid}|{scale}|{text}".encode()).hexdigest()[:16]
47
+ out = _CACHE_DIR / f"{key}.wav"
48
+ if out.exists():
49
+ return out
50
+ with self._lock:
51
+ if out.exists():
52
+ return out
53
+ return p.synth_to_file(text, voice, out)
54
+
55
+
56
+ TTS = TtsService()
57
+
58
+
59
+ def voice_seed(sus_id: str, *, female: bool | None = None) -> VoiceAssignment:
60
+ """A stable VoiceAssignment from a suspect id when no CaseFile suspect is available
61
+ (e.g. the golden case). Gender-matched if known, else any of the 10 voices."""
62
+ seed = int.from_bytes(hashlib.sha256(sus_id.encode()).digest()[:4], "big")
63
+ if female is True:
64
+ speaker = 5 + (seed % 5)
65
+ elif female is False:
66
+ speaker = seed % 5
67
+ else:
68
+ speaker = seed % 10
69
+ return VoiceAssignment(
70
+ engine="supertonic",
71
+ speaker_id=speaker,
72
+ length_scale=round(0.95 + (seed % 20) / 100.0, 3),
73
+ )
src/case_zero/audio/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio: SFX/music manifest and the procedural synth fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .manifest import MUSIC_DIR, MUSIC_TRACKS, SFX_DIR, SFX_EVENTS
6
+ from .synth import generate_music, generate_placeholder_pack, generate_sfx
7
+
8
+ __all__ = [
9
+ "MUSIC_DIR",
10
+ "MUSIC_TRACKS",
11
+ "SFX_DIR",
12
+ "SFX_EVENTS",
13
+ "generate_music",
14
+ "generate_placeholder_pack",
15
+ "generate_sfx",
16
+ ]
src/case_zero/audio/manifest.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio asset manifest: UI sound events and music tracks.
2
+
3
+ Assets are committed under assets/ui and played by the browser at runtime (no model
4
+ loads during play). They are authored once - by an open audio model at pre-bake time,
5
+ or by the procedural synth fallback in ``synth.py``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..constants import ASSETS_DIR
11
+
12
+ SFX_DIR = ASSETS_DIR / "ui" / "sfx"
13
+ MUSIC_DIR = ASSETS_DIR / "ui" / "music"
14
+
15
+ # event name -> filename. Event names are referenced by the UI's JS audio shim.
16
+ SFX_EVENTS: dict[str, str] = {
17
+ "click": "click.wav",
18
+ "select": "select.wav",
19
+ "present": "present.wav",
20
+ "accuse": "accuse.wav",
21
+ "success": "success.wav",
22
+ "fail": "fail.wav",
23
+ "page": "page.wav",
24
+ }
25
+
26
+ MUSIC_TRACKS: tuple[str, ...] = ("ambient_theme.wav",)
src/case_zero/audio/synth.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Procedural audio synth (numpy + stdlib wave).
2
+
3
+ Generates calm, professional UI SFX and a sparse ambient music loop in the spirit of
4
+ Minecraft's quiet themes. Used as the offline placeholder until an open audio model
5
+ (Stable Audio Open / MusicGen) bakes higher-fidelity assets via scripts/prebake_audio.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import wave
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ from .manifest import MUSIC_DIR, SFX_DIR
16
+
17
+ SAMPLE_RATE = 22050
18
+
19
+
20
+ def _write_wav(path: Path, samples: np.ndarray) -> None:
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ clipped = np.clip(samples, -1.0, 1.0)
23
+ pcm = (clipped * 32767.0).astype("<i2")
24
+ with wave.open(str(path), "wb") as wav:
25
+ wav.setnchannels(1)
26
+ wav.setsampwidth(2)
27
+ wav.setframerate(SAMPLE_RATE)
28
+ wav.writeframes(pcm.tobytes())
29
+
30
+
31
+ def _t(duration: float) -> np.ndarray:
32
+ return np.linspace(0.0, duration, int(SAMPLE_RATE * duration), endpoint=False)
33
+
34
+
35
+ def _adsr(n: int, attack: float = 0.01, release: float = 0.2) -> np.ndarray:
36
+ env = np.ones(n)
37
+ a = min(max(1, int(SAMPLE_RATE * attack)), n)
38
+ r = min(max(1, int(SAMPLE_RATE * release)), max(0, n - a))
39
+ if a:
40
+ env[:a] = np.linspace(0.0, 1.0, a)
41
+ if r:
42
+ env[-r:] = np.linspace(1.0, 0.0, r)
43
+ return env
44
+
45
+
46
+ def _tone(freq: float, duration: float, *, kind: str = "sine", gain: float = 0.4,
47
+ attack: float = 0.01, release: float = 0.2) -> np.ndarray:
48
+ t = _t(duration)
49
+ phase = 2 * np.pi * freq * t
50
+ if kind == "square":
51
+ wave_data = np.sign(np.sin(phase))
52
+ elif kind == "triangle":
53
+ wave_data = 2 / np.pi * np.arcsin(np.sin(phase))
54
+ else:
55
+ wave_data = np.sin(phase)
56
+ return wave_data * _adsr(len(t), attack, release) * gain
57
+
58
+
59
+ def _sweep(f0: float, f1: float, duration: float, gain: float = 0.35) -> np.ndarray:
60
+ t = _t(duration)
61
+ freqs = np.linspace(f0, f1, len(t))
62
+ phase = 2 * np.pi * np.cumsum(freqs) / SAMPLE_RATE
63
+ return np.sin(phase) * _adsr(len(t), 0.005, 0.15) * gain
64
+
65
+
66
+ def _noise(duration: float, gain: float = 0.25) -> np.ndarray:
67
+ n = int(SAMPLE_RATE * duration)
68
+ rng = np.random.default_rng(7)
69
+ return rng.uniform(-1, 1, n) * _adsr(n, 0.002, 0.08) * gain
70
+
71
+
72
+ def _sfx(name: str) -> np.ndarray:
73
+ if name == "click":
74
+ return _tone(660, 0.06, kind="triangle", release=0.05)
75
+ if name == "select":
76
+ return np.concatenate([_tone(523, 0.06, release=0.04), _tone(784, 0.08, release=0.06)])
77
+ if name == "present":
78
+ return _sweep(400, 900, 0.22)
79
+ if name == "accuse":
80
+ return _tone(110, 0.5, kind="triangle", gain=0.5, release=0.4)
81
+ if name == "success":
82
+ return np.concatenate([_tone(f, 0.12, release=0.1) for f in (523, 659, 784, 1046)])
83
+ if name == "fail":
84
+ return np.concatenate([_tone(f, 0.16, kind="triangle", release=0.12) for f in (392, 311, 233)])
85
+ if name == "page":
86
+ return _noise(0.18)
87
+ return _tone(440, 0.1)
88
+
89
+
90
+ def generate_sfx(out_dir: Path | None = None) -> list[Path]:
91
+ out = out_dir or SFX_DIR
92
+ from .manifest import SFX_EVENTS
93
+
94
+ paths: list[Path] = []
95
+ for event, filename in SFX_EVENTS.items():
96
+ path = out / filename
97
+ _write_wav(path, _sfx(event))
98
+ paths.append(path)
99
+ return paths
100
+
101
+
102
+ # A slow lofi-noir jazz loop: i - iv - V7 - i in A minor, soft Rhodes-like pads,
103
+ # a gentle bass, brushed percussion, and faint vinyl crackle.
104
+ _CHORDS: tuple[tuple[float, tuple[float, ...]], ...] = (
105
+ (110.00, (261.63, 329.63, 392.00)), # Am7 (A bass; C E G)
106
+ (146.83, (349.23, 440.00, 523.25)), # Dm7 (D bass; F A C)
107
+ (164.81, (415.30, 493.88, 587.33)), # E7 (E bass; G# B D)
108
+ (110.00, (261.63, 329.63, 392.00)), # Am7
109
+ )
110
+
111
+
112
+ def _pad(freqs: tuple[float, ...], duration: float) -> np.ndarray:
113
+ t = _t(duration)
114
+ vibrato = 1.0 + 0.004 * np.sin(2 * np.pi * 5.0 * t)
115
+ tone = np.zeros(len(t))
116
+ for f in freqs:
117
+ tone += np.sin(2 * np.pi * f * t * vibrato) + 0.4 * np.sin(2 * np.pi * 2 * f * t)
118
+ env = np.ones(len(t))
119
+ a, r = int(SAMPLE_RATE * 0.25), int(SAMPLE_RATE * 0.6)
120
+ env[:a] = np.linspace(0, 1, a)
121
+ env[-r:] = np.linspace(1, 0.0, r)
122
+ return tone / len(freqs) * env * 0.16
123
+
124
+
125
+ def generate_music(out_dir: Path | None = None, chord_seconds: float = 4.0) -> Path:
126
+ out = out_dir or MUSIC_DIR
127
+ duration = chord_seconds * len(_CHORDS)
128
+ n = int(SAMPLE_RATE * duration)
129
+ mix = np.zeros(n)
130
+ rng = np.random.default_rng(1989)
131
+
132
+ for i, (bass, voicing) in enumerate(_CHORDS):
133
+ start = int(i * chord_seconds * SAMPLE_RATE)
134
+ chord = _pad(voicing, chord_seconds)
135
+ bass_line = _tone(bass, chord_seconds, kind="sine", gain=0.18, attack=0.05,
136
+ release=chord_seconds * 0.4)
137
+ seg = chord[: len(bass_line)] + bass_line[: len(chord)]
138
+ end = min(n, start + len(seg))
139
+ mix[start:end] += seg[: end - start]
140
+
141
+ # Brushed percussion on a slow ~80 BPM pulse.
142
+ beat = 60.0 / 80.0
143
+ pos = 0.0
144
+ while pos < duration - 0.1:
145
+ tick = _noise(0.05, gain=0.04)
146
+ s = int(pos * SAMPLE_RATE)
147
+ e = min(n, s + len(tick))
148
+ mix[s:e] += tick[: e - s]
149
+ pos += beat
150
+
151
+ # Faint vinyl crackle.
152
+ crackle = rng.uniform(-1, 1, n)
153
+ crackle[np.abs(crackle) < 0.985] = 0.0
154
+ mix += crackle * 0.05
155
+
156
+ # Seamless loop fade.
157
+ fade = int(SAMPLE_RATE * 0.8)
158
+ mix[:fade] *= np.linspace(0, 1, fade)
159
+ mix[-fade:] *= np.linspace(1, 0, fade)
160
+ mix *= 0.85 / (np.max(np.abs(mix)) or 1.0)
161
+
162
+ path = out / "ambient_theme.wav"
163
+ _write_wav(path, mix)
164
+ return path
165
+
166
+
167
+ def generate_placeholder_pack() -> list[Path]:
168
+ return [*generate_sfx(), generate_music()]