Spaces:
Running
Running
feat: multi-crime cases, scene+exhibit pixel art, background AI generation
Browse files- six crime kinds (homicide, theft, fraud, blackmail, arson, missing person) via one crime-profile table; solver guarantees unchanged
- 16 new procedural pixel scenes + per-exhibit evidence-photo art; story beats render the case's real scenes; dossier gains THE EXHIBITS page
- background case generation on every box: per-call model lock, aborts between tokens when a player interrogates; fresh AI cases join the shuffled pool
- prebaked pool grown to 13 cases across kinds; first case randomized every run
- faster generation: tighter token caps, capped grammar fallback, 2 attempts
- hackathon badge tags in README YAML; COMPLIANCE and FIELD_NOTES refreshed
- COMPLIANCE.md +15 -5
- README.md +32 -10
- cases/prebaked/CASE-0006.json +545 -0
- cases/prebaked/CASE-0007.json +545 -0
- cases/prebaked/CASE-0008.json +545 -0
- cases/prebaked/CASE-0009.json +545 -0
- cases/prebaked/CASE-0010.json +546 -0
- cases/prebaked/CASE-0011.json +545 -0
- cases/prebaked/CASE-0012.json +544 -0
- cases/prebaked/CASE-0013.json +545 -0
- docs/FIELD_NOTES.md +17 -6
- scripts/deploy_space.py +44 -0
- scripts/export_traces.py +172 -0
- scripts/prebake_cases.py +45 -9
- src/case_zero/api/case_adapter.py +58 -15
- src/case_zero/api/hints.py +3 -3
- src/case_zero/api/public_view.py +8 -0
- src/case_zero/api/runtime.py +107 -31
- src/case_zero/generator/assemble.py +34 -23
- src/case_zero/generator/crime_profiles.py +247 -0
- src/case_zero/generator/pipeline.py +17 -8
- src/case_zero/generator/stages.py +30 -22
- src/case_zero/llm/decoding.py +5 -2
- src/case_zero/projections/suspect_brief.py +8 -3
- src/case_zero/schemas/case.py +5 -1
- src/case_zero/schemas/enums.py +14 -0
- src/case_zero/suspects/persona.py +2 -2
- src/case_zero/suspects/scrub.py +6 -3
- tests/test_crime_kinds.py +106 -0
- tests/test_runtime_gen.py +65 -0
- web/src/engine/art.ts +836 -9
- web/src/screens/cold.tsx +31 -9
- web/src/screens/deduce.tsx +3 -3
- web/src/screens/endgame.tsx +5 -5
- web/src/screens/evidence.tsx +5 -2
- web/src/types.ts +7 -0
- web/src/ui/components.tsx +30 -1
COMPLIANCE.md
CHANGED
|
@@ -57,13 +57,23 @@ Every model is open-weights and self-run. **No third-party AI service is ever ca
|
|
| 57 |
|
| 58 |
### Targeted / in progress
|
| 59 |
|
| 60 |
-
- **Field Notes** - *"Write a blog post or report about your project."*
|
| 61 |
-
[`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md)
|
| 62 |
- **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
|
| 63 |
-
from."*
|
|
|
|
|
|
|
| 64 |
- **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
|
| 65 |
-
Not
|
| 66 |
-
model; out of scope for this submission
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
## Zero cloud AI APIs
|
| 69 |
|
|
|
|
| 57 |
|
| 58 |
### Targeted / in progress
|
| 59 |
|
| 60 |
+
- **Field Notes** - *"Write a blog post or report about your project."*
|
| 61 |
+
[`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md), shipped with the Space.
|
| 62 |
- **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
|
| 63 |
+
from."* Real traces (the exact prompts + raw completions of a full case generation, and
|
| 64 |
+
a live interrogation playthrough with server-authoritative suspicion) are produced by
|
| 65 |
+
`scripts/export_traces.py` and published as a Hub dataset (linked from the README).
|
| 66 |
- **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
|
| 67 |
+
Not claimed - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and
|
| 68 |
+
publishing a model; out of scope for this submission.
|
| 69 |
+
|
| 70 |
+
## Content scope
|
| 71 |
+
|
| 72 |
+
Cases span **homicide, theft, fraud, blackmail, arson, and missing-person** mysteries.
|
| 73 |
+
Generation is structurally constrained (case-file language, physical evidence, no graphic
|
| 74 |
+
description) and a deterministic scrubber sanitizes model output. Sexual violence is
|
| 75 |
+
deliberately **not** a case type, keeping the Space comfortably inside the
|
| 76 |
+
[HF Content Guidelines](https://huggingface.co/content-guidelines) with no NFAA gating.
|
| 77 |
|
| 78 |
## Zero cloud AI APIs
|
| 79 |
|
README.md
CHANGED
|
@@ -11,8 +11,14 @@ models:
|
|
| 11 |
- Qwen/Qwen2.5-1.5B-Instruct
|
| 12 |
tags:
|
| 13 |
- build-small-hackathon
|
| 14 |
-
-
|
|
|
|
|
|
|
|
|
|
| 15 |
- tiny-titan
|
|
|
|
|
|
|
|
|
|
| 16 |
- detective-game
|
| 17 |
- text-generation
|
| 18 |
- tts
|
|
@@ -20,13 +26,14 @@ tags:
|
|
| 20 |
|
| 21 |
# 🕵️ Case Zero — the AI *is* the detective game
|
| 22 |
|
| 23 |
-
**A brand-new
|
| 24 |
|
| 25 |
No scripted cases. No content library. A single small local model invents the whole
|
| 26 |
-
thing —
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
piece of evidence, you watch the lie
|
|
|
|
| 30 |
|
| 31 |
> Interrogate. Investigate. Accuse. One of them is guilty. Prove it.
|
| 32 |
|
|
@@ -54,15 +61,30 @@ reliability layer — it never writes story, character, or dialogue.
|
|
| 54 |
|
| 55 |
## 🏆 Built for the Build Small Hackathon
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
See [COMPLIANCE.md](COMPLIANCE.md) for the full parameter budget and badge details.
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
## ▶️ Run it locally
|
| 67 |
|
| 68 |
```bash
|
|
|
|
| 11 |
- Qwen/Qwen2.5-1.5B-Instruct
|
| 12 |
tags:
|
| 13 |
- build-small-hackathon
|
| 14 |
+
- thousand-token-wood
|
| 15 |
+
- off-the-grid
|
| 16 |
+
- llama-champion
|
| 17 |
+
- off-brand
|
| 18 |
- tiny-titan
|
| 19 |
+
- field-notes
|
| 20 |
+
- sharing-is-caring
|
| 21 |
+
- llama-cpp
|
| 22 |
- detective-game
|
| 23 |
- text-generation
|
| 24 |
- tts
|
|
|
|
| 26 |
|
| 27 |
# 🕵️ Case Zero — the AI *is* the detective game
|
| 28 |
|
| 29 |
+
**A brand-new mystery, written and acted by a 1.5B model, every single time.**
|
| 30 |
|
| 31 |
No scripted cases. No content library. A single small local model invents the whole
|
| 32 |
+
thing — a homicide, a heist, a fraud, a blackmail ring, an arson, a disappearance — the
|
| 33 |
+
victim, the suspects, their secrets and motives, the timeline, the evidence, and the one
|
| 34 |
+
who did it — then **role-plays every suspect live**. They remember what you asked. They
|
| 35 |
+
lie to your face. And when you slap down the right piece of evidence, you watch the lie
|
| 36 |
+
**crack in real time**.
|
| 37 |
|
| 38 |
> Interrogate. Investigate. Accuse. One of them is guilty. Prove it.
|
| 39 |
|
|
|
|
| 61 |
|
| 62 |
## 🏆 Built for the Build Small Hackathon
|
| 63 |
|
| 64 |
+
**Track: Thousand Token Wood** — a game that is genuinely impossible without AI: the
|
| 65 |
+
model doesn't power a feature, it *authors and performs the entire mystery*.
|
| 66 |
+
|
| 67 |
+
- **Off the Grid:** zero cloud APIs. The LLM and the TTS run **in-process on the Space's
|
| 68 |
+
CPU**; a network-guarded test plays a full case with all non-loopback sockets blocked.
|
| 69 |
- **Llama Champion:** the model runs through the **llama.cpp** runtime, in-process — no
|
| 70 |
server, no remote endpoint.
|
| 71 |
+
- **Off-Brand:** a fully custom 12-screen pixel-art noir SPA served through
|
| 72 |
+
**`gr.Server`** — procedural scenes, exhibit art, and sprites, all canvas-drawn.
|
| 73 |
+
- **Tiny Titan (≤4B):** the entire game runs on **Qwen2.5-1.5B** — ~1.6B total runtime
|
| 74 |
+
params (LLM + Supertonic), far under the 32B cap.
|
| 75 |
+
- **Field Notes:** [docs/FIELD_NOTES.md](docs/FIELD_NOTES.md) — what we built, what
|
| 76 |
+
broke, and what a 1.5B model can really do.
|
| 77 |
+
- **Sharing is Caring:** real agent traces (case generation + live interrogations) are
|
| 78 |
+
published on the Hub — see the traces dataset linked below.
|
| 79 |
|
| 80 |
See [COMPLIANCE.md](COMPLIANCE.md) for the full parameter budget and badge details.
|
| 81 |
|
| 82 |
+
## 📦 Submission links
|
| 83 |
+
|
| 84 |
+
- **Demo video:** *coming before the deadline — link will land here.*
|
| 85 |
+
- **Social post:** *coming before the deadline — link will land here.*
|
| 86 |
+
- **Agent traces dataset:** *link lands here once published.*
|
| 87 |
+
|
| 88 |
## ▶️ Run it locally
|
| 89 |
|
| 90 |
```bash
|
cases/prebaked/CASE-0006.json
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0006",
|
| 3 |
+
"seed": 51000,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Empty Case in the Master Bedroom",
|
| 6 |
+
"briefing": "A precious jewel, a painting worth its weight in gold, and a rare manuscript were stolen from the victim's mansion between 21:00 and 22:00. The thief fled without leaving any trace, making the case even more mysterious.",
|
| 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 |
+
"crime_kind": "theft"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "theft",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "The Whispering Veil Manor",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "Entrance Hall",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "Library",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "Master Bedroom",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "Guest Room",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Lord Rivenstone",
|
| 65 |
+
"role": "Owner of a rare manuscript and painting, victim of the theft",
|
| 66 |
+
"found_at_loc_id": "L3",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "The victim discovered the loss in his library while reviewing old manuscripts. It was left on the floor.",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "",
|
| 77 |
+
"kind": "",
|
| 78 |
+
"origin_loc_id": "L3",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "Sir Edward Blackwood",
|
| 86 |
+
"role": "Rival of Lord Rivenstone, heir to a fortune in gold and jewels",
|
| 87 |
+
"persona_summary": "A charming gentleman with a secret scandal. He has been seen at the mansion but is nowhere near the thief.",
|
| 88 |
+
"demeanour": "cool and self-assured, almost amused by the questioning",
|
| 89 |
+
"is_culprit": true,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.88,
|
| 96 |
+
"aggression": 0.3,
|
| 97 |
+
"evasiveness": 0.35
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"a faint knowing smile"
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_scene",
|
| 104 |
+
"F_sec1"
|
| 105 |
+
],
|
| 106 |
+
"secrets": [
|
| 107 |
+
"He has been found guilty of embezzling funds from the estate for his mistress. His cover story is that he was out of town on a business trip, which was only to avoid confrontation."
|
| 108 |
+
],
|
| 109 |
+
"true_whereabouts": [
|
| 110 |
+
{
|
| 111 |
+
"window": {
|
| 112 |
+
"start_min": 1260,
|
| 113 |
+
"end_min": 1280
|
| 114 |
+
},
|
| 115 |
+
"loc_id": "L4",
|
| 116 |
+
"activity": "mingling in plain sight",
|
| 117 |
+
"co_present_sus_ids": []
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"window": {
|
| 121 |
+
"start_min": 1280,
|
| 122 |
+
"end_min": 1310
|
| 123 |
+
},
|
| 124 |
+
"loc_id": "L3",
|
| 125 |
+
"activity": "at the scene of the theft",
|
| 126 |
+
"co_present_sus_ids": []
|
| 127 |
+
}
|
| 128 |
+
],
|
| 129 |
+
"stated_alibi": {
|
| 130 |
+
"claim_text": "I was in the Guest Room all night long. I have nothing to do with the theft.",
|
| 131 |
+
"claimed_segments": [
|
| 132 |
+
{
|
| 133 |
+
"window": {
|
| 134 |
+
"start_min": 1260,
|
| 135 |
+
"end_min": 1320
|
| 136 |
+
},
|
| 137 |
+
"loc_id": "L4",
|
| 138 |
+
"witness_sus_ids": []
|
| 139 |
+
}
|
| 140 |
+
]
|
| 141 |
+
},
|
| 142 |
+
"must_lie_about": [
|
| 143 |
+
"F_scene"
|
| 144 |
+
],
|
| 145 |
+
"anchored_lies": [
|
| 146 |
+
{
|
| 147 |
+
"lie_id": "LIE_alibi",
|
| 148 |
+
"topic": "where you were during the theft",
|
| 149 |
+
"claimed": "I was in the Guest Room all night long. I have nothing to do with the theft.",
|
| 150 |
+
"truth_ref": "F_scene",
|
| 151 |
+
"breaks_on": [
|
| 152 |
+
"C_b1",
|
| 153 |
+
"C_b2"
|
| 154 |
+
],
|
| 155 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 156 |
+
}
|
| 157 |
+
],
|
| 158 |
+
"voice": null,
|
| 159 |
+
"visual": {
|
| 160 |
+
"subject_type": "suspect",
|
| 161 |
+
"palette": "noir",
|
| 162 |
+
"gender": "male",
|
| 163 |
+
"age_band": null,
|
| 164 |
+
"build": null,
|
| 165 |
+
"hair": null,
|
| 166 |
+
"attire": null,
|
| 167 |
+
"mood": "guarded",
|
| 168 |
+
"accent_color": "#b8860b",
|
| 169 |
+
"location_tags": [],
|
| 170 |
+
"prop_tags": [],
|
| 171 |
+
"prompt_hint": "Attractive, well-dressed in a silk dressing gown and evening gloves, age: 50s, attire: Formal evening wear with an elaborate bow tie, composure: 1, aggression: 0.3, evasiveness: 0.7, tell: 'I was just helping my father prepare for the upcoming masquerade ball.'"
|
| 172 |
+
}
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"sus_id": "S2",
|
| 176 |
+
"name": "Lady Evelyn Blackwood",
|
| 177 |
+
"role": "Sister to Sir Edward, who was seen at the mansion but is not a suspect in this case",
|
| 178 |
+
"persona_summary": "The sweetest of ladies and sister to the ringleader, she had no reason to be anywhere near the theft.",
|
| 179 |
+
"demeanour": "visibly frightened and on edge, dreading every question",
|
| 180 |
+
"is_culprit": false,
|
| 181 |
+
"physical_capability": {
|
| 182 |
+
"strength": true,
|
| 183 |
+
"mobility": true
|
| 184 |
+
},
|
| 185 |
+
"personality": {
|
| 186 |
+
"composure": 0.2,
|
| 187 |
+
"aggression": 0.3,
|
| 188 |
+
"evasiveness": 0.7
|
| 189 |
+
},
|
| 190 |
+
"tells": [
|
| 191 |
+
"trembling hands"
|
| 192 |
+
],
|
| 193 |
+
"knows_facts": [
|
| 194 |
+
"F_sec2"
|
| 195 |
+
],
|
| 196 |
+
"secrets": [
|
| 197 |
+
"Her husband has been found guilty of embezzling funds from the estate. Her cover story is that she was at her brother's business meetings."
|
| 198 |
+
],
|
| 199 |
+
"true_whereabouts": [
|
| 200 |
+
{
|
| 201 |
+
"window": {
|
| 202 |
+
"start_min": 1260,
|
| 203 |
+
"end_min": 1320
|
| 204 |
+
},
|
| 205 |
+
"loc_id": "L2",
|
| 206 |
+
"activity": "going about the evening",
|
| 207 |
+
"co_present_sus_ids": []
|
| 208 |
+
}
|
| 209 |
+
],
|
| 210 |
+
"stated_alibi": {
|
| 211 |
+
"claim_text": "I was in Library the whole time.",
|
| 212 |
+
"claimed_segments": [
|
| 213 |
+
{
|
| 214 |
+
"window": {
|
| 215 |
+
"start_min": 1260,
|
| 216 |
+
"end_min": 1320
|
| 217 |
+
},
|
| 218 |
+
"loc_id": "L2",
|
| 219 |
+
"witness_sus_ids": []
|
| 220 |
+
}
|
| 221 |
+
]
|
| 222 |
+
},
|
| 223 |
+
"must_lie_about": [
|
| 224 |
+
"F_sec2"
|
| 225 |
+
],
|
| 226 |
+
"anchored_lies": [
|
| 227 |
+
{
|
| 228 |
+
"lie_id": "LIE_sec2",
|
| 229 |
+
"topic": "Her husband has been found guilty of embezzling ",
|
| 230 |
+
"claimed": "She claimed she was at a charity event hosted by the Blackwood family, but no proof exists to corroborate this lie.",
|
| 231 |
+
"truth_ref": "F_sec2",
|
| 232 |
+
"breaks_on": [
|
| 233 |
+
"C_h2"
|
| 234 |
+
],
|
| 235 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the theft."
|
| 236 |
+
}
|
| 237 |
+
],
|
| 238 |
+
"voice": null,
|
| 239 |
+
"visual": {
|
| 240 |
+
"subject_type": "suspect",
|
| 241 |
+
"palette": "noir",
|
| 242 |
+
"gender": "female",
|
| 243 |
+
"age_band": null,
|
| 244 |
+
"build": null,
|
| 245 |
+
"hair": null,
|
| 246 |
+
"attire": null,
|
| 247 |
+
"mood": "guarded",
|
| 248 |
+
"accent_color": "#3a6ea5",
|
| 249 |
+
"location_tags": [],
|
| 250 |
+
"prop_tags": [],
|
| 251 |
+
"prompt_hint": "Graceful, dressed in an elegant gown and high heels, age: 30s, attire: Formal evening wear with delicate jewelry, composure: 1, aggression: 0.2, evasiveness: 0.8, tell: 'I was just helping my brother arrange the event for the charity ball.'"
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"sus_id": "S3",
|
| 256 |
+
"name": "Captain Jameson",
|
| 257 |
+
"role": "Guard at the mansion, hired by Sir Edward to protect him from Lord Rivenstone's rival",
|
| 258 |
+
"persona_summary": "A man of duty and strict discipline, he had no reason for entering the house.",
|
| 259 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 260 |
+
"is_culprit": false,
|
| 261 |
+
"physical_capability": {
|
| 262 |
+
"strength": true,
|
| 263 |
+
"mobility": true
|
| 264 |
+
},
|
| 265 |
+
"personality": {
|
| 266 |
+
"composure": 0.55,
|
| 267 |
+
"aggression": 0.88,
|
| 268 |
+
"evasiveness": 0.4
|
| 269 |
+
},
|
| 270 |
+
"tells": [
|
| 271 |
+
"a clenched jaw"
|
| 272 |
+
],
|
| 273 |
+
"knows_facts": [
|
| 274 |
+
"F_sec3"
|
| 275 |
+
],
|
| 276 |
+
"secrets": [
|
| 277 |
+
"He is a former soldier who was dismissed from his duties due to insubordination. His cover story is that he was on leave but not allowed home."
|
| 278 |
+
],
|
| 279 |
+
"true_whereabouts": [
|
| 280 |
+
{
|
| 281 |
+
"window": {
|
| 282 |
+
"start_min": 1260,
|
| 283 |
+
"end_min": 1320
|
| 284 |
+
},
|
| 285 |
+
"loc_id": "L4",
|
| 286 |
+
"activity": "going about the evening",
|
| 287 |
+
"co_present_sus_ids": []
|
| 288 |
+
}
|
| 289 |
+
],
|
| 290 |
+
"stated_alibi": {
|
| 291 |
+
"claim_text": "I was in Guest Room the whole time.",
|
| 292 |
+
"claimed_segments": [
|
| 293 |
+
{
|
| 294 |
+
"window": {
|
| 295 |
+
"start_min": 1260,
|
| 296 |
+
"end_min": 1320
|
| 297 |
+
},
|
| 298 |
+
"loc_id": "L4",
|
| 299 |
+
"witness_sus_ids": []
|
| 300 |
+
}
|
| 301 |
+
]
|
| 302 |
+
},
|
| 303 |
+
"must_lie_about": [
|
| 304 |
+
"F_sec3"
|
| 305 |
+
],
|
| 306 |
+
"anchored_lies": [
|
| 307 |
+
{
|
| 308 |
+
"lie_id": "LIE_sec3",
|
| 309 |
+
"topic": "He is a former soldier who was dismissed from hi",
|
| 310 |
+
"claimed": "He claimed he was out on patrol when the theft occurred, as part of his job description.",
|
| 311 |
+
"truth_ref": "F_sec3",
|
| 312 |
+
"breaks_on": [
|
| 313 |
+
"C_h3"
|
| 314 |
+
],
|
| 315 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the theft."
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"voice": null,
|
| 319 |
+
"visual": {
|
| 320 |
+
"subject_type": "suspect",
|
| 321 |
+
"palette": "noir",
|
| 322 |
+
"gender": "male",
|
| 323 |
+
"age_band": null,
|
| 324 |
+
"build": null,
|
| 325 |
+
"hair": null,
|
| 326 |
+
"attire": null,
|
| 327 |
+
"mood": "tense",
|
| 328 |
+
"accent_color": "#9a9aa0",
|
| 329 |
+
"location_tags": [],
|
| 330 |
+
"prop_tags": [],
|
| 331 |
+
"prompt_hint": "Strictly dressed in military uniform, age: 40s, attire: Formal evening wear with a polished brass watch chain, composure: 0.8, aggression: 0.5, evasiveness: 0.3, tell: 'I was just checking the security system when I noticed someone moving in my direction.'"
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sus_id": "S4",
|
| 336 |
+
"name": "Sophie Rivenstone",
|
| 337 |
+
"role": "Servant, working for Lord Rivenstone, often in the Guest Room but not at the time of the theft",
|
| 338 |
+
"persona_summary": "A reliable and trustworthy servant who was always present when Lord Rivenstone was out.",
|
| 339 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 340 |
+
"is_culprit": false,
|
| 341 |
+
"physical_capability": {
|
| 342 |
+
"strength": true,
|
| 343 |
+
"mobility": true
|
| 344 |
+
},
|
| 345 |
+
"personality": {
|
| 346 |
+
"composure": 0.72,
|
| 347 |
+
"aggression": 0.4,
|
| 348 |
+
"evasiveness": 0.3
|
| 349 |
+
},
|
| 350 |
+
"tells": [
|
| 351 |
+
"a too-steady voice"
|
| 352 |
+
],
|
| 353 |
+
"knows_facts": [
|
| 354 |
+
"F_sec4"
|
| 355 |
+
],
|
| 356 |
+
"secrets": [
|
| 357 |
+
"She has been accused of being a spy for her former employer, a rival to the Blackwood family. Her cover story is that she was helping with the estate's organization."
|
| 358 |
+
],
|
| 359 |
+
"true_whereabouts": [
|
| 360 |
+
{
|
| 361 |
+
"window": {
|
| 362 |
+
"start_min": 1260,
|
| 363 |
+
"end_min": 1320
|
| 364 |
+
},
|
| 365 |
+
"loc_id": "L1",
|
| 366 |
+
"activity": "going about the evening",
|
| 367 |
+
"co_present_sus_ids": []
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"stated_alibi": {
|
| 371 |
+
"claim_text": "I was in Entrance Hall the whole time.",
|
| 372 |
+
"claimed_segments": [
|
| 373 |
+
{
|
| 374 |
+
"window": {
|
| 375 |
+
"start_min": 1260,
|
| 376 |
+
"end_min": 1320
|
| 377 |
+
},
|
| 378 |
+
"loc_id": "L1",
|
| 379 |
+
"witness_sus_ids": []
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"must_lie_about": [
|
| 384 |
+
"F_sec4"
|
| 385 |
+
],
|
| 386 |
+
"anchored_lies": [
|
| 387 |
+
{
|
| 388 |
+
"lie_id": "LIE_sec4",
|
| 389 |
+
"topic": "She has been accused of being a spy for her form",
|
| 390 |
+
"claimed": "She claimed she was at a meeting in her quarters but no proof exists to corroborate this lie.",
|
| 391 |
+
"truth_ref": "F_sec4",
|
| 392 |
+
"breaks_on": [
|
| 393 |
+
"C_h4"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the theft."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "female",
|
| 403 |
+
"age_band": null,
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": null,
|
| 407 |
+
"mood": "guarded",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "Attractive, dressed casually but neatly, age: 20s, attire: Casual evening wear with a small necklace, composure: 0.6, aggression: 0.3, evasiveness: 0.7, tell: 'I was just tidying up one of the guest rooms when I noticed something that shouldn't be there.'"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S1",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "Sir Edward Blackwood stole the jewels from Lord Rivenstone to exact revenge after a false accusation."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "The thief, Sir Edward Blackwood, used his influence and connections to frame Lord Rivenstone for a crime he did not commit. Afterward, he took the opportunity to steal the valuable jewels from Lord Rivenstone's Master Bedroom, which were then hidden in a secret compartment.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L4",
|
| 425 |
+
"actual_loc_id": "L3",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "Sir Edward Blackwood was in Master Bedroom during the theft.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L3",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "He has been found guilty of embezzling funds from the estate for his mistress. His cover story is that he was out of town on a business trip, which was only to avoid confrontation.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "Her husband has been found guilty of embezzling funds from the estate. Her cover story is that she was at her brother's business meetings.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "He is a former soldier who was dismissed from his duties due to insubordination. His cover story is that he was on leave but not allowed home.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "She has been accused of being a spy for her former employer, a rival to the Blackwood family. Her cover story is that she was helping with the estate's organization.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Partial fingerprint",
|
| 474 |
+
"reveal_text": "A fresh partial fingerprint on the weapon, unaccounted for among the guests.",
|
| 475 |
+
"discoverable_at_loc_id": "L3",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S1",
|
| 479 |
+
"contradicts_alibi_of": "S1",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Smudged tumbler",
|
| 486 |
+
"reveal_text": "A tumbler abandoned at the scene, its rim marked with a recent lip-print.",
|
| 487 |
+
"discoverable_at_loc_id": "L1",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S1",
|
| 491 |
+
"contradicts_alibi_of": "S1",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h2",
|
| 497 |
+
"name": "Old photograph",
|
| 498 |
+
"reveal_text": "A worn photograph they would rather no one had seen.",
|
| 499 |
+
"discoverable_at_loc_id": "L2",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec2",
|
| 502 |
+
"points_to_sus_id": "S2",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h3",
|
| 509 |
+
"name": "Pawn ticket",
|
| 510 |
+
"reveal_text": "A pawn ticket for a family heirloom, recently surrendered.",
|
| 511 |
+
"discoverable_at_loc_id": "L4",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec3",
|
| 514 |
+
"points_to_sus_id": "S3",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h4",
|
| 521 |
+
"name": "Ticket stub",
|
| 522 |
+
"reveal_text": "A ticket stub placing them somewhere they had denied being.",
|
| 523 |
+
"discoverable_at_loc_id": "L1",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec4",
|
| 526 |
+
"points_to_sus_id": "S4",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S1",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"The alibi claim is based on a false location index, indicating the thief was in an entirely different room.",
|
| 541 |
+
"The dropped object, a monogrammed locket found near the stolen jewels, suggests it belonged to someone with ties to both Lord Rivenstone and Sir Edward Blackwood.",
|
| 542 |
+
"The torn fiber discovered next to the door of the Master Bedroom shows that someone entered through the window, leading to the conclusion that the theft occurred from the outside."
|
| 543 |
+
]
|
| 544 |
+
}
|
| 545 |
+
}
|
cases/prebaked/CASE-0007.json
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0007",
|
| 3 |
+
"seed": 51002,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Old Clock Tower Demand",
|
| 6 |
+
"briefing": "A blackmail demand was planted between 21:00 and 22:00, hinting a sinister game of deception.",
|
| 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 |
+
"crime_kind": "blackmail"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "blackmail",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "The Old Clock Tower",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "living_room",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "kitchen",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "bathroom",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "study",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Lena Thompson",
|
| 65 |
+
"role": "Business Partner in the tech startup she co-founded",
|
| 66 |
+
"found_at_loc_id": "L1",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "An overdose of a prescription painkillers mixed with arsenic found in her study, victimized by a blackmail extortionist who sat at table 1.",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "",
|
| 77 |
+
"kind": "",
|
| 78 |
+
"origin_loc_id": "L1",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "Thomas Reeves",
|
| 86 |
+
"role": "Business Partner of Lena Thompson",
|
| 87 |
+
"persona_summary": "A stoic, workaholic who is the most distant from Lena and has never been involved in their financial dealings.",
|
| 88 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 89 |
+
"is_culprit": false,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.55,
|
| 96 |
+
"aggression": 0.88,
|
| 97 |
+
"evasiveness": 0.4
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"I've always been good at covering up my gambling problems."
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_sec1"
|
| 104 |
+
],
|
| 105 |
+
"secrets": [
|
| 106 |
+
"He was once a known gambler with a gambling addiction that he kept hidden from his wife, causing her to be jealous but not aware of it."
|
| 107 |
+
],
|
| 108 |
+
"true_whereabouts": [
|
| 109 |
+
{
|
| 110 |
+
"window": {
|
| 111 |
+
"start_min": 1260,
|
| 112 |
+
"end_min": 1320
|
| 113 |
+
},
|
| 114 |
+
"loc_id": "L2",
|
| 115 |
+
"activity": "going about the evening",
|
| 116 |
+
"co_present_sus_ids": []
|
| 117 |
+
}
|
| 118 |
+
],
|
| 119 |
+
"stated_alibi": {
|
| 120 |
+
"claim_text": "I was in kitchen the whole time.",
|
| 121 |
+
"claimed_segments": [
|
| 122 |
+
{
|
| 123 |
+
"window": {
|
| 124 |
+
"start_min": 1260,
|
| 125 |
+
"end_min": 1320
|
| 126 |
+
},
|
| 127 |
+
"loc_id": "L2",
|
| 128 |
+
"witness_sus_ids": []
|
| 129 |
+
}
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
"must_lie_about": [
|
| 133 |
+
"F_sec1"
|
| 134 |
+
],
|
| 135 |
+
"anchored_lies": [
|
| 136 |
+
{
|
| 137 |
+
"lie_id": "LIE_sec1",
|
| 138 |
+
"topic": "He was once a known gambler with a gambling addi",
|
| 139 |
+
"claimed": "A high-profile business partner, always in control, who can handle any crisis professionally but rarely displays emotions.",
|
| 140 |
+
"truth_ref": "F_sec1",
|
| 141 |
+
"breaks_on": [
|
| 142 |
+
"C_h1"
|
| 143 |
+
],
|
| 144 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the blackmail drop."
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
"voice": null,
|
| 148 |
+
"visual": {
|
| 149 |
+
"subject_type": "suspect",
|
| 150 |
+
"palette": "noir",
|
| 151 |
+
"gender": "male",
|
| 152 |
+
"age_band": "50s-60s",
|
| 153 |
+
"build": null,
|
| 154 |
+
"hair": null,
|
| 155 |
+
"attire": "Dark suit, dark shoes",
|
| 156 |
+
"mood": "guarded",
|
| 157 |
+
"accent_color": "#b8860b",
|
| 158 |
+
"location_tags": [],
|
| 159 |
+
"prop_tags": [],
|
| 160 |
+
"prompt_hint": "Business suit and tie, middle-aged male with sharp features, Dark suit, dark shoes"
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"sus_id": "S2",
|
| 165 |
+
"name": "Sophie Thompson (née Reeves)",
|
| 166 |
+
"role": "Lena's Rival",
|
| 167 |
+
"persona_summary": "A career-driven woman with a strong personality who is fiercely competitive and has a hidden hatred for Lena.",
|
| 168 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 169 |
+
"is_culprit": false,
|
| 170 |
+
"physical_capability": {
|
| 171 |
+
"strength": true,
|
| 172 |
+
"mobility": true
|
| 173 |
+
},
|
| 174 |
+
"personality": {
|
| 175 |
+
"composure": 0.72,
|
| 176 |
+
"aggression": 0.4,
|
| 177 |
+
"evasiveness": 0.3
|
| 178 |
+
},
|
| 179 |
+
"tells": [
|
| 180 |
+
"I've been doing well lately, but this shows my old rival is still out for me."
|
| 181 |
+
],
|
| 182 |
+
"knows_facts": [
|
| 183 |
+
"F_sec2"
|
| 184 |
+
],
|
| 185 |
+
"secrets": [
|
| 186 |
+
"She was once the CEO of her own tech startup, but it collapsed under financial mismanagement. Her personal life is less glamorous, but she hides this from everyone to maintain her reputation."
|
| 187 |
+
],
|
| 188 |
+
"true_whereabouts": [
|
| 189 |
+
{
|
| 190 |
+
"window": {
|
| 191 |
+
"start_min": 1260,
|
| 192 |
+
"end_min": 1320
|
| 193 |
+
},
|
| 194 |
+
"loc_id": "L3",
|
| 195 |
+
"activity": "going about the evening",
|
| 196 |
+
"co_present_sus_ids": []
|
| 197 |
+
}
|
| 198 |
+
],
|
| 199 |
+
"stated_alibi": {
|
| 200 |
+
"claim_text": "I was in bathroom the whole time.",
|
| 201 |
+
"claimed_segments": [
|
| 202 |
+
{
|
| 203 |
+
"window": {
|
| 204 |
+
"start_min": 1260,
|
| 205 |
+
"end_min": 1320
|
| 206 |
+
},
|
| 207 |
+
"loc_id": "L3",
|
| 208 |
+
"witness_sus_ids": []
|
| 209 |
+
}
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
"must_lie_about": [
|
| 213 |
+
"F_sec2"
|
| 214 |
+
],
|
| 215 |
+
"anchored_lies": [
|
| 216 |
+
{
|
| 217 |
+
"lie_id": "LIE_sec2",
|
| 218 |
+
"topic": "She was once the CEO of her own tech startup, bu",
|
| 219 |
+
"claimed": "A business rival with a sharp tongue who always puts pressure on people in negotiations and never reveals their weaknesses.",
|
| 220 |
+
"truth_ref": "F_sec2",
|
| 221 |
+
"breaks_on": [
|
| 222 |
+
"C_h2"
|
| 223 |
+
],
|
| 224 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the blackmail drop."
|
| 225 |
+
}
|
| 226 |
+
],
|
| 227 |
+
"voice": null,
|
| 228 |
+
"visual": {
|
| 229 |
+
"subject_type": "suspect",
|
| 230 |
+
"palette": "noir",
|
| 231 |
+
"gender": "female",
|
| 232 |
+
"age_band": "20s-40s",
|
| 233 |
+
"build": null,
|
| 234 |
+
"hair": null,
|
| 235 |
+
"attire": "Office attire, high heels, dressy blouse",
|
| 236 |
+
"mood": "guarded",
|
| 237 |
+
"accent_color": "#3a6ea5",
|
| 238 |
+
"location_tags": [],
|
| 239 |
+
"prop_tags": [],
|
| 240 |
+
"prompt_hint": "Business attire and heels, 20s-30s with a sharp look in her eyes, Office attire, high heels, dressy blouse"
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"sus_id": "S3",
|
| 245 |
+
"name": "Jack Miller",
|
| 246 |
+
"role": "Rival Business Partner",
|
| 247 |
+
"persona_summary": "A charismatic man who uses his charm to manipulate others into giving him money. He's not very good at financial planning.",
|
| 248 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 249 |
+
"is_culprit": true,
|
| 250 |
+
"physical_capability": {
|
| 251 |
+
"strength": true,
|
| 252 |
+
"mobility": true
|
| 253 |
+
},
|
| 254 |
+
"personality": {
|
| 255 |
+
"composure": 0.32,
|
| 256 |
+
"aggression": 0.55,
|
| 257 |
+
"evasiveness": 0.68
|
| 258 |
+
},
|
| 259 |
+
"tells": [
|
| 260 |
+
"I'm not the gambling type, but I do love a good tip."
|
| 261 |
+
],
|
| 262 |
+
"knows_facts": [
|
| 263 |
+
"F_scene",
|
| 264 |
+
"F_sec3"
|
| 265 |
+
],
|
| 266 |
+
"secrets": [
|
| 267 |
+
"He had a gambling addiction that he never disclosed, leading to financial ruin and the bankruptcy of several companies he invested in."
|
| 268 |
+
],
|
| 269 |
+
"true_whereabouts": [
|
| 270 |
+
{
|
| 271 |
+
"window": {
|
| 272 |
+
"start_min": 1260,
|
| 273 |
+
"end_min": 1280
|
| 274 |
+
},
|
| 275 |
+
"loc_id": "L2",
|
| 276 |
+
"activity": "mingling in plain sight",
|
| 277 |
+
"co_present_sus_ids": []
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"window": {
|
| 281 |
+
"start_min": 1280,
|
| 282 |
+
"end_min": 1310
|
| 283 |
+
},
|
| 284 |
+
"loc_id": "L1",
|
| 285 |
+
"activity": "at the scene of the blackmail drop",
|
| 286 |
+
"co_present_sus_ids": []
|
| 287 |
+
}
|
| 288 |
+
],
|
| 289 |
+
"stated_alibi": {
|
| 290 |
+
"claim_text": "I was cooking dinner for my family in the living room; I didn't leave until after midnight.",
|
| 291 |
+
"claimed_segments": [
|
| 292 |
+
{
|
| 293 |
+
"window": {
|
| 294 |
+
"start_min": 1260,
|
| 295 |
+
"end_min": 1320
|
| 296 |
+
},
|
| 297 |
+
"loc_id": "L2",
|
| 298 |
+
"witness_sus_ids": []
|
| 299 |
+
}
|
| 300 |
+
]
|
| 301 |
+
},
|
| 302 |
+
"must_lie_about": [
|
| 303 |
+
"F_scene"
|
| 304 |
+
],
|
| 305 |
+
"anchored_lies": [
|
| 306 |
+
{
|
| 307 |
+
"lie_id": "LIE_alibi",
|
| 308 |
+
"topic": "where you were during the blackmail drop",
|
| 309 |
+
"claimed": "I was cooking dinner for my family in the living room; I didn't leave until after midnight.",
|
| 310 |
+
"truth_ref": "F_scene",
|
| 311 |
+
"breaks_on": [
|
| 312 |
+
"C_b1",
|
| 313 |
+
"C_b2"
|
| 314 |
+
],
|
| 315 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"voice": null,
|
| 319 |
+
"visual": {
|
| 320 |
+
"subject_type": "suspect",
|
| 321 |
+
"palette": "noir",
|
| 322 |
+
"gender": "male",
|
| 323 |
+
"age_band": "50s-60s",
|
| 324 |
+
"build": null,
|
| 325 |
+
"hair": null,
|
| 326 |
+
"attire": "Business suit, dress shoes",
|
| 327 |
+
"mood": "guarded",
|
| 328 |
+
"accent_color": "#9a9aa0",
|
| 329 |
+
"location_tags": [],
|
| 330 |
+
"prop_tags": [],
|
| 331 |
+
"prompt_hint": "Business suit and tie, middle-aged man with slicked-back hair, Business suit, dress shoes"
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sus_id": "S4",
|
| 336 |
+
"name": "Ella Johnson",
|
| 337 |
+
"role": "Secret Lover of Lena Thompson",
|
| 338 |
+
"persona_summary": "An old flame of Lena's who has been living together in secret for years. She keeps her relationship hidden as she is afraid of the consequences if it were discovered.",
|
| 339 |
+
"demeanour": "guarded and weary, giving away as little as possible",
|
| 340 |
+
"is_culprit": false,
|
| 341 |
+
"physical_capability": {
|
| 342 |
+
"strength": true,
|
| 343 |
+
"mobility": true
|
| 344 |
+
},
|
| 345 |
+
"personality": {
|
| 346 |
+
"composure": 0.6,
|
| 347 |
+
"aggression": 0.25,
|
| 348 |
+
"evasiveness": 0.55
|
| 349 |
+
},
|
| 350 |
+
"tells": [
|
| 351 |
+
"I was never one for commitment or public displays of affection."
|
| 352 |
+
],
|
| 353 |
+
"knows_facts": [
|
| 354 |
+
"F_sec4"
|
| 355 |
+
],
|
| 356 |
+
"secrets": [
|
| 357 |
+
"She was involved in a blackmail scheme involving false accusations and threats to ruin Lena's business career."
|
| 358 |
+
],
|
| 359 |
+
"true_whereabouts": [
|
| 360 |
+
{
|
| 361 |
+
"window": {
|
| 362 |
+
"start_min": 1260,
|
| 363 |
+
"end_min": 1320
|
| 364 |
+
},
|
| 365 |
+
"loc_id": "L2",
|
| 366 |
+
"activity": "going about the evening",
|
| 367 |
+
"co_present_sus_ids": []
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"stated_alibi": {
|
| 371 |
+
"claim_text": "I was in kitchen the whole time.",
|
| 372 |
+
"claimed_segments": [
|
| 373 |
+
{
|
| 374 |
+
"window": {
|
| 375 |
+
"start_min": 1260,
|
| 376 |
+
"end_min": 1320
|
| 377 |
+
},
|
| 378 |
+
"loc_id": "L2",
|
| 379 |
+
"witness_sus_ids": []
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"must_lie_about": [
|
| 384 |
+
"F_sec4"
|
| 385 |
+
],
|
| 386 |
+
"anchored_lies": [
|
| 387 |
+
{
|
| 388 |
+
"lie_id": "LIE_sec4",
|
| 389 |
+
"topic": "She was involved in a blackmail scheme involving",
|
| 390 |
+
"claimed": "A private and shrewd woman with impeccable taste who has been an admirer of Lena for many years, but never showed her true feelings before. She is the one who orchestrated the blackmail.",
|
| 391 |
+
"truth_ref": "F_sec4",
|
| 392 |
+
"breaks_on": [
|
| 393 |
+
"C_h4"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the blackmail drop."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "female",
|
| 403 |
+
"age_band": "40s-50s",
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": "Casual outfit, flowers in hand",
|
| 407 |
+
"mood": "guarded",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "Casual attire, 30s with a bouquet of flowers, Casual outfit, flowers in hand"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S3",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "To get revenge on Lena Thompson for stealing his business partner position and exploiting him financially."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "Jack Miller planted the demand to blackmail Lena Thompson with a false alibi claim that he was in the kitchen, while actually being present in the living room between 21:00 and 22:00.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L2",
|
| 425 |
+
"actual_loc_id": "L1",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "Jack Miller was in living_room during the blackmail drop.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L1",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "He was once a known gambler with a gambling addiction that he kept hidden from his wife, causing her to be jealous but not aware of it.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "She was once the CEO of her own tech startup, but it collapsed under financial mismanagement. Her personal life is less glamorous, but she hides this from everyone to maintain her reputation.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "He had a gambling addiction that he never disclosed, leading to financial ruin and the bankruptcy of several companies he invested in.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "She was involved in a blackmail scheme involving false accusations and threats to ruin Lena's business career.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Snagged thread",
|
| 474 |
+
"reveal_text": "A torn thread of dark cloth snagged on the living_room doorframe.",
|
| 475 |
+
"discoverable_at_loc_id": "L1",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S3",
|
| 479 |
+
"contradicts_alibi_of": "S3",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Partial fingerprint",
|
| 486 |
+
"reveal_text": "A fresh partial fingerprint on the weapon, unaccounted for among the guests.",
|
| 487 |
+
"discoverable_at_loc_id": "L2",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S3",
|
| 491 |
+
"contradicts_alibi_of": "S3",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h1",
|
| 497 |
+
"name": "Rusty letter opener with the name 'Lena' engraved on the blade",
|
| 498 |
+
"reveal_text": "The letter opener was found in a drawer of her study, an old love letter that she kept for years and never showed anyone.",
|
| 499 |
+
"discoverable_at_loc_id": "L3",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec1",
|
| 502 |
+
"points_to_sus_id": "S1",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h2",
|
| 509 |
+
"name": "Ticket stub",
|
| 510 |
+
"reveal_text": "A ticket stub placing them somewhere they had denied being.",
|
| 511 |
+
"discoverable_at_loc_id": "L4",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec2",
|
| 514 |
+
"points_to_sus_id": "S2",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h4",
|
| 521 |
+
"name": "Hidden receipt",
|
| 522 |
+
"reveal_text": "A receipt for something they swore they never bought.",
|
| 523 |
+
"discoverable_at_loc_id": "L2",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec4",
|
| 526 |
+
"points_to_sus_id": "S4",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S3",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"The fingerprints on a dropped knife in the living room suggest Jack was present during the blackmailing incident.",
|
| 541 |
+
"The presence of a monogrammed tea cup in the vicinity points to Lena's direct involvement or knowledge of the setup.",
|
| 542 |
+
"This evidence, combined with the alibi claim that Lena was in the kitchen, provides strong circumstantial evidence pointing towards revenge as the motive."
|
| 543 |
+
]
|
| 544 |
+
}
|
| 545 |
+
}
|
cases/prebaked/CASE-0008.json
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0008",
|
| 3 |
+
"seed": 51006,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Blazing Oven",
|
| 6 |
+
"briefing": "A mysterious fire has left the antique oven at the bakery burning, leaving its owner unable to bake bread for a week. The victim was found inside the oven, suffering from burns on her hands and face.",
|
| 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 |
+
"crime_kind": "arson"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "arson",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "Antique Bakery",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "Oven Room",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "Bakery Main Hall",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "Storage Area",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "Kitchen",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Eliza Johnson",
|
| 65 |
+
"role": "Owner and baker",
|
| 66 |
+
"found_at_loc_id": "L3",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "Burns from the fire",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "",
|
| 77 |
+
"kind": "",
|
| 78 |
+
"origin_loc_id": "L3",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "Henry Brown",
|
| 86 |
+
"role": "Employee (Bakery Worker)",
|
| 87 |
+
"persona_summary": "A gruff, short-tempered employee who dislikes Eliza for her constant complaints about the low pay and working conditions.",
|
| 88 |
+
"demeanour": "cool and self-assured, almost amused by the questioning",
|
| 89 |
+
"is_culprit": false,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.88,
|
| 96 |
+
"aggression": 0.3,
|
| 97 |
+
"evasiveness": 0.35
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"He was just checking on Eliza’s baking; no reason to be worried about fires."
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_sec1"
|
| 104 |
+
],
|
| 105 |
+
"secrets": [
|
| 106 |
+
"He has a hidden stash of kerosene in his locker, believing it’s needed for occasional cleaning but hiding it from Eliza. His secret is that he also uses it to start fires when she isn’t looking."
|
| 107 |
+
],
|
| 108 |
+
"true_whereabouts": [
|
| 109 |
+
{
|
| 110 |
+
"window": {
|
| 111 |
+
"start_min": 1260,
|
| 112 |
+
"end_min": 1320
|
| 113 |
+
},
|
| 114 |
+
"loc_id": "L1",
|
| 115 |
+
"activity": "going about the evening",
|
| 116 |
+
"co_present_sus_ids": []
|
| 117 |
+
}
|
| 118 |
+
],
|
| 119 |
+
"stated_alibi": {
|
| 120 |
+
"claim_text": "I was in Oven Room the whole time.",
|
| 121 |
+
"claimed_segments": [
|
| 122 |
+
{
|
| 123 |
+
"window": {
|
| 124 |
+
"start_min": 1260,
|
| 125 |
+
"end_min": 1320
|
| 126 |
+
},
|
| 127 |
+
"loc_id": "L1",
|
| 128 |
+
"witness_sus_ids": []
|
| 129 |
+
}
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
"must_lie_about": [
|
| 133 |
+
"F_sec1"
|
| 134 |
+
],
|
| 135 |
+
"anchored_lies": [
|
| 136 |
+
{
|
| 137 |
+
"lie_id": "LIE_sec1",
|
| 138 |
+
"topic": "He has a hidden stash of kerosene in his locker,",
|
| 139 |
+
"claimed": "He was just checking on the oven to ensure it reached the perfect temperature; nothing more malicious.",
|
| 140 |
+
"truth_ref": "F_sec1",
|
| 141 |
+
"breaks_on": [
|
| 142 |
+
"C_h1"
|
| 143 |
+
],
|
| 144 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the fire."
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
"voice": null,
|
| 148 |
+
"visual": {
|
| 149 |
+
"subject_type": "suspect",
|
| 150 |
+
"palette": "noir",
|
| 151 |
+
"gender": "male",
|
| 152 |
+
"age_band": "30s",
|
| 153 |
+
"build": null,
|
| 154 |
+
"hair": null,
|
| 155 |
+
"attire": "Worn work clothes, dirty hands from the floor.",
|
| 156 |
+
"mood": "tense",
|
| 157 |
+
"accent_color": "#b8860b",
|
| 158 |
+
"location_tags": [],
|
| 159 |
+
"prop_tags": [],
|
| 160 |
+
"prompt_hint": "Short, stocky with a thick beard and a habit of smoking while working. Wears a tattered apron that matches his unkempt appearance., Worn work clothes, dirty hands from the floor."
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"sus_id": "S2",
|
| 165 |
+
"name": "Molly Thompson",
|
| 166 |
+
"role": "Customer (Regular Shopper)",
|
| 167 |
+
"persona_summary": "An outgoing, friendly shop assistant who often has a few too many drinks before her shift. She tends to leave early when the business is slow but never stays long enough for the fire to start.",
|
| 168 |
+
"demeanour": "visibly frightened and on edge, dreading every question",
|
| 169 |
+
"is_culprit": false,
|
| 170 |
+
"physical_capability": {
|
| 171 |
+
"strength": true,
|
| 172 |
+
"mobility": true
|
| 173 |
+
},
|
| 174 |
+
"personality": {
|
| 175 |
+
"composure": 0.2,
|
| 176 |
+
"aggression": 0.3,
|
| 177 |
+
"evasiveness": 0.7
|
| 178 |
+
},
|
| 179 |
+
"tells": [
|
| 180 |
+
"She saw Eliza’s rival cleaning things in the storage area after closing; no reason for a fire to start."
|
| 181 |
+
],
|
| 182 |
+
"knows_facts": [
|
| 183 |
+
"F_sec2"
|
| 184 |
+
],
|
| 185 |
+
"secrets": [
|
| 186 |
+
"She remembers seeing someone in the back storage area after closing time one night. She believes it was Eliza's rival, claiming she left to help clean up a spill that happened during her shift."
|
| 187 |
+
],
|
| 188 |
+
"true_whereabouts": [
|
| 189 |
+
{
|
| 190 |
+
"window": {
|
| 191 |
+
"start_min": 1260,
|
| 192 |
+
"end_min": 1320
|
| 193 |
+
},
|
| 194 |
+
"loc_id": "L2",
|
| 195 |
+
"activity": "going about the evening",
|
| 196 |
+
"co_present_sus_ids": []
|
| 197 |
+
}
|
| 198 |
+
],
|
| 199 |
+
"stated_alibi": {
|
| 200 |
+
"claim_text": "I was in Bakery Main Hall the whole time.",
|
| 201 |
+
"claimed_segments": [
|
| 202 |
+
{
|
| 203 |
+
"window": {
|
| 204 |
+
"start_min": 1260,
|
| 205 |
+
"end_min": 1320
|
| 206 |
+
},
|
| 207 |
+
"loc_id": "L2",
|
| 208 |
+
"witness_sus_ids": []
|
| 209 |
+
}
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
"must_lie_about": [
|
| 213 |
+
"F_sec2"
|
| 214 |
+
],
|
| 215 |
+
"anchored_lies": [
|
| 216 |
+
{
|
| 217 |
+
"lie_id": "LIE_sec2",
|
| 218 |
+
"topic": "She remembers seeing someone in the back storage",
|
| 219 |
+
"claimed": "She saw Eliza’s rival and went into the storage room to apologize for any messes he may have caused earlier.",
|
| 220 |
+
"truth_ref": "F_sec2",
|
| 221 |
+
"breaks_on": [
|
| 222 |
+
"C_h2"
|
| 223 |
+
],
|
| 224 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the fire."
|
| 225 |
+
}
|
| 226 |
+
],
|
| 227 |
+
"voice": null,
|
| 228 |
+
"visual": {
|
| 229 |
+
"subject_type": "suspect",
|
| 230 |
+
"palette": "noir",
|
| 231 |
+
"gender": "female",
|
| 232 |
+
"age_band": "20s",
|
| 233 |
+
"build": null,
|
| 234 |
+
"hair": null,
|
| 235 |
+
"attire": "Slightly disheveled attire, often seen in high heels and large bags.",
|
| 236 |
+
"mood": "tense",
|
| 237 |
+
"accent_color": "#3a6ea5",
|
| 238 |
+
"location_tags": [],
|
| 239 |
+
"prop_tags": [],
|
| 240 |
+
"prompt_hint": "Ruddy, energetic with a habit of wearing mismatched jewelry. Tends to drink heavily during her shifts but usually leaves before the business closes completely., Slightly disheveled attire, often seen in high heels and large bags."
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"sus_id": "S3",
|
| 245 |
+
"name": "Samuel Jenkins",
|
| 246 |
+
"role": "Delivery Boy (Bakery Delivery)",
|
| 247 |
+
"persona_summary": "A dependable, hardworking delivery boy who often makes multiple deliveries on one day. He tends to arrive late and get frustrated when the bakery is busy but remembers his duty to deliver.",
|
| 248 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 249 |
+
"is_culprit": true,
|
| 250 |
+
"physical_capability": {
|
| 251 |
+
"strength": true,
|
| 252 |
+
"mobility": true
|
| 253 |
+
},
|
| 254 |
+
"personality": {
|
| 255 |
+
"composure": 0.55,
|
| 256 |
+
"aggression": 0.88,
|
| 257 |
+
"evasiveness": 0.4
|
| 258 |
+
},
|
| 259 |
+
"tells": [
|
| 260 |
+
"He was just delivering to the bakery when Eliza asked if he could help with any deliveries; no reason for a fire."
|
| 261 |
+
],
|
| 262 |
+
"knows_facts": [
|
| 263 |
+
"F_scene",
|
| 264 |
+
"F_sec3"
|
| 265 |
+
],
|
| 266 |
+
"secrets": [
|
| 267 |
+
"He keeps a kerosene tin in the car for emergencies; he’s been using it to start fires instead of filling up the fuel tank, believing it will come in handy someday."
|
| 268 |
+
],
|
| 269 |
+
"true_whereabouts": [
|
| 270 |
+
{
|
| 271 |
+
"window": {
|
| 272 |
+
"start_min": 1260,
|
| 273 |
+
"end_min": 1280
|
| 274 |
+
},
|
| 275 |
+
"loc_id": "L4",
|
| 276 |
+
"activity": "mingling in plain sight",
|
| 277 |
+
"co_present_sus_ids": []
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"window": {
|
| 281 |
+
"start_min": 1280,
|
| 282 |
+
"end_min": 1310
|
| 283 |
+
},
|
| 284 |
+
"loc_id": "L3",
|
| 285 |
+
"activity": "at the scene of the fire",
|
| 286 |
+
"co_present_sus_ids": []
|
| 287 |
+
}
|
| 288 |
+
],
|
| 289 |
+
"stated_alibi": {
|
| 290 |
+
"claim_text": "I was in the Kitchen all night long.",
|
| 291 |
+
"claimed_segments": [
|
| 292 |
+
{
|
| 293 |
+
"window": {
|
| 294 |
+
"start_min": 1260,
|
| 295 |
+
"end_min": 1320
|
| 296 |
+
},
|
| 297 |
+
"loc_id": "L4",
|
| 298 |
+
"witness_sus_ids": []
|
| 299 |
+
}
|
| 300 |
+
]
|
| 301 |
+
},
|
| 302 |
+
"must_lie_about": [
|
| 303 |
+
"F_scene"
|
| 304 |
+
],
|
| 305 |
+
"anchored_lies": [
|
| 306 |
+
{
|
| 307 |
+
"lie_id": "LIE_alibi",
|
| 308 |
+
"topic": "where you were during the fire",
|
| 309 |
+
"claimed": "I was in the Kitchen all night long.",
|
| 310 |
+
"truth_ref": "F_scene",
|
| 311 |
+
"breaks_on": [
|
| 312 |
+
"C_b1",
|
| 313 |
+
"C_b2"
|
| 314 |
+
],
|
| 315 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"voice": null,
|
| 319 |
+
"visual": {
|
| 320 |
+
"subject_type": "suspect",
|
| 321 |
+
"palette": "noir",
|
| 322 |
+
"gender": "male",
|
| 323 |
+
"age_band": "30s",
|
| 324 |
+
"build": null,
|
| 325 |
+
"hair": null,
|
| 326 |
+
"attire": "Dressy but not fancy; always on time to avoid Eliza’s complaints about being late.",
|
| 327 |
+
"mood": "guarded",
|
| 328 |
+
"accent_color": "#9a9aa0",
|
| 329 |
+
"location_tags": [],
|
| 330 |
+
"prop_tags": [],
|
| 331 |
+
"prompt_hint": "Tall, slender with a regular job but occasionally seen late at night. Wears casual clothes and sometimes carries small boxes for deliveries., Dressy but not fancy; always on time to avoid Eliza’s complaints about being late."
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sus_id": "S4",
|
| 336 |
+
"name": "Tommy Davis",
|
| 337 |
+
"role": "Freelance Cleaner (Bakery Maintenance)",
|
| 338 |
+
"persona_summary": "A reliable, trustworthy cleaner who keeps the bakery’s kitchen and bathrooms well-maintained. He tends to be very serious about his job but never stays long enough for a fire to start.",
|
| 339 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 340 |
+
"is_culprit": false,
|
| 341 |
+
"physical_capability": {
|
| 342 |
+
"strength": true,
|
| 343 |
+
"mobility": true
|
| 344 |
+
},
|
| 345 |
+
"personality": {
|
| 346 |
+
"composure": 0.72,
|
| 347 |
+
"aggression": 0.4,
|
| 348 |
+
"evasiveness": 0.3
|
| 349 |
+
},
|
| 350 |
+
"tells": [
|
| 351 |
+
"He is just checking on the fire safety and cleanliness; no reason for a fire to start."
|
| 352 |
+
],
|
| 353 |
+
"knows_facts": [
|
| 354 |
+
"F_sec4"
|
| 355 |
+
],
|
| 356 |
+
"secrets": [
|
| 357 |
+
"He often slips kerosene into the fuel tank of his cleaning equipment; he has been doing this since he was a teenager, believing it will come in handy if something goes wrong with the housekeeping."
|
| 358 |
+
],
|
| 359 |
+
"true_whereabouts": [
|
| 360 |
+
{
|
| 361 |
+
"window": {
|
| 362 |
+
"start_min": 1260,
|
| 363 |
+
"end_min": 1320
|
| 364 |
+
},
|
| 365 |
+
"loc_id": "L1",
|
| 366 |
+
"activity": "going about the evening",
|
| 367 |
+
"co_present_sus_ids": []
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"stated_alibi": {
|
| 371 |
+
"claim_text": "I was in Oven Room the whole time.",
|
| 372 |
+
"claimed_segments": [
|
| 373 |
+
{
|
| 374 |
+
"window": {
|
| 375 |
+
"start_min": 1260,
|
| 376 |
+
"end_min": 1320
|
| 377 |
+
},
|
| 378 |
+
"loc_id": "L1",
|
| 379 |
+
"witness_sus_ids": []
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"must_lie_about": [
|
| 384 |
+
"F_sec4"
|
| 385 |
+
],
|
| 386 |
+
"anchored_lies": [
|
| 387 |
+
{
|
| 388 |
+
"lie_id": "LIE_sec4",
|
| 389 |
+
"topic": "He often slips kerosene into the fuel tank of hi",
|
| 390 |
+
"claimed": "He is just checking on the bakery’s cleanliness and fire safety protocols. No reason to worry about any fires.",
|
| 391 |
+
"truth_ref": "F_sec4",
|
| 392 |
+
"breaks_on": [
|
| 393 |
+
"C_h4"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the fire."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "male",
|
| 403 |
+
"age_band": "50s",
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": "Dusty but well-kept attire, always neat even when cleaning in the bakery's dark corners.",
|
| 407 |
+
"mood": "tense",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "Regularly seen in his uniform, clean and tidy with a clipboard for records. Wears glasses and often takes notes during inspections., Dusty but well-kept attire, always neat even when cleaning in the bakery's dark corners."
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S3",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "The arsonist set the fire in Eliza Johnson's Storage Area to punish her for her infidelity and betrayal."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "Samuel Jenkins, who was known as Delivery Boy at a bakery, carried out the arson using a stolen lighter. He fired the lighter into the storage area just after closing time, when it was dark and quiet.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L4",
|
| 425 |
+
"actual_loc_id": "L3",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "Samuel Jenkins was in Storage Area during the fire.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L3",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "He has a hidden stash of kerosene in his locker, believing it’s needed for occasional cleaning but hiding it from Eliza. His secret is that he also uses it to start fires when she isn’t looking.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "She remembers seeing someone in the back storage area after closing time one night. She believes it was Eliza's rival, claiming she left to help clean up a spill that happened during her shift.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "He keeps a kerosene tin in the car for emergencies; he’s been using it to start fires instead of filling up the fuel tank, believing it will come in handy someday.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "He often slips kerosene into the fuel tank of his cleaning equipment; he has been doing this since he was a teenager, believing it will come in handy if something goes wrong with the housekeeping.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Stubbed cigarette",
|
| 474 |
+
"reveal_text": "A cigarette stubbed out mid-smoke - and not the victim's brand.",
|
| 475 |
+
"discoverable_at_loc_id": "L3",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S3",
|
| 479 |
+
"contradicts_alibi_of": "S3",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Partial fingerprint",
|
| 486 |
+
"reveal_text": "A fresh partial fingerprint on the weapon, unaccounted for among the guests.",
|
| 487 |
+
"discoverable_at_loc_id": "L1",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S3",
|
| 491 |
+
"contradicts_alibi_of": "S3",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h1",
|
| 497 |
+
"name": "Old photograph",
|
| 498 |
+
"reveal_text": "A worn photograph they would rather no one had seen.",
|
| 499 |
+
"discoverable_at_loc_id": "L2",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec1",
|
| 502 |
+
"points_to_sus_id": "S1",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h2",
|
| 509 |
+
"name": "Pawn ticket",
|
| 510 |
+
"reveal_text": "A pawn ticket for a family heirloom, recently surrendered.",
|
| 511 |
+
"discoverable_at_loc_id": "L4",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec2",
|
| 514 |
+
"points_to_sus_id": "S2",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h4",
|
| 521 |
+
"name": "Crumpled letter",
|
| 522 |
+
"reveal_text": "A crumpled letter hinting at a debt they never mentioned.",
|
| 523 |
+
"discoverable_at_loc_id": "L1",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec4",
|
| 526 |
+
"points_to_sus_id": "S4",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S3",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"The alibi of 'in the Kitchen' is highly suspicious because it's a common place for people to be seen late at night.",
|
| 541 |
+
"Close inspection reveals that the fingerprints on a torn piece of paper found in the Storage Area match Samuel Jenkins’ prints.",
|
| 542 |
+
"A stopped clock was right twice a day, but it matches the time when Samuel left the Kitchen. The dropped object near the fire source points to his involvement."
|
| 543 |
+
]
|
| 544 |
+
}
|
| 545 |
+
}
|
cases/prebaked/CASE-0009.json
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0009",
|
| 3 |
+
"seed": 51008,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Phantom's Room",
|
| 6 |
+
"briefing": "A man vanished from the library, never to be seen again.",
|
| 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 |
+
"crime_kind": "missing_person"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "missing_person",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "Old Town Hall Library",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "Front Entrance",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "Reader's Area",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "Study Room",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "Offices",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Ethan Thompson",
|
| 65 |
+
"role": "Librarian",
|
| 66 |
+
"found_at_loc_id": "L3",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "Never found, presumed dead by the staff",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "",
|
| 77 |
+
"kind": "",
|
| 78 |
+
"origin_loc_id": "L3",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "Marianne Taylor-Wilson",
|
| 86 |
+
"role": "Former Partner",
|
| 87 |
+
"persona_summary": "A charming and popular colleague who was often seen flirting with Ethan.",
|
| 88 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 89 |
+
"is_culprit": true,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.55,
|
| 96 |
+
"aggression": 0.88,
|
| 97 |
+
"evasiveness": 0.4
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"She is quite reserved, often wearing a small pendant around her neck as a constant reminder of her past."
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_scene",
|
| 104 |
+
"F_sec1"
|
| 105 |
+
],
|
| 106 |
+
"secrets": [
|
| 107 |
+
"Married to a deceased husband, she is ashamed of her past relationship."
|
| 108 |
+
],
|
| 109 |
+
"true_whereabouts": [
|
| 110 |
+
{
|
| 111 |
+
"window": {
|
| 112 |
+
"start_min": 1260,
|
| 113 |
+
"end_min": 1280
|
| 114 |
+
},
|
| 115 |
+
"loc_id": "L4",
|
| 116 |
+
"activity": "mingling in plain sight",
|
| 117 |
+
"co_present_sus_ids": []
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"window": {
|
| 121 |
+
"start_min": 1280,
|
| 122 |
+
"end_min": 1310
|
| 123 |
+
},
|
| 124 |
+
"loc_id": "L3",
|
| 125 |
+
"activity": "at the scene of the disappearance",
|
| 126 |
+
"co_present_sus_ids": []
|
| 127 |
+
}
|
| 128 |
+
],
|
| 129 |
+
"stated_alibi": {
|
| 130 |
+
"claim_text": "They claim they were in the Offices during Ethan's disappearance.",
|
| 131 |
+
"claimed_segments": [
|
| 132 |
+
{
|
| 133 |
+
"window": {
|
| 134 |
+
"start_min": 1260,
|
| 135 |
+
"end_min": 1320
|
| 136 |
+
},
|
| 137 |
+
"loc_id": "L4",
|
| 138 |
+
"witness_sus_ids": []
|
| 139 |
+
}
|
| 140 |
+
]
|
| 141 |
+
},
|
| 142 |
+
"must_lie_about": [
|
| 143 |
+
"F_scene"
|
| 144 |
+
],
|
| 145 |
+
"anchored_lies": [
|
| 146 |
+
{
|
| 147 |
+
"lie_id": "LIE_alibi",
|
| 148 |
+
"topic": "where you were during the disappearance",
|
| 149 |
+
"claimed": "They claim they were in the Offices during Ethan's disappearance.",
|
| 150 |
+
"truth_ref": "F_scene",
|
| 151 |
+
"breaks_on": [
|
| 152 |
+
"C_b1",
|
| 153 |
+
"C_b2"
|
| 154 |
+
],
|
| 155 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 156 |
+
}
|
| 157 |
+
],
|
| 158 |
+
"voice": null,
|
| 159 |
+
"visual": {
|
| 160 |
+
"subject_type": "suspect",
|
| 161 |
+
"palette": "noir",
|
| 162 |
+
"gender": "female",
|
| 163 |
+
"age_band": "30-40s",
|
| 164 |
+
"build": null,
|
| 165 |
+
"hair": null,
|
| 166 |
+
"attire": "Librarian uniform including pearl chokers and matching jewelry",
|
| 167 |
+
"mood": "guarded",
|
| 168 |
+
"accent_color": "#b8860b",
|
| 169 |
+
"location_tags": [],
|
| 170 |
+
"prop_tags": [],
|
| 171 |
+
"prompt_hint": "In her 30s, wearing traditional librarian dress with pearls in her hair, Librarian uniform including pearl chokers and matching jewelry"
|
| 172 |
+
}
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"sus_id": "S2",
|
| 176 |
+
"name": "David Johnson",
|
| 177 |
+
"role": "Rival Librarian",
|
| 178 |
+
"persona_summary": "Competitive and slightly aggressive towards Ethan.",
|
| 179 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 180 |
+
"is_culprit": false,
|
| 181 |
+
"physical_capability": {
|
| 182 |
+
"strength": true,
|
| 183 |
+
"mobility": true
|
| 184 |
+
},
|
| 185 |
+
"personality": {
|
| 186 |
+
"composure": 0.72,
|
| 187 |
+
"aggression": 0.4,
|
| 188 |
+
"evasiveness": 0.3
|
| 189 |
+
},
|
| 190 |
+
"tells": [
|
| 191 |
+
"He is highly aggressive, often speaking in a loud whisper to hide his secrets."
|
| 192 |
+
],
|
| 193 |
+
"knows_facts": [
|
| 194 |
+
"F_sec2"
|
| 195 |
+
],
|
| 196 |
+
"secrets": [
|
| 197 |
+
"He had been hired to sabotage the library, but was tipped off by Marianne about Ethan’s position there."
|
| 198 |
+
],
|
| 199 |
+
"true_whereabouts": [
|
| 200 |
+
{
|
| 201 |
+
"window": {
|
| 202 |
+
"start_min": 1260,
|
| 203 |
+
"end_min": 1320
|
| 204 |
+
},
|
| 205 |
+
"loc_id": "L2",
|
| 206 |
+
"activity": "going about the evening",
|
| 207 |
+
"co_present_sus_ids": []
|
| 208 |
+
}
|
| 209 |
+
],
|
| 210 |
+
"stated_alibi": {
|
| 211 |
+
"claim_text": "I was in Reader's Area the whole time.",
|
| 212 |
+
"claimed_segments": [
|
| 213 |
+
{
|
| 214 |
+
"window": {
|
| 215 |
+
"start_min": 1260,
|
| 216 |
+
"end_min": 1320
|
| 217 |
+
},
|
| 218 |
+
"loc_id": "L2",
|
| 219 |
+
"witness_sus_ids": []
|
| 220 |
+
}
|
| 221 |
+
]
|
| 222 |
+
},
|
| 223 |
+
"must_lie_about": [
|
| 224 |
+
"F_sec2"
|
| 225 |
+
],
|
| 226 |
+
"anchored_lies": [
|
| 227 |
+
{
|
| 228 |
+
"lie_id": "LIE_sec2",
|
| 229 |
+
"topic": "He had been hired to sabotage the library, but w",
|
| 230 |
+
"claimed": "He claims he came here for a research session on book reviews.",
|
| 231 |
+
"truth_ref": "F_sec2",
|
| 232 |
+
"breaks_on": [
|
| 233 |
+
"C_h2"
|
| 234 |
+
],
|
| 235 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the disappearance."
|
| 236 |
+
}
|
| 237 |
+
],
|
| 238 |
+
"voice": null,
|
| 239 |
+
"visual": {
|
| 240 |
+
"subject_type": "suspect",
|
| 241 |
+
"palette": "noir",
|
| 242 |
+
"gender": "male",
|
| 243 |
+
"age_band": "40-50s",
|
| 244 |
+
"build": null,
|
| 245 |
+
"hair": null,
|
| 246 |
+
"attire": "Librarian’s uniform including a badge on the chest and matching shoes",
|
| 247 |
+
"mood": "guarded",
|
| 248 |
+
"accent_color": "#3a6ea5",
|
| 249 |
+
"location_tags": [],
|
| 250 |
+
"prop_tags": [],
|
| 251 |
+
"prompt_hint": "In his 50s, dressed in an official librarian's uniform but with a badge of suspicion pinned to it, Librarian’s uniform including a badge on the chest and matching shoes"
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"sus_id": "S3",
|
| 256 |
+
"name": "Ella Hart",
|
| 257 |
+
"role": "Acquaintance of Ethan’s Family",
|
| 258 |
+
"persona_summary": "A close family friend who knew about Ethan's involvement.",
|
| 259 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 260 |
+
"is_culprit": false,
|
| 261 |
+
"physical_capability": {
|
| 262 |
+
"strength": true,
|
| 263 |
+
"mobility": true
|
| 264 |
+
},
|
| 265 |
+
"personality": {
|
| 266 |
+
"composure": 0.32,
|
| 267 |
+
"aggression": 0.55,
|
| 268 |
+
"evasiveness": 0.68
|
| 269 |
+
},
|
| 270 |
+
"tells": [
|
| 271 |
+
"She is quite calm and composed, often referring to the library as a place where she can play music without disturbing others."
|
| 272 |
+
],
|
| 273 |
+
"knows_facts": [
|
| 274 |
+
"F_sec3"
|
| 275 |
+
],
|
| 276 |
+
"secrets": [
|
| 277 |
+
"She was jealous of the bond between Ethan and her nephew, leading her to tell false information about his job status."
|
| 278 |
+
],
|
| 279 |
+
"true_whereabouts": [
|
| 280 |
+
{
|
| 281 |
+
"window": {
|
| 282 |
+
"start_min": 1260,
|
| 283 |
+
"end_min": 1320
|
| 284 |
+
},
|
| 285 |
+
"loc_id": "L4",
|
| 286 |
+
"activity": "going about the evening",
|
| 287 |
+
"co_present_sus_ids": []
|
| 288 |
+
}
|
| 289 |
+
],
|
| 290 |
+
"stated_alibi": {
|
| 291 |
+
"claim_text": "I was in Offices the whole time.",
|
| 292 |
+
"claimed_segments": [
|
| 293 |
+
{
|
| 294 |
+
"window": {
|
| 295 |
+
"start_min": 1260,
|
| 296 |
+
"end_min": 1320
|
| 297 |
+
},
|
| 298 |
+
"loc_id": "L4",
|
| 299 |
+
"witness_sus_ids": []
|
| 300 |
+
}
|
| 301 |
+
]
|
| 302 |
+
},
|
| 303 |
+
"must_lie_about": [
|
| 304 |
+
"F_sec3"
|
| 305 |
+
],
|
| 306 |
+
"anchored_lies": [
|
| 307 |
+
{
|
| 308 |
+
"lie_id": "LIE_sec3",
|
| 309 |
+
"topic": "She was jealous of the bond between Ethan and he",
|
| 310 |
+
"claimed": "They met at an annual family reunion where Ethan was introduced as a potential librarian for their local library.",
|
| 311 |
+
"truth_ref": "F_sec3",
|
| 312 |
+
"breaks_on": [
|
| 313 |
+
"C_h3"
|
| 314 |
+
],
|
| 315 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the disappearance."
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"voice": null,
|
| 319 |
+
"visual": {
|
| 320 |
+
"subject_type": "suspect",
|
| 321 |
+
"palette": "noir",
|
| 322 |
+
"gender": "female",
|
| 323 |
+
"age_band": "20-30s",
|
| 324 |
+
"build": null,
|
| 325 |
+
"hair": null,
|
| 326 |
+
"attire": "Casual clothing including a blouse and slacks, often seen carrying an old-fashioned keyboard nearby her.",
|
| 327 |
+
"mood": "guarded",
|
| 328 |
+
"accent_color": "#9a9aa0",
|
| 329 |
+
"location_tags": [],
|
| 330 |
+
"prop_tags": [],
|
| 331 |
+
"prompt_hint": "In her 20s, wearing casual clothes with long hair tied in a ponytail, often seen playing the piano by the reception desk, Casual clothing including a blouse and slacks, often seen carrying an old-fashioned keyboard nearby her."
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sus_id": "S4",
|
| 336 |
+
"name": "James Brown",
|
| 337 |
+
"role": "Secret Lover of Ethan's",
|
| 338 |
+
"persona_summary": "A close friend who was always present during Ethan’s meetings.",
|
| 339 |
+
"demeanour": "guarded and weary, giving away as little as possible",
|
| 340 |
+
"is_culprit": false,
|
| 341 |
+
"physical_capability": {
|
| 342 |
+
"strength": true,
|
| 343 |
+
"mobility": true
|
| 344 |
+
},
|
| 345 |
+
"personality": {
|
| 346 |
+
"composure": 0.6,
|
| 347 |
+
"aggression": 0.25,
|
| 348 |
+
"evasiveness": 0.55
|
| 349 |
+
},
|
| 350 |
+
"tells": [
|
| 351 |
+
"He is very aggressive, often talking in high-pitched whispers to hide his secret."
|
| 352 |
+
],
|
| 353 |
+
"knows_facts": [
|
| 354 |
+
"F_sec4"
|
| 355 |
+
],
|
| 356 |
+
"secrets": [
|
| 357 |
+
"He had been secretly dating Ethan and wanted to warn him, but was scared of the consequences."
|
| 358 |
+
],
|
| 359 |
+
"true_whereabouts": [
|
| 360 |
+
{
|
| 361 |
+
"window": {
|
| 362 |
+
"start_min": 1260,
|
| 363 |
+
"end_min": 1320
|
| 364 |
+
},
|
| 365 |
+
"loc_id": "L1",
|
| 366 |
+
"activity": "going about the evening",
|
| 367 |
+
"co_present_sus_ids": []
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"stated_alibi": {
|
| 371 |
+
"claim_text": "I was in Front Entrance the whole time.",
|
| 372 |
+
"claimed_segments": [
|
| 373 |
+
{
|
| 374 |
+
"window": {
|
| 375 |
+
"start_min": 1260,
|
| 376 |
+
"end_min": 1320
|
| 377 |
+
},
|
| 378 |
+
"loc_id": "L1",
|
| 379 |
+
"witness_sus_ids": []
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"must_lie_about": [
|
| 384 |
+
"F_sec4"
|
| 385 |
+
],
|
| 386 |
+
"anchored_lies": [
|
| 387 |
+
{
|
| 388 |
+
"lie_id": "LIE_sec4",
|
| 389 |
+
"topic": "He had been secretly dating Ethan and wanted to ",
|
| 390 |
+
"claimed": "They met in a park by chance, and he offered to be his confidant about work-related issues.",
|
| 391 |
+
"truth_ref": "F_sec4",
|
| 392 |
+
"breaks_on": [
|
| 393 |
+
"C_h4"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the disappearance."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "male",
|
| 403 |
+
"age_band": "30-40s",
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": "Casual attire including glasses and a book bag nearby the table where they usually meet",
|
| 407 |
+
"mood": "guarded",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "In his 30s, dressed casually with glasses on top of his head as usual, often seen holding a notebook next to Ethan's desk, Casual attire including glasses and a book bag nearby the table where they usually meet"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S1",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "Marianne Taylor-Wilson and her accomplice are seeking revenge against Ethan Thompson for a perceived betrayal or slight."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "The abductors used the time between 21:00 and 22:00 to lure Ethan from the Study Room using an elaborate scheme that included lying about leaving the Offices. They then used false alibis, claiming they were in the Offices when he was last seen.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L4",
|
| 425 |
+
"actual_loc_id": "L3",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "Marianne Taylor-Wilson was in Study Room during the disappearance.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L3",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "Married to a deceased husband, she is ashamed of her past relationship.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "He had been hired to sabotage the library, but was tipped off by Marianne about Ethan’s position there.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "She was jealous of the bond between Ethan and her nephew, leading her to tell false information about his job status.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "He had been secretly dating Ethan and wanted to warn him, but was scared of the consequences.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Partial fingerprint",
|
| 474 |
+
"reveal_text": "A fresh partial fingerprint on the weapon, unaccounted for among the guests.",
|
| 475 |
+
"discoverable_at_loc_id": "L3",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S1",
|
| 479 |
+
"contradicts_alibi_of": "S1",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Smudged tumbler",
|
| 486 |
+
"reveal_text": "A tumbler abandoned at the scene, its rim marked with a recent lip-print.",
|
| 487 |
+
"discoverable_at_loc_id": "L1",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S1",
|
| 491 |
+
"contradicts_alibi_of": "S1",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h2",
|
| 497 |
+
"name": "Crumpled letter",
|
| 498 |
+
"reveal_text": "A crumpled letter hinting at a debt they never mentioned.",
|
| 499 |
+
"discoverable_at_loc_id": "L2",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec2",
|
| 502 |
+
"points_to_sus_id": "S2",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h3",
|
| 509 |
+
"name": "Hidden receipt",
|
| 510 |
+
"reveal_text": "A receipt for something they swore they never bought.",
|
| 511 |
+
"discoverable_at_loc_id": "L4",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec3",
|
| 514 |
+
"points_to_sus_id": "S3",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h4",
|
| 521 |
+
"name": "Old photograph",
|
| 522 |
+
"reveal_text": "A worn photograph they would rather no one had seen.",
|
| 523 |
+
"discoverable_at_loc_id": "L1",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec4",
|
| 526 |
+
"points_to_sus_id": "S4",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S1",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"Marianne Taylor-Wilson was involved in an argument with Ethan Thompson that escalated into a personal vendetta.",
|
| 541 |
+
"The abductors meticulously planned Ethan's disappearance, using false alibis and timeframes to frame him for their own gain.",
|
| 542 |
+
"Ethan's disappearance was likely due to the timing of his last known location (the Study Room) coinciding with Marianne Taylor-Wilson's alleged activities in that area during a specific window."
|
| 543 |
+
]
|
| 544 |
+
}
|
| 545 |
+
}
|
cases/prebaked/CASE-0010.json
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0010",
|
| 3 |
+
"seed": 51010,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Paper Fortune",
|
| 6 |
+
"briefing": "A swindle went tragically wrong, leaving the victims with shattered trust and a haunting silence.",
|
| 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 |
+
"crime_kind": "fraud"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "fraud",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "Mansion of Ravenswood",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "Hall",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "Kitchen",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "Study",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "Master Bedroom",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Lena Thompson",
|
| 65 |
+
"role": "Trustee of Ravenswood Trust",
|
| 66 |
+
"found_at_loc_id": "L4",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "Found dead in her study, a note pinned to the window, hinting at betrayal.",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "Rusted antique pen nib with bloodstain on end grain",
|
| 77 |
+
"kind": "Instrument of murder",
|
| 78 |
+
"origin_loc_id": "L4",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "Evan Jenkins",
|
| 86 |
+
"role": "Business Partner and Trustee's Husband",
|
| 87 |
+
"persona_summary": "Evan is the loving husband, but his secret obsession with control has poisoned their relationship.",
|
| 88 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 89 |
+
"is_culprit": false,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.32,
|
| 96 |
+
"aggression": 0.55,
|
| 97 |
+
"evasiveness": 0.68
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"I've been telling you this all along, but now it's too late."
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_sec1"
|
| 104 |
+
],
|
| 105 |
+
"secrets": [
|
| 106 |
+
"He was once a professional forger who committed the swindle."
|
| 107 |
+
],
|
| 108 |
+
"true_whereabouts": [
|
| 109 |
+
{
|
| 110 |
+
"window": {
|
| 111 |
+
"start_min": 1260,
|
| 112 |
+
"end_min": 1320
|
| 113 |
+
},
|
| 114 |
+
"loc_id": "L1",
|
| 115 |
+
"activity": "going about the evening",
|
| 116 |
+
"co_present_sus_ids": []
|
| 117 |
+
}
|
| 118 |
+
],
|
| 119 |
+
"stated_alibi": {
|
| 120 |
+
"claim_text": "I was in Hall the whole time.",
|
| 121 |
+
"claimed_segments": [
|
| 122 |
+
{
|
| 123 |
+
"window": {
|
| 124 |
+
"start_min": 1260,
|
| 125 |
+
"end_min": 1320
|
| 126 |
+
},
|
| 127 |
+
"loc_id": "L1",
|
| 128 |
+
"witness_sus_ids": []
|
| 129 |
+
}
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
"must_lie_about": [
|
| 133 |
+
"F_sec1"
|
| 134 |
+
],
|
| 135 |
+
"anchored_lies": [
|
| 136 |
+
{
|
| 137 |
+
"lie_id": "LIE_sec1",
|
| 138 |
+
"topic": "He was once a professional forger who committed ",
|
| 139 |
+
"claimed": "I'm just trying to help you become more successful. Please trust me on this one!",
|
| 140 |
+
"truth_ref": "F_sec1",
|
| 141 |
+
"breaks_on": [
|
| 142 |
+
"C_h1"
|
| 143 |
+
],
|
| 144 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the fraud."
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
"voice": null,
|
| 148 |
+
"visual": {
|
| 149 |
+
"subject_type": "suspect",
|
| 150 |
+
"palette": "noir",
|
| 151 |
+
"gender": "male",
|
| 152 |
+
"age_band": "50s",
|
| 153 |
+
"build": null,
|
| 154 |
+
"hair": null,
|
| 155 |
+
"attire": "A pair of sharp trousers and a conservative shirt",
|
| 156 |
+
"mood": "guarded",
|
| 157 |
+
"accent_color": "#b8860b",
|
| 158 |
+
"location_tags": [],
|
| 159 |
+
"prop_tags": [],
|
| 160 |
+
"prompt_hint": "Casually dressed in suits, middle-aged with a stern expression, A pair of sharp trousers and a conservative shirt"
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"sus_id": "S2",
|
| 165 |
+
"name": "Sophie Johnson",
|
| 166 |
+
"role": "Lena's Sister-in-Law",
|
| 167 |
+
"persona_summary": "Sophie is the kind-hearted woman who believes in her niece's integrity.",
|
| 168 |
+
"demeanour": "guarded and weary, giving away as little as possible",
|
| 169 |
+
"is_culprit": false,
|
| 170 |
+
"physical_capability": {
|
| 171 |
+
"strength": true,
|
| 172 |
+
"mobility": true
|
| 173 |
+
},
|
| 174 |
+
"personality": {
|
| 175 |
+
"composure": 0.6,
|
| 176 |
+
"aggression": 0.25,
|
| 177 |
+
"evasiveness": 0.55
|
| 178 |
+
},
|
| 179 |
+
"tells": [
|
| 180 |
+
"I always trusted my niece, but I can't live with the guilt anymore."
|
| 181 |
+
],
|
| 182 |
+
"knows_facts": [
|
| 183 |
+
"F_sec2"
|
| 184 |
+
],
|
| 185 |
+
"secrets": [
|
| 186 |
+
"She was once a professional forger with no criminal record but lost everything due to this swindle."
|
| 187 |
+
],
|
| 188 |
+
"true_whereabouts": [
|
| 189 |
+
{
|
| 190 |
+
"window": {
|
| 191 |
+
"start_min": 1260,
|
| 192 |
+
"end_min": 1320
|
| 193 |
+
},
|
| 194 |
+
"loc_id": "L2",
|
| 195 |
+
"activity": "going about the evening",
|
| 196 |
+
"co_present_sus_ids": []
|
| 197 |
+
}
|
| 198 |
+
],
|
| 199 |
+
"stated_alibi": {
|
| 200 |
+
"claim_text": "I was in Kitchen the whole time.",
|
| 201 |
+
"claimed_segments": [
|
| 202 |
+
{
|
| 203 |
+
"window": {
|
| 204 |
+
"start_min": 1260,
|
| 205 |
+
"end_min": 1320
|
| 206 |
+
},
|
| 207 |
+
"loc_id": "L2",
|
| 208 |
+
"witness_sus_ids": []
|
| 209 |
+
}
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
"must_lie_about": [
|
| 213 |
+
"F_sec2"
|
| 214 |
+
],
|
| 215 |
+
"anchored_lies": [
|
| 216 |
+
{
|
| 217 |
+
"lie_id": "LIE_sec2",
|
| 218 |
+
"topic": "She was once a professional forger with no crimi",
|
| 219 |
+
"claimed": "I'm just worried about your welfare. I've never been a thief, trust me!",
|
| 220 |
+
"truth_ref": "F_sec2",
|
| 221 |
+
"breaks_on": [
|
| 222 |
+
"C_h2"
|
| 223 |
+
],
|
| 224 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the fraud."
|
| 225 |
+
}
|
| 226 |
+
],
|
| 227 |
+
"voice": null,
|
| 228 |
+
"visual": {
|
| 229 |
+
"subject_type": "suspect",
|
| 230 |
+
"palette": "noir",
|
| 231 |
+
"gender": "female",
|
| 232 |
+
"age_band": "30s",
|
| 233 |
+
"build": null,
|
| 234 |
+
"hair": null,
|
| 235 |
+
"attire": "A classic blazer and a soft skirt",
|
| 236 |
+
"mood": "guarded",
|
| 237 |
+
"accent_color": "#3a6ea5",
|
| 238 |
+
"location_tags": [],
|
| 239 |
+
"prop_tags": [],
|
| 240 |
+
"prompt_hint": "Soft-spoken, middle-aged with a kind expression, A classic blazer and a soft skirt"
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"sus_id": "S3",
|
| 245 |
+
"name": "Mark Davis",
|
| 246 |
+
"role": "Lena's Personal Chef",
|
| 247 |
+
"persona_summary": "Mark is the devoted servant who has been secretly working alongside his employer for years.",
|
| 248 |
+
"demeanour": "cool and self-assured, almost amused by the questioning",
|
| 249 |
+
"is_culprit": true,
|
| 250 |
+
"physical_capability": {
|
| 251 |
+
"strength": true,
|
| 252 |
+
"mobility": true
|
| 253 |
+
},
|
| 254 |
+
"personality": {
|
| 255 |
+
"composure": 0.88,
|
| 256 |
+
"aggression": 0.3,
|
| 257 |
+
"evasiveness": 0.35
|
| 258 |
+
},
|
| 259 |
+
"tells": [
|
| 260 |
+
"I've been doing this for years, I'm just now revealing the truth."
|
| 261 |
+
],
|
| 262 |
+
"knows_facts": [
|
| 263 |
+
"F_scene",
|
| 264 |
+
"F_sec3"
|
| 265 |
+
],
|
| 266 |
+
"secrets": [
|
| 267 |
+
"He was a professional forger hired by the swindler to deliver forged documents."
|
| 268 |
+
],
|
| 269 |
+
"true_whereabouts": [
|
| 270 |
+
{
|
| 271 |
+
"window": {
|
| 272 |
+
"start_min": 1260,
|
| 273 |
+
"end_min": 1280
|
| 274 |
+
},
|
| 275 |
+
"loc_id": "L1",
|
| 276 |
+
"activity": "mingling in plain sight",
|
| 277 |
+
"co_present_sus_ids": []
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"window": {
|
| 281 |
+
"start_min": 1280,
|
| 282 |
+
"end_min": 1310
|
| 283 |
+
},
|
| 284 |
+
"loc_id": "L4",
|
| 285 |
+
"activity": "at the scene of the fraud",
|
| 286 |
+
"co_present_sus_ids": []
|
| 287 |
+
}
|
| 288 |
+
],
|
| 289 |
+
"stated_alibi": {
|
| 290 |
+
"claim_text": "I was in the Hall at all times.",
|
| 291 |
+
"claimed_segments": [
|
| 292 |
+
{
|
| 293 |
+
"window": {
|
| 294 |
+
"start_min": 1260,
|
| 295 |
+
"end_min": 1320
|
| 296 |
+
},
|
| 297 |
+
"loc_id": "L1",
|
| 298 |
+
"witness_sus_ids": []
|
| 299 |
+
}
|
| 300 |
+
]
|
| 301 |
+
},
|
| 302 |
+
"must_lie_about": [
|
| 303 |
+
"F_scene"
|
| 304 |
+
],
|
| 305 |
+
"anchored_lies": [
|
| 306 |
+
{
|
| 307 |
+
"lie_id": "LIE_alibi",
|
| 308 |
+
"topic": "where you were during the fraud",
|
| 309 |
+
"claimed": "I was in the Hall at all times.",
|
| 310 |
+
"truth_ref": "F_scene",
|
| 311 |
+
"breaks_on": [
|
| 312 |
+
"C_b1",
|
| 313 |
+
"C_b2"
|
| 314 |
+
],
|
| 315 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"voice": null,
|
| 319 |
+
"visual": {
|
| 320 |
+
"subject_type": "suspect",
|
| 321 |
+
"palette": "noir",
|
| 322 |
+
"gender": "male",
|
| 323 |
+
"age_band": "40s",
|
| 324 |
+
"build": null,
|
| 325 |
+
"hair": null,
|
| 326 |
+
"attire": "A traditional chef uniform with a bow tie",
|
| 327 |
+
"mood": "guarded",
|
| 328 |
+
"accent_color": "#9a9aa0",
|
| 329 |
+
"location_tags": [],
|
| 330 |
+
"prop_tags": [],
|
| 331 |
+
"prompt_hint": "Polite, middle-aged with a gentle expression, A traditional chef uniform with a bow tie"
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sus_id": "S4",
|
| 336 |
+
"name": "Liam McCarthy",
|
| 337 |
+
"role": "Rival Trustee with a Hidden Rivalry",
|
| 338 |
+
"persona_summary": "Liam is the ambitious rival who feels threatened by their success.",
|
| 339 |
+
"demeanour": "visibly frightened and on edge, dreading every question",
|
| 340 |
+
"is_culprit": false,
|
| 341 |
+
"physical_capability": {
|
| 342 |
+
"strength": true,
|
| 343 |
+
"mobility": true
|
| 344 |
+
},
|
| 345 |
+
"personality": {
|
| 346 |
+
"composure": 0.2,
|
| 347 |
+
"aggression": 0.3,
|
| 348 |
+
"evasiveness": 0.7
|
| 349 |
+
},
|
| 350 |
+
"tells": [
|
| 351 |
+
"You're all too trusting, just like me!"
|
| 352 |
+
],
|
| 353 |
+
"knows_facts": [
|
| 354 |
+
"F_sec4"
|
| 355 |
+
],
|
| 356 |
+
"secrets": [
|
| 357 |
+
"He was secretly funding this swindle to gain the trust of the victims and take over the trust."
|
| 358 |
+
],
|
| 359 |
+
"true_whereabouts": [
|
| 360 |
+
{
|
| 361 |
+
"window": {
|
| 362 |
+
"start_min": 1260,
|
| 363 |
+
"end_min": 1320
|
| 364 |
+
},
|
| 365 |
+
"loc_id": "L1",
|
| 366 |
+
"activity": "going about the evening",
|
| 367 |
+
"co_present_sus_ids": []
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"stated_alibi": {
|
| 371 |
+
"claim_text": "I was in Hall the whole time.",
|
| 372 |
+
"claimed_segments": [
|
| 373 |
+
{
|
| 374 |
+
"window": {
|
| 375 |
+
"start_min": 1260,
|
| 376 |
+
"end_min": 1320
|
| 377 |
+
},
|
| 378 |
+
"loc_id": "L1",
|
| 379 |
+
"witness_sus_ids": []
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"must_lie_about": [
|
| 384 |
+
"F_sec4"
|
| 385 |
+
],
|
| 386 |
+
"anchored_lies": [
|
| 387 |
+
{
|
| 388 |
+
"lie_id": "LIE_sec4",
|
| 389 |
+
"topic": "He was secretly funding this swindle to gain the",
|
| 390 |
+
"claimed": "I'm just trying to protect you from your own greed. Trust me on this!",
|
| 391 |
+
"truth_ref": "F_sec4",
|
| 392 |
+
"breaks_on": [
|
| 393 |
+
"C_h4"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the fraud."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "male",
|
| 403 |
+
"age_band": "20s",
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": "A sharp business suit with a pocket watch chain",
|
| 407 |
+
"mood": "guarded",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "Confident, 30s young man in blazers and suits, A sharp business suit with a pocket watch chain"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S3",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "Mark Davis defrauded Lena Thompson by using a Rusted antique pen nib with bloodstain on end grain. He did this from the Master Bedroom between 21:00 and 22:00, claiming he never left the Hall."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "The fraudster planted evidence of his presence in the Master Bedroom during the time of the theft.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L1",
|
| 425 |
+
"actual_loc_id": "L4",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "Mark Davis was in Master Bedroom during the fraud.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L4",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "He was once a professional forger who committed the swindle.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "She was once a professional forger with no criminal record but lost everything due to this swindle.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "He was a professional forger hired by the swindler to deliver forged documents.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "He was secretly funding this swindle to gain the trust of the victims and take over the trust.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Snagged thread",
|
| 474 |
+
"reveal_text": "A torn thread of dark cloth snagged on the master bedroom doorframe.",
|
| 475 |
+
"discoverable_at_loc_id": "L4",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S3",
|
| 479 |
+
"contradicts_alibi_of": "S3",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Smudged tumbler",
|
| 486 |
+
"reveal_text": "A tumbler abandoned at the scene, its rim marked with a recent lip-print.",
|
| 487 |
+
"discoverable_at_loc_id": "L1",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S3",
|
| 491 |
+
"contradicts_alibi_of": "S3",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h1",
|
| 497 |
+
"name": "Rusted pen nib with bloodstain",
|
| 498 |
+
"reveal_text": "The note pinned to the window was a clue: 'Trustworthiness is an illusion.'",
|
| 499 |
+
"discoverable_at_loc_id": "L2",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec1",
|
| 502 |
+
"points_to_sus_id": "S1",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h2",
|
| 509 |
+
"name": "Old photograph",
|
| 510 |
+
"reveal_text": "A worn photograph they would rather no one had seen.",
|
| 511 |
+
"discoverable_at_loc_id": "L3",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec2",
|
| 514 |
+
"points_to_sus_id": "S2",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h4",
|
| 521 |
+
"name": "A letter signed 'Trustee's Wife' with a postmark indicating a distant address",
|
| 522 |
+
"reveal_text": "The letter was not addressed to the victims, but to Liam.",
|
| 523 |
+
"discoverable_at_loc_id": "L1",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec4",
|
| 526 |
+
"points_to_sus_id": "S4",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S3",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"The Rusted antique pen nib with bloodstain on end grain was found in the Master Bedroom.",
|
| 541 |
+
"A fingerprint analysis shows that the stain matches the fraudster's prints.",
|
| 542 |
+
"Dust particles from the pen nib were collected and matched to furniture fibers in the Hall.",
|
| 543 |
+
"Witnesses noticed a monogrammed object, which turned out to be part of the breakfast table setup for dinner time."
|
| 544 |
+
]
|
| 545 |
+
}
|
| 546 |
+
}
|
cases/prebaked/CASE-0011.json
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0011",
|
| 3 |
+
"seed": 51013,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Vanishing Jewel",
|
| 6 |
+
"briefing": "A rare manuscript was stolen from the library of Countess Veronica, a prominent noblewoman and avid collector. The theft occurred between midnight and early morning in the Grand Hall.",
|
| 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 |
+
"crime_kind": "theft"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "theft",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "Grand Library",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "Entryway",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "Staircase Leading to Second Floor",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "Top Tier Stacking Area",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "Reception Room",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Veronica Cavendish",
|
| 65 |
+
"role": "Victor, Art Collector",
|
| 66 |
+
"found_at_loc_id": "L3",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "The victim discovered the loss herself in her quarters while attending a small gathering.",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "",
|
| 77 |
+
"kind": "",
|
| 78 |
+
"origin_loc_id": "L3",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "Sir Richard Cavendish",
|
| 86 |
+
"role": "Rival Collector, Business Partner",
|
| 87 |
+
"persona_summary": "A wealthy and arrogant man who always wanted to outdo his sister.",
|
| 88 |
+
"demeanour": "visibly frightened and on edge, dreading every question",
|
| 89 |
+
"is_culprit": false,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.2,
|
| 96 |
+
"aggression": 0.3,
|
| 97 |
+
"evasiveness": 0.7
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"I was just passing through the library for a small conversation when I heard someone clearing their throat nearby. The next thing I knew, they were gone and none of us could find them."
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_sec1"
|
| 104 |
+
],
|
| 105 |
+
"secrets": [
|
| 106 |
+
"He has been secretly acquiring stolen art pieces from the countess for years to complete a collection. He was not present due to an urgent business meeting."
|
| 107 |
+
],
|
| 108 |
+
"true_whereabouts": [
|
| 109 |
+
{
|
| 110 |
+
"window": {
|
| 111 |
+
"start_min": 1260,
|
| 112 |
+
"end_min": 1320
|
| 113 |
+
},
|
| 114 |
+
"loc_id": "L1",
|
| 115 |
+
"activity": "going about the evening",
|
| 116 |
+
"co_present_sus_ids": []
|
| 117 |
+
}
|
| 118 |
+
],
|
| 119 |
+
"stated_alibi": {
|
| 120 |
+
"claim_text": "I was in Entryway the whole time.",
|
| 121 |
+
"claimed_segments": [
|
| 122 |
+
{
|
| 123 |
+
"window": {
|
| 124 |
+
"start_min": 1260,
|
| 125 |
+
"end_min": 1320
|
| 126 |
+
},
|
| 127 |
+
"loc_id": "L1",
|
| 128 |
+
"witness_sus_ids": []
|
| 129 |
+
}
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
"must_lie_about": [
|
| 133 |
+
"F_sec1"
|
| 134 |
+
],
|
| 135 |
+
"anchored_lies": [
|
| 136 |
+
{
|
| 137 |
+
"lie_id": "LIE_sec1",
|
| 138 |
+
"topic": "He has been secretly acquiring stolen art pieces",
|
| 139 |
+
"claimed": "Sir Richard was present but claims he only took a brief walk in the library, which was coincidentally where the theft occurred.",
|
| 140 |
+
"truth_ref": "F_sec1",
|
| 141 |
+
"breaks_on": [
|
| 142 |
+
"C_h1"
|
| 143 |
+
],
|
| 144 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the theft."
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
"voice": null,
|
| 148 |
+
"visual": {
|
| 149 |
+
"subject_type": "suspect",
|
| 150 |
+
"palette": "noir",
|
| 151 |
+
"gender": "male",
|
| 152 |
+
"age_band": "40s",
|
| 153 |
+
"build": null,
|
| 154 |
+
"hair": null,
|
| 155 |
+
"attire": "Tuxedo with elegant cufflinks, top hat",
|
| 156 |
+
"mood": "guarded",
|
| 157 |
+
"accent_color": "#b8860b",
|
| 158 |
+
"location_tags": [],
|
| 159 |
+
"prop_tags": [],
|
| 160 |
+
"prompt_hint": "He is a well-dressed man in his 40s, wearing expensive jewelry and a tailored suit. He exudes confidence but has a nervous smile when asked about the theft., Tuxedo with elegant cufflinks, top hat"
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"sus_id": "S2",
|
| 165 |
+
"name": "Marianne Cavendish",
|
| 166 |
+
"role": "Sister to Victor, Loyal but Quiet",
|
| 167 |
+
"persona_summary": "An intelligent yet quiet sister who idolizes her brother. She keeps a low profile in the library.",
|
| 168 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 169 |
+
"is_culprit": true,
|
| 170 |
+
"physical_capability": {
|
| 171 |
+
"strength": true,
|
| 172 |
+
"mobility": true
|
| 173 |
+
},
|
| 174 |
+
"personality": {
|
| 175 |
+
"composure": 0.55,
|
| 176 |
+
"aggression": 0.88,
|
| 177 |
+
"evasiveness": 0.4
|
| 178 |
+
},
|
| 179 |
+
"tells": [
|
| 180 |
+
"I was just helping my brother find the book he was looking for when I saw him take the manuscript. He had been gone for quite some time, so I assumed it must have fallen off a shelf or slipped through his fingers by accident."
|
| 181 |
+
],
|
| 182 |
+
"knows_facts": [
|
| 183 |
+
"F_scene",
|
| 184 |
+
"F_sec2"
|
| 185 |
+
],
|
| 186 |
+
"secrets": [
|
| 187 |
+
"She was the last person to see the manuscript and is ashamed of her actions by keeping it hidden."
|
| 188 |
+
],
|
| 189 |
+
"true_whereabouts": [
|
| 190 |
+
{
|
| 191 |
+
"window": {
|
| 192 |
+
"start_min": 1260,
|
| 193 |
+
"end_min": 1280
|
| 194 |
+
},
|
| 195 |
+
"loc_id": "L4",
|
| 196 |
+
"activity": "mingling in plain sight",
|
| 197 |
+
"co_present_sus_ids": []
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"window": {
|
| 201 |
+
"start_min": 1280,
|
| 202 |
+
"end_min": 1310
|
| 203 |
+
},
|
| 204 |
+
"loc_id": "L3",
|
| 205 |
+
"activity": "at the scene of the theft",
|
| 206 |
+
"co_present_sus_ids": []
|
| 207 |
+
}
|
| 208 |
+
],
|
| 209 |
+
"stated_alibi": {
|
| 210 |
+
"claim_text": "I was cleaning the reception room when I saw Marianne and Lucy enter together at 21:00 and exit the area shortly after.",
|
| 211 |
+
"claimed_segments": [
|
| 212 |
+
{
|
| 213 |
+
"window": {
|
| 214 |
+
"start_min": 1260,
|
| 215 |
+
"end_min": 1320
|
| 216 |
+
},
|
| 217 |
+
"loc_id": "L4",
|
| 218 |
+
"witness_sus_ids": []
|
| 219 |
+
}
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
"must_lie_about": [
|
| 223 |
+
"F_scene"
|
| 224 |
+
],
|
| 225 |
+
"anchored_lies": [
|
| 226 |
+
{
|
| 227 |
+
"lie_id": "LIE_alibi",
|
| 228 |
+
"topic": "where you were during the theft",
|
| 229 |
+
"claimed": "I was cleaning the reception room when I saw Marianne and Lucy enter together at 21:00 and exit the area shortly after.",
|
| 230 |
+
"truth_ref": "F_scene",
|
| 231 |
+
"breaks_on": [
|
| 232 |
+
"C_b1",
|
| 233 |
+
"C_b2"
|
| 234 |
+
],
|
| 235 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 236 |
+
}
|
| 237 |
+
],
|
| 238 |
+
"voice": null,
|
| 239 |
+
"visual": {
|
| 240 |
+
"subject_type": "suspect",
|
| 241 |
+
"palette": "noir",
|
| 242 |
+
"gender": "female",
|
| 243 |
+
"age_band": "30-40s",
|
| 244 |
+
"build": null,
|
| 245 |
+
"hair": null,
|
| 246 |
+
"attire": "Comfortable dress with a small purse at her side",
|
| 247 |
+
"mood": "guarded",
|
| 248 |
+
"accent_color": "#3a6ea5",
|
| 249 |
+
"location_tags": [],
|
| 250 |
+
"prop_tags": [],
|
| 251 |
+
"prompt_hint": "She is a gentle woman in her 30s, wearing simple clothes that cover everything except her hands. Her eyes are gentle but filled with guilt when asked about her actions., Comfortable dress with a small purse at her side"
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"sus_id": "S3",
|
| 256 |
+
"name": "John Johnson",
|
| 257 |
+
"role": "Housekeeper, Anxious but Relaxed",
|
| 258 |
+
"persona_summary": "A diligent and anxious man who is always on edge in the library. He usually keeps to himself.",
|
| 259 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 260 |
+
"is_culprit": false,
|
| 261 |
+
"physical_capability": {
|
| 262 |
+
"strength": true,
|
| 263 |
+
"mobility": true
|
| 264 |
+
},
|
| 265 |
+
"personality": {
|
| 266 |
+
"composure": 0.72,
|
| 267 |
+
"aggression": 0.4,
|
| 268 |
+
"evasiveness": 0.3
|
| 269 |
+
},
|
| 270 |
+
"tells": [
|
| 271 |
+
"I was just checking if the books were free from any dust when I noticed a key and papers on the floor. They looked like they had been there for quite some time, but I never thought about what it could mean."
|
| 272 |
+
],
|
| 273 |
+
"knows_facts": [
|
| 274 |
+
"F_sec3"
|
| 275 |
+
],
|
| 276 |
+
"secrets": [
|
| 277 |
+
"He had a key to the top tier where the manuscript was hidden for quick access by him alone."
|
| 278 |
+
],
|
| 279 |
+
"true_whereabouts": [
|
| 280 |
+
{
|
| 281 |
+
"window": {
|
| 282 |
+
"start_min": 1260,
|
| 283 |
+
"end_min": 1320
|
| 284 |
+
},
|
| 285 |
+
"loc_id": "L4",
|
| 286 |
+
"activity": "going about the evening",
|
| 287 |
+
"co_present_sus_ids": []
|
| 288 |
+
}
|
| 289 |
+
],
|
| 290 |
+
"stated_alibi": {
|
| 291 |
+
"claim_text": "I was in Reception Room the whole time.",
|
| 292 |
+
"claimed_segments": [
|
| 293 |
+
{
|
| 294 |
+
"window": {
|
| 295 |
+
"start_min": 1260,
|
| 296 |
+
"end_min": 1320
|
| 297 |
+
},
|
| 298 |
+
"loc_id": "L4",
|
| 299 |
+
"witness_sus_ids": []
|
| 300 |
+
}
|
| 301 |
+
]
|
| 302 |
+
},
|
| 303 |
+
"must_lie_about": [
|
| 304 |
+
"F_sec3"
|
| 305 |
+
],
|
| 306 |
+
"anchored_lies": [
|
| 307 |
+
{
|
| 308 |
+
"lie_id": "LIE_sec3",
|
| 309 |
+
"topic": "He had a key to the top tier where the manuscrip",
|
| 310 |
+
"claimed": "John claims he simply brought the housekeeping staff to clean up dust from the rare books and accidentally left the room, but it didn't match his usual habits. He never thought about what happened next.",
|
| 311 |
+
"truth_ref": "F_sec3",
|
| 312 |
+
"breaks_on": [
|
| 313 |
+
"C_h3"
|
| 314 |
+
],
|
| 315 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the theft."
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"voice": null,
|
| 319 |
+
"visual": {
|
| 320 |
+
"subject_type": "suspect",
|
| 321 |
+
"palette": "noir",
|
| 322 |
+
"gender": "male",
|
| 323 |
+
"age_band": "50s",
|
| 324 |
+
"build": null,
|
| 325 |
+
"hair": null,
|
| 326 |
+
"attire": "A classic suit with ties, pocket watch",
|
| 327 |
+
"mood": "tense",
|
| 328 |
+
"accent_color": "#9a9aa0",
|
| 329 |
+
"location_tags": [],
|
| 330 |
+
"prop_tags": [],
|
| 331 |
+
"prompt_hint": "He is a tall man in his 50s with a strong build. His hair is neatly combed back and he wears clean, simple clothes that hint at his anxiety., A classic suit with ties, pocket watch"
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sus_id": "S4",
|
| 336 |
+
"name": "Thomas Thompson",
|
| 337 |
+
"role": "Lover of Countess Cavendish, Passionate yet Quiet",
|
| 338 |
+
"persona_summary": "A passionate man who has always admired the countess. He is often present in the library but seldom speaks.",
|
| 339 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 340 |
+
"is_culprit": false,
|
| 341 |
+
"physical_capability": {
|
| 342 |
+
"strength": true,
|
| 343 |
+
"mobility": true
|
| 344 |
+
},
|
| 345 |
+
"personality": {
|
| 346 |
+
"composure": 0.32,
|
| 347 |
+
"aggression": 0.55,
|
| 348 |
+
"evasiveness": 0.68
|
| 349 |
+
},
|
| 350 |
+
"tells": [
|
| 351 |
+
"I was here for a meeting with Marianne when I heard voices coming from the library. The next thing I knew, someone was gone, and I couldn't find them or the room they had been in."
|
| 352 |
+
],
|
| 353 |
+
"knows_facts": [
|
| 354 |
+
"F_sec4"
|
| 355 |
+
],
|
| 356 |
+
"secrets": [
|
| 357 |
+
"He had a hidden passageway leading to the manuscript's hiding place and was unaware he left it open."
|
| 358 |
+
],
|
| 359 |
+
"true_whereabouts": [
|
| 360 |
+
{
|
| 361 |
+
"window": {
|
| 362 |
+
"start_min": 1260,
|
| 363 |
+
"end_min": 1320
|
| 364 |
+
},
|
| 365 |
+
"loc_id": "L1",
|
| 366 |
+
"activity": "going about the evening",
|
| 367 |
+
"co_present_sus_ids": []
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"stated_alibi": {
|
| 371 |
+
"claim_text": "I was in Entryway the whole time.",
|
| 372 |
+
"claimed_segments": [
|
| 373 |
+
{
|
| 374 |
+
"window": {
|
| 375 |
+
"start_min": 1260,
|
| 376 |
+
"end_min": 1320
|
| 377 |
+
},
|
| 378 |
+
"loc_id": "L1",
|
| 379 |
+
"witness_sus_ids": []
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"must_lie_about": [
|
| 384 |
+
"F_sec4"
|
| 385 |
+
],
|
| 386 |
+
"anchored_lies": [
|
| 387 |
+
{
|
| 388 |
+
"lie_id": "LIE_sec4",
|
| 389 |
+
"topic": "He had a hidden passageway leading to the manusc",
|
| 390 |
+
"claimed": "Thomas claims he came here for a meeting with Marianne, thinking it was another book she wanted, but never expected the manuscript would be stolen. He was distracted by his passion and didn't notice when he left it unlocked.",
|
| 391 |
+
"truth_ref": "F_sec4",
|
| 392 |
+
"breaks_on": [
|
| 393 |
+
"C_h4"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the theft."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "male",
|
| 403 |
+
"age_band": "20s",
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": "T-shirt, jeans, simple jewelry",
|
| 407 |
+
"mood": "guarded",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "He is a handsome man in his 20s with piercing eyes. His hair is well-groomed but always looks disheveled. He wears casual clothes and often smiles nervously when asked about the theft., T-shirt, jeans, simple jewelry"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S2",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "Marianne stole the diamonds to exact Victor Cavendish, who had previously betrayed her by revealing her past as a diamond thief."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "Marianne used Veronica's trusted maid, Lucy, to steal the diamonds. They meticulously cleaned and packed them into small, inconspicuous containers that fit easily in the Top Tier Stacking Area.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L4",
|
| 425 |
+
"actual_loc_id": "L3",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "Marianne Cavendish was in Top Tier Stacking Area during the theft.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L3",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "He has been secretly acquiring stolen art pieces from the countess for years to complete a collection. He was not present due to an urgent business meeting.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "She was the last person to see the manuscript and is ashamed of her actions by keeping it hidden.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "He had a key to the top tier where the manuscript was hidden for quick access by him alone.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "He had a hidden passageway leading to the manuscript's hiding place and was unaware he left it open.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Lucy",
|
| 474 |
+
"reveal_text": "The diamonds were packed into small containers that fit easily in the Top Tier Stacking Area, matching the description of what was found there.",
|
| 475 |
+
"discoverable_at_loc_id": "L3",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S2",
|
| 479 |
+
"contradicts_alibi_of": "S2",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Smudged tumbler",
|
| 486 |
+
"reveal_text": "A tumbler left where the body fell, its rim marked with a recent lip-print.",
|
| 487 |
+
"discoverable_at_loc_id": "L1",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S2",
|
| 491 |
+
"contradicts_alibi_of": "S2",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h1",
|
| 497 |
+
"name": "A signed contract detailing his acquisitions",
|
| 498 |
+
"reveal_text": "The document reveals that Sir Richard's brother also had access to the manuscript. They were in league with each other.",
|
| 499 |
+
"discoverable_at_loc_id": "L2",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec1",
|
| 502 |
+
"points_to_sus_id": "S1",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h3",
|
| 509 |
+
"name": "A key to the top tier with a small piece of paper tied in knots",
|
| 510 |
+
"reveal_text": "The key matches the description provided by Marianne and has been used for the same reason as her secret evidence, indicating where she hid it last.",
|
| 511 |
+
"discoverable_at_loc_id": "L4",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec3",
|
| 514 |
+
"points_to_sus_id": "S3",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h4",
|
| 521 |
+
"name": "A photograph of the passageway leading to the library",
|
| 522 |
+
"reveal_text": "The photograph shows a small corridor that leads directly to the manuscript's hidden room, matching Thomas's secret evidence.",
|
| 523 |
+
"discoverable_at_loc_id": "L1",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec4",
|
| 526 |
+
"points_to_sus_id": "S4",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S2",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"The physical evidence of Marianne's cleaning actions in the reception room aligns with her motive for wanting to clean up Veronica Cavendish’s belongings.",
|
| 541 |
+
"Lucy, who was present during the theft and later cleaned the reception area, is a logical alibi since she can explain why she saw Marianne and Lucy together at 21:00. Her presence in the room supports the theory that she might have seen what she claimed to see.",
|
| 542 |
+
"Victor Cavendish's suspicion of Marianne’s past actions suggests he may have evidence linking her to previous thefts, further complicating his suspicions."
|
| 543 |
+
]
|
| 544 |
+
}
|
| 545 |
+
}
|
cases/prebaked/CASE-0012.json
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0012",
|
| 3 |
+
"seed": 51019,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Johnson File",
|
| 6 |
+
"briefing": "The murder occurred around 21:00 on a rainy evening. The victim, Sarah, was last seen alive in the Conservatory.",
|
| 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 |
+
"crime_kind": "homicide"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "homicide",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "The Palace of Mirrors",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "Library",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "Conservatory",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "Study",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "Bathroom",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Sarah Johnson",
|
| 65 |
+
"role": "English teacher at a prestigious high school, deceased from an apparent suicide by hanging in the Conservatory.",
|
| 66 |
+
"found_at_loc_id": "L3",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "Suicide",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "No weapon found on scene.",
|
| 77 |
+
"kind": "None (suicidal means)",
|
| 78 |
+
"origin_loc_id": "L3",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "Ethan Jones",
|
| 86 |
+
"role": "Sarah's colleague and close friend, a male lawyer in his late 30s with dark hair",
|
| 87 |
+
"persona_summary": "Genuinely concerned about Sarah’s well-being.",
|
| 88 |
+
"demeanour": "visibly frightened and on edge, dreading every question",
|
| 89 |
+
"is_culprit": false,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.2,
|
| 96 |
+
"aggression": 0.3,
|
| 97 |
+
"evasiveness": 0.7
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"He's always so serious about their friendship that he never brings up his secrets."
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_sec1"
|
| 104 |
+
],
|
| 105 |
+
"secrets": [
|
| 106 |
+
"He is secretly involved in the school's controversial book club that plans to release a banned novel on the day of her class presentation."
|
| 107 |
+
],
|
| 108 |
+
"true_whereabouts": [
|
| 109 |
+
{
|
| 110 |
+
"window": {
|
| 111 |
+
"start_min": 1260,
|
| 112 |
+
"end_min": 1320
|
| 113 |
+
},
|
| 114 |
+
"loc_id": "L1",
|
| 115 |
+
"activity": "going about the evening",
|
| 116 |
+
"co_present_sus_ids": []
|
| 117 |
+
}
|
| 118 |
+
],
|
| 119 |
+
"stated_alibi": {
|
| 120 |
+
"claim_text": "I was in Library the whole time.",
|
| 121 |
+
"claimed_segments": [
|
| 122 |
+
{
|
| 123 |
+
"window": {
|
| 124 |
+
"start_min": 1260,
|
| 125 |
+
"end_min": 1320
|
| 126 |
+
},
|
| 127 |
+
"loc_id": "L1",
|
| 128 |
+
"witness_sus_ids": []
|
| 129 |
+
}
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
"must_lie_about": [
|
| 133 |
+
"F_sec1"
|
| 134 |
+
],
|
| 135 |
+
"anchored_lies": [
|
| 136 |
+
{
|
| 137 |
+
"lie_id": "LIE_sec1",
|
| 138 |
+
"topic": "He is secretly involved in the school's controve",
|
| 139 |
+
"claimed": "Sarah had an argument with him over the book selection. They reconciled and he thought it was best she dropped out of the contest.",
|
| 140 |
+
"truth_ref": "F_sec1",
|
| 141 |
+
"breaks_on": [
|
| 142 |
+
"C_h1"
|
| 143 |
+
],
|
| 144 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
"voice": null,
|
| 148 |
+
"visual": {
|
| 149 |
+
"subject_type": "suspect",
|
| 150 |
+
"palette": "noir",
|
| 151 |
+
"gender": "male",
|
| 152 |
+
"age_band": "late 30s",
|
| 153 |
+
"build": null,
|
| 154 |
+
"hair": null,
|
| 155 |
+
"attire": "Tailored suits, tie, glasses",
|
| 156 |
+
"mood": "tense",
|
| 157 |
+
"accent_color": "#b8860b",
|
| 158 |
+
"location_tags": [],
|
| 159 |
+
"prop_tags": [],
|
| 160 |
+
"prompt_hint": "Trained lawyer attire: dark suit, formal shirt, polished shoes, Tailored suits, tie, glasses"
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"sus_id": "S2",
|
| 165 |
+
"name": "Olivia Morris",
|
| 166 |
+
"role": "Sarah’s rival for the school’s Most Likely to Win award, a female teacher in her early 40s with blonde hair",
|
| 167 |
+
"persona_summary": "Rivals Sarah fiercely but have been trying to collaborate on research projects.",
|
| 168 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 169 |
+
"is_culprit": false,
|
| 170 |
+
"physical_capability": {
|
| 171 |
+
"strength": true,
|
| 172 |
+
"mobility": true
|
| 173 |
+
},
|
| 174 |
+
"personality": {
|
| 175 |
+
"composure": 0.55,
|
| 176 |
+
"aggression": 0.88,
|
| 177 |
+
"evasiveness": 0.4
|
| 178 |
+
},
|
| 179 |
+
"tells": [
|
| 180 |
+
"She's very reserved and keeps her true intentions hidden in plain sight."
|
| 181 |
+
],
|
| 182 |
+
"knows_facts": [
|
| 183 |
+
"F_sec2"
|
| 184 |
+
],
|
| 185 |
+
"secrets": [
|
| 186 |
+
"She has an affair with a student not under observation by Sarah or Olivia, making it difficult for either of them to confront."
|
| 187 |
+
],
|
| 188 |
+
"true_whereabouts": [
|
| 189 |
+
{
|
| 190 |
+
"window": {
|
| 191 |
+
"start_min": 1260,
|
| 192 |
+
"end_min": 1320
|
| 193 |
+
},
|
| 194 |
+
"loc_id": "L2",
|
| 195 |
+
"activity": "going about the evening",
|
| 196 |
+
"co_present_sus_ids": []
|
| 197 |
+
}
|
| 198 |
+
],
|
| 199 |
+
"stated_alibi": {
|
| 200 |
+
"claim_text": "I was in Conservatory the whole time.",
|
| 201 |
+
"claimed_segments": [
|
| 202 |
+
{
|
| 203 |
+
"window": {
|
| 204 |
+
"start_min": 1260,
|
| 205 |
+
"end_min": 1320
|
| 206 |
+
},
|
| 207 |
+
"loc_id": "L2",
|
| 208 |
+
"witness_sus_ids": []
|
| 209 |
+
}
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
"must_lie_about": [
|
| 213 |
+
"F_sec2"
|
| 214 |
+
],
|
| 215 |
+
"anchored_lies": [
|
| 216 |
+
{
|
| 217 |
+
"lie_id": "LIE_sec2",
|
| 218 |
+
"topic": "She has an affair with a student not under obser",
|
| 219 |
+
"claimed": "They were discussing the same project and both had personal reasons they couldn’t explain.",
|
| 220 |
+
"truth_ref": "F_sec2",
|
| 221 |
+
"breaks_on": [
|
| 222 |
+
"C_h2"
|
| 223 |
+
],
|
| 224 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 225 |
+
}
|
| 226 |
+
],
|
| 227 |
+
"voice": null,
|
| 228 |
+
"visual": {
|
| 229 |
+
"subject_type": "suspect",
|
| 230 |
+
"palette": "noir",
|
| 231 |
+
"gender": "female",
|
| 232 |
+
"age_band": "early 40s",
|
| 233 |
+
"build": null,
|
| 234 |
+
"hair": null,
|
| 235 |
+
"attire": "Casual business attire, high heels, long sleeves",
|
| 236 |
+
"mood": "tense",
|
| 237 |
+
"accent_color": "#3a6ea5",
|
| 238 |
+
"location_tags": [],
|
| 239 |
+
"prop_tags": [],
|
| 240 |
+
"prompt_hint": "Educated teacher attire: blouse, skirt, heels, professional makeup, Casual business attire, high heels, long sleeves"
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"sus_id": "S3",
|
| 245 |
+
"name": "James Davis",
|
| 246 |
+
"role": "Sarah’s boyfriend, a male teacher in his late 30s with brown hair",
|
| 247 |
+
"persona_summary": "Proud of Sarah for being the perfect girlfriend.",
|
| 248 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 249 |
+
"is_culprit": false,
|
| 250 |
+
"physical_capability": {
|
| 251 |
+
"strength": true,
|
| 252 |
+
"mobility": true
|
| 253 |
+
},
|
| 254 |
+
"personality": {
|
| 255 |
+
"composure": 0.72,
|
| 256 |
+
"aggression": 0.4,
|
| 257 |
+
"evasiveness": 0.3
|
| 258 |
+
},
|
| 259 |
+
"tells": [
|
| 260 |
+
"He’s very calm, almost oblivious to the tension in the room."
|
| 261 |
+
],
|
| 262 |
+
"knows_facts": [
|
| 263 |
+
"F_sec3"
|
| 264 |
+
],
|
| 265 |
+
"secrets": [
|
| 266 |
+
"He has a secret plan to propose marriage to her but is too nervous and unsure about their future."
|
| 267 |
+
],
|
| 268 |
+
"true_whereabouts": [
|
| 269 |
+
{
|
| 270 |
+
"window": {
|
| 271 |
+
"start_min": 1260,
|
| 272 |
+
"end_min": 1320
|
| 273 |
+
},
|
| 274 |
+
"loc_id": "L4",
|
| 275 |
+
"activity": "going about the evening",
|
| 276 |
+
"co_present_sus_ids": []
|
| 277 |
+
}
|
| 278 |
+
],
|
| 279 |
+
"stated_alibi": {
|
| 280 |
+
"claim_text": "I was in Bathroom the whole time.",
|
| 281 |
+
"claimed_segments": [
|
| 282 |
+
{
|
| 283 |
+
"window": {
|
| 284 |
+
"start_min": 1260,
|
| 285 |
+
"end_min": 1320
|
| 286 |
+
},
|
| 287 |
+
"loc_id": "L4",
|
| 288 |
+
"witness_sus_ids": []
|
| 289 |
+
}
|
| 290 |
+
]
|
| 291 |
+
},
|
| 292 |
+
"must_lie_about": [
|
| 293 |
+
"F_sec3"
|
| 294 |
+
],
|
| 295 |
+
"anchored_lies": [
|
| 296 |
+
{
|
| 297 |
+
"lie_id": "LIE_sec3",
|
| 298 |
+
"topic": "He has a secret plan to propose marriage to her ",
|
| 299 |
+
"claimed": "They were planning a surprise graduation ceremony, which was just postponed due to circumstances.",
|
| 300 |
+
"truth_ref": "F_sec3",
|
| 301 |
+
"breaks_on": [
|
| 302 |
+
"C_h3"
|
| 303 |
+
],
|
| 304 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 305 |
+
}
|
| 306 |
+
],
|
| 307 |
+
"voice": null,
|
| 308 |
+
"visual": {
|
| 309 |
+
"subject_type": "suspect",
|
| 310 |
+
"palette": "noir",
|
| 311 |
+
"gender": "male",
|
| 312 |
+
"age_band": "late 30s",
|
| 313 |
+
"build": null,
|
| 314 |
+
"hair": null,
|
| 315 |
+
"attire": "Tuxedo with a bow tie and black dress shoes",
|
| 316 |
+
"mood": "tense",
|
| 317 |
+
"accent_color": "#9a9aa0",
|
| 318 |
+
"location_tags": [],
|
| 319 |
+
"prop_tags": [],
|
| 320 |
+
"prompt_hint": "Proud boyfriend attire: suit jacket, white shirt, polished shoes, tie, Tuxedo with a bow tie and black dress shoes"
|
| 321 |
+
}
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"sus_id": "S4",
|
| 325 |
+
"name": "Mrs. Elizabeth Smith",
|
| 326 |
+
"role": "Sarah's mother, a female teacher in her mid-60s with short hair, middle-class background",
|
| 327 |
+
"persona_summary": "Protective and slightly overprotective of Sarah.",
|
| 328 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 329 |
+
"is_culprit": true,
|
| 330 |
+
"physical_capability": {
|
| 331 |
+
"strength": true,
|
| 332 |
+
"mobility": true
|
| 333 |
+
},
|
| 334 |
+
"personality": {
|
| 335 |
+
"composure": 0.32,
|
| 336 |
+
"aggression": 0.55,
|
| 337 |
+
"evasiveness": 0.68
|
| 338 |
+
},
|
| 339 |
+
"tells": [
|
| 340 |
+
"She was always trying to protect her daughter from any harm."
|
| 341 |
+
],
|
| 342 |
+
"knows_facts": [
|
| 343 |
+
"F_scene",
|
| 344 |
+
"F_sec4"
|
| 345 |
+
],
|
| 346 |
+
"secrets": [
|
| 347 |
+
"She was not the main culprit but rather an accomplice who used Sarah’s guilt to force him into committing suicide out of fear for his own job security."
|
| 348 |
+
],
|
| 349 |
+
"true_whereabouts": [
|
| 350 |
+
{
|
| 351 |
+
"window": {
|
| 352 |
+
"start_min": 1260,
|
| 353 |
+
"end_min": 1280
|
| 354 |
+
},
|
| 355 |
+
"loc_id": "L4",
|
| 356 |
+
"activity": "mingling in plain sight",
|
| 357 |
+
"co_present_sus_ids": []
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"window": {
|
| 361 |
+
"start_min": 1280,
|
| 362 |
+
"end_min": 1310
|
| 363 |
+
},
|
| 364 |
+
"loc_id": "L3",
|
| 365 |
+
"activity": "at the scene of the murder",
|
| 366 |
+
"co_present_sus_ids": []
|
| 367 |
+
}
|
| 368 |
+
],
|
| 369 |
+
"stated_alibi": {
|
| 370 |
+
"claim_text": "I was in the Bathroom.",
|
| 371 |
+
"claimed_segments": [
|
| 372 |
+
{
|
| 373 |
+
"window": {
|
| 374 |
+
"start_min": 1260,
|
| 375 |
+
"end_min": 1320
|
| 376 |
+
},
|
| 377 |
+
"loc_id": "L4",
|
| 378 |
+
"witness_sus_ids": []
|
| 379 |
+
}
|
| 380 |
+
]
|
| 381 |
+
},
|
| 382 |
+
"must_lie_about": [
|
| 383 |
+
"F_scene"
|
| 384 |
+
],
|
| 385 |
+
"anchored_lies": [
|
| 386 |
+
{
|
| 387 |
+
"lie_id": "LIE_alibi",
|
| 388 |
+
"topic": "where you were during the murder",
|
| 389 |
+
"claimed": "I was in the Bathroom.",
|
| 390 |
+
"truth_ref": "F_scene",
|
| 391 |
+
"breaks_on": [
|
| 392 |
+
"C_b1",
|
| 393 |
+
"C_b2"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "female",
|
| 403 |
+
"age_band": "mid-60s",
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": "Business suit with pearls, bow tie, white blouse",
|
| 407 |
+
"mood": "tense",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "Wise and caring teacher attire: blazer, skirt, pearls, Business suit with pearls, bow tie, white blouse"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S4",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "Sarah Johnson was jealous of Elizabeth Smith's success and wealth, leading to a bitter revenge against her."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "The killer, Mrs. Elizabeth Smith, meticulously planned the murder by selecting an opportune time and choosing a quiet location with minimal evidence. She used a stopped clock as a false alibi and left no weapon on the scene.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L4",
|
| 425 |
+
"actual_loc_id": "L3",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "Mrs. Elizabeth Smith was in Study during the murder.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L3",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "He is secretly involved in the school's controversial book club that plans to release a banned novel on the day of her class presentation.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "She has an affair with a student not under observation by Sarah or Olivia, making it difficult for either of them to confront.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "He has a secret plan to propose marriage to her but is too nervous and unsure about their future.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "She was not the main culprit but rather an accomplice who used Sarah’s guilt to force him into committing suicide out of fear for his own job security.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Damp footprints",
|
| 474 |
+
"reveal_text": "Half-dried footprints crossing the study, left around the time of death.",
|
| 475 |
+
"discoverable_at_loc_id": "L3",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S4",
|
| 479 |
+
"contradicts_alibi_of": "S4",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Snagged thread",
|
| 486 |
+
"reveal_text": "A torn thread of dark cloth snagged on the study doorframe.",
|
| 487 |
+
"discoverable_at_loc_id": "L1",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S4",
|
| 491 |
+
"contradicts_alibi_of": "S4",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h1",
|
| 497 |
+
"name": "Letter from Sarah indicating her interest in the book club",
|
| 498 |
+
"reveal_text": "The letter reveals that the evidence suggests he manipulated events to keep her involved, fearing his failure would harm their friendship.",
|
| 499 |
+
"discoverable_at_loc_id": "L2",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec1",
|
| 502 |
+
"points_to_sus_id": "S1",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h2",
|
| 509 |
+
"name": "Receipt from a meeting where she discussed the research without mentioning her rival",
|
| 510 |
+
"reveal_text": "The receipt indicates that Olivia was present but not directly involved in discussions, suggesting she wasn't aware of her affair with Sarah's student.",
|
| 511 |
+
"discoverable_at_loc_id": "L4",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec2",
|
| 514 |
+
"points_to_sus_id": "S2",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h3",
|
| 521 |
+
"name": "Photograph from an engagement ring box with an envelope",
|
| 522 |
+
"reveal_text": "The photograph shows the engagement ring on the table as they were discussing plans for her birthday party. The envelope inside contains their wedding invitations but no flowers or gifts, indicating he is nervous about proposing.",
|
| 523 |
+
"discoverable_at_loc_id": "L1",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec3",
|
| 526 |
+
"points_to_sus_id": "S3",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S4",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"The stopped clock is a clear physical evidence pointing to Mrs. Smith's involvement.",
|
| 541 |
+
"A torn fibre found near the study window suggests an accidental entry and exit point, indicating she was present but not in the act of murder."
|
| 542 |
+
]
|
| 543 |
+
}
|
| 544 |
+
}
|
cases/prebaked/CASE-0013.json
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0013",
|
| 3 |
+
"seed": 51020,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Vanishing of Evelyn Hart",
|
| 6 |
+
"briefing": "Evelyn Hart, a renowned art historian and curator, vanished from her home on the evening of October 15th at 21:30. The disappearance occurred between 21:00 and 22:00.",
|
| 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 |
+
"crime_kind": "missing_person"
|
| 16 |
+
},
|
| 17 |
+
"crime_kind": "missing_person",
|
| 18 |
+
"setting": {
|
| 19 |
+
"name": "The Hart Family Mansion",
|
| 20 |
+
"description": "",
|
| 21 |
+
"locations": [
|
| 22 |
+
{
|
| 23 |
+
"loc_id": "L1",
|
| 24 |
+
"name": "Entrance Hall",
|
| 25 |
+
"description": "",
|
| 26 |
+
"adjacent_to": [
|
| 27 |
+
"L2",
|
| 28 |
+
"L3",
|
| 29 |
+
"L4"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"loc_id": "L2",
|
| 34 |
+
"name": "Living Room",
|
| 35 |
+
"description": "",
|
| 36 |
+
"adjacent_to": [
|
| 37 |
+
"L1"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"loc_id": "L3",
|
| 42 |
+
"name": "Kitchen",
|
| 43 |
+
"description": "",
|
| 44 |
+
"adjacent_to": [
|
| 45 |
+
"L1"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"loc_id": "L4",
|
| 50 |
+
"name": "Bathroom",
|
| 51 |
+
"description": "",
|
| 52 |
+
"adjacent_to": [
|
| 53 |
+
"L1"
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"murder_window": {
|
| 58 |
+
"start_min": 1260,
|
| 59 |
+
"end_min": 1320
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"victim": {
|
| 63 |
+
"vic_id": "V1",
|
| 64 |
+
"name": "Evelyn Hart",
|
| 65 |
+
"role": "Curator of the Hart Family Collection",
|
| 66 |
+
"found_at_loc_id": "L3",
|
| 67 |
+
"found_at_min": 1325,
|
| 68 |
+
"cause_of_death": "No visible cause, but her belongings were found in a secluded room.",
|
| 69 |
+
"time_of_death": {
|
| 70 |
+
"start_min": 1280,
|
| 71 |
+
"end_min": 1310
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
"weapon": {
|
| 75 |
+
"weapon_id": "W1",
|
| 76 |
+
"name": "",
|
| 77 |
+
"kind": "",
|
| 78 |
+
"origin_loc_id": "L3",
|
| 79 |
+
"requires_strength": false,
|
| 80 |
+
"leaves_trace": ""
|
| 81 |
+
},
|
| 82 |
+
"suspects": [
|
| 83 |
+
{
|
| 84 |
+
"sus_id": "S1",
|
| 85 |
+
"name": "James Hart",
|
| 86 |
+
"role": "Evelyn's husband and curator of the family collection",
|
| 87 |
+
"persona_summary": "A meticulous, dedicated collector who often worked late into the night.",
|
| 88 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 89 |
+
"is_culprit": true,
|
| 90 |
+
"physical_capability": {
|
| 91 |
+
"strength": true,
|
| 92 |
+
"mobility": true
|
| 93 |
+
},
|
| 94 |
+
"personality": {
|
| 95 |
+
"composure": 0.55,
|
| 96 |
+
"aggression": 0.88,
|
| 97 |
+
"evasiveness": 0.4
|
| 98 |
+
},
|
| 99 |
+
"tells": [
|
| 100 |
+
"He was just finishing the final touches on a painting for the collection when he received an urgent call from his business partner."
|
| 101 |
+
],
|
| 102 |
+
"knows_facts": [
|
| 103 |
+
"F_scene",
|
| 104 |
+
"F_sec1"
|
| 105 |
+
],
|
| 106 |
+
"secrets": [
|
| 107 |
+
"He was secretly involved in a fraudulent art trade that had been discovered by his wife."
|
| 108 |
+
],
|
| 109 |
+
"true_whereabouts": [
|
| 110 |
+
{
|
| 111 |
+
"window": {
|
| 112 |
+
"start_min": 1260,
|
| 113 |
+
"end_min": 1280
|
| 114 |
+
},
|
| 115 |
+
"loc_id": "L4",
|
| 116 |
+
"activity": "mingling in plain sight",
|
| 117 |
+
"co_present_sus_ids": []
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"window": {
|
| 121 |
+
"start_min": 1280,
|
| 122 |
+
"end_min": 1310
|
| 123 |
+
},
|
| 124 |
+
"loc_id": "L3",
|
| 125 |
+
"activity": "at the scene of the disappearance",
|
| 126 |
+
"co_present_sus_ids": []
|
| 127 |
+
}
|
| 128 |
+
],
|
| 129 |
+
"stated_alibi": {
|
| 130 |
+
"claim_text": "In the kitchen when she left at 20:59; they were in the Bathroom after that time.",
|
| 131 |
+
"claimed_segments": [
|
| 132 |
+
{
|
| 133 |
+
"window": {
|
| 134 |
+
"start_min": 1260,
|
| 135 |
+
"end_min": 1320
|
| 136 |
+
},
|
| 137 |
+
"loc_id": "L4",
|
| 138 |
+
"witness_sus_ids": []
|
| 139 |
+
}
|
| 140 |
+
]
|
| 141 |
+
},
|
| 142 |
+
"must_lie_about": [
|
| 143 |
+
"F_scene"
|
| 144 |
+
],
|
| 145 |
+
"anchored_lies": [
|
| 146 |
+
{
|
| 147 |
+
"lie_id": "LIE_alibi",
|
| 148 |
+
"topic": "where you were during the disappearance",
|
| 149 |
+
"claimed": "In the kitchen when she left at 20:59; they were in the Bathroom after that time.",
|
| 150 |
+
"truth_ref": "F_scene",
|
| 151 |
+
"breaks_on": [
|
| 152 |
+
"C_b1",
|
| 153 |
+
"C_b2"
|
| 154 |
+
],
|
| 155 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 156 |
+
}
|
| 157 |
+
],
|
| 158 |
+
"voice": null,
|
| 159 |
+
"visual": {
|
| 160 |
+
"subject_type": "suspect",
|
| 161 |
+
"palette": "noir",
|
| 162 |
+
"gender": "male",
|
| 163 |
+
"age_band": null,
|
| 164 |
+
"build": null,
|
| 165 |
+
"hair": null,
|
| 166 |
+
"attire": "A black suit and white shirt, neatly pressed and ironed",
|
| 167 |
+
"mood": "tense",
|
| 168 |
+
"accent_color": "#b8860b",
|
| 169 |
+
"location_tags": [],
|
| 170 |
+
"prop_tags": [],
|
| 171 |
+
"prompt_hint": "Dressed in formal attire with a polished appearance, 40s, wearing a suit and tie, A black suit and white shirt, neatly pressed and ironed"
|
| 172 |
+
}
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"sus_id": "S2",
|
| 176 |
+
"name": "Sarah Hart",
|
| 177 |
+
"role": "Evelyn's daughter, studying art history at university",
|
| 178 |
+
"persona_summary": "A bright and ambitious student who often visited her mother in the evenings.",
|
| 179 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 180 |
+
"is_culprit": false,
|
| 181 |
+
"physical_capability": {
|
| 182 |
+
"strength": true,
|
| 183 |
+
"mobility": true
|
| 184 |
+
},
|
| 185 |
+
"personality": {
|
| 186 |
+
"composure": 0.72,
|
| 187 |
+
"aggression": 0.4,
|
| 188 |
+
"evasiveness": 0.3
|
| 189 |
+
},
|
| 190 |
+
"tells": [
|
| 191 |
+
"She was just finishing her research on the Hart Family Collection when she received a call from her mother."
|
| 192 |
+
],
|
| 193 |
+
"knows_facts": [
|
| 194 |
+
"F_sec2"
|
| 195 |
+
],
|
| 196 |
+
"secrets": [
|
| 197 |
+
"She was secretly working on a thesis that could potentially expose her father’s fraudulent activities."
|
| 198 |
+
],
|
| 199 |
+
"true_whereabouts": [
|
| 200 |
+
{
|
| 201 |
+
"window": {
|
| 202 |
+
"start_min": 1260,
|
| 203 |
+
"end_min": 1320
|
| 204 |
+
},
|
| 205 |
+
"loc_id": "L2",
|
| 206 |
+
"activity": "going about the evening",
|
| 207 |
+
"co_present_sus_ids": []
|
| 208 |
+
}
|
| 209 |
+
],
|
| 210 |
+
"stated_alibi": {
|
| 211 |
+
"claim_text": "I was in Living Room the whole time.",
|
| 212 |
+
"claimed_segments": [
|
| 213 |
+
{
|
| 214 |
+
"window": {
|
| 215 |
+
"start_min": 1260,
|
| 216 |
+
"end_min": 1320
|
| 217 |
+
},
|
| 218 |
+
"loc_id": "L2",
|
| 219 |
+
"witness_sus_ids": []
|
| 220 |
+
}
|
| 221 |
+
]
|
| 222 |
+
},
|
| 223 |
+
"must_lie_about": [
|
| 224 |
+
"F_sec2"
|
| 225 |
+
],
|
| 226 |
+
"anchored_lies": [
|
| 227 |
+
{
|
| 228 |
+
"lie_id": "LIE_sec2",
|
| 229 |
+
"topic": "She was secretly working on a thesis that could ",
|
| 230 |
+
"claimed": "She was simply helping her mother with research for an upcoming exhibition.",
|
| 231 |
+
"truth_ref": "F_sec2",
|
| 232 |
+
"breaks_on": [
|
| 233 |
+
"C_h2"
|
| 234 |
+
],
|
| 235 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the disappearance."
|
| 236 |
+
}
|
| 237 |
+
],
|
| 238 |
+
"voice": null,
|
| 239 |
+
"visual": {
|
| 240 |
+
"subject_type": "suspect",
|
| 241 |
+
"palette": "noir",
|
| 242 |
+
"gender": "female",
|
| 243 |
+
"age_band": "20s",
|
| 244 |
+
"build": null,
|
| 245 |
+
"hair": null,
|
| 246 |
+
"attire": "A tailored suit and high heels, professional appearance",
|
| 247 |
+
"mood": "tense",
|
| 248 |
+
"accent_color": "#3a6ea5",
|
| 249 |
+
"location_tags": [],
|
| 250 |
+
"prop_tags": [],
|
| 251 |
+
"prompt_hint": "Dressed in academic attire, 20s, wearing a smart suit and heels, A tailored suit and high heels, professional appearance"
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"sus_id": "S3",
|
| 256 |
+
"name": "Victor Langley",
|
| 257 |
+
"role": "A rival curator at another prestigious museum, known for his unethical practices",
|
| 258 |
+
"persona_summary": "An aggressive and confrontational individual who often competed with Evelyn in exhibitions.",
|
| 259 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 260 |
+
"is_culprit": false,
|
| 261 |
+
"physical_capability": {
|
| 262 |
+
"strength": true,
|
| 263 |
+
"mobility": true
|
| 264 |
+
},
|
| 265 |
+
"personality": {
|
| 266 |
+
"composure": 0.32,
|
| 267 |
+
"aggression": 0.55,
|
| 268 |
+
"evasiveness": 0.68
|
| 269 |
+
},
|
| 270 |
+
"tells": [
|
| 271 |
+
"He was just finishing his presentation when he received a call from Evelyn."
|
| 272 |
+
],
|
| 273 |
+
"knows_facts": [
|
| 274 |
+
"F_sec3"
|
| 275 |
+
],
|
| 276 |
+
"secrets": [
|
| 277 |
+
"He was secretly involved in a scheme to steal the Hart Family Collection's most valuable pieces."
|
| 278 |
+
],
|
| 279 |
+
"true_whereabouts": [
|
| 280 |
+
{
|
| 281 |
+
"window": {
|
| 282 |
+
"start_min": 1260,
|
| 283 |
+
"end_min": 1320
|
| 284 |
+
},
|
| 285 |
+
"loc_id": "L4",
|
| 286 |
+
"activity": "going about the evening",
|
| 287 |
+
"co_present_sus_ids": []
|
| 288 |
+
}
|
| 289 |
+
],
|
| 290 |
+
"stated_alibi": {
|
| 291 |
+
"claim_text": "I was in Bathroom the whole time.",
|
| 292 |
+
"claimed_segments": [
|
| 293 |
+
{
|
| 294 |
+
"window": {
|
| 295 |
+
"start_min": 1260,
|
| 296 |
+
"end_min": 1320
|
| 297 |
+
},
|
| 298 |
+
"loc_id": "L4",
|
| 299 |
+
"witness_sus_ids": []
|
| 300 |
+
}
|
| 301 |
+
]
|
| 302 |
+
},
|
| 303 |
+
"must_lie_about": [
|
| 304 |
+
"F_sec3"
|
| 305 |
+
],
|
| 306 |
+
"anchored_lies": [
|
| 307 |
+
{
|
| 308 |
+
"lie_id": "LIE_sec3",
|
| 309 |
+
"topic": "He was secretly involved in a scheme to steal th",
|
| 310 |
+
"claimed": "He was simply trying to improve his reputation by showcasing rare art from other collections.",
|
| 311 |
+
"truth_ref": "F_sec3",
|
| 312 |
+
"breaks_on": [
|
| 313 |
+
"C_h3"
|
| 314 |
+
],
|
| 315 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the disappearance."
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"voice": null,
|
| 319 |
+
"visual": {
|
| 320 |
+
"subject_type": "suspect",
|
| 321 |
+
"palette": "noir",
|
| 322 |
+
"gender": "male",
|
| 323 |
+
"age_band": null,
|
| 324 |
+
"build": null,
|
| 325 |
+
"hair": null,
|
| 326 |
+
"attire": "A black suit and white shirt, polished appearance",
|
| 327 |
+
"mood": "tense",
|
| 328 |
+
"accent_color": "#9a9aa0",
|
| 329 |
+
"location_tags": [],
|
| 330 |
+
"prop_tags": [],
|
| 331 |
+
"prompt_hint": "Dressed in formal attire with a stern expression, 50s, wearing a suit and tie, A black suit and white shirt, polished appearance"
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sus_id": "S4",
|
| 336 |
+
"name": "Edmund Bell",
|
| 337 |
+
"role": "A wealthy businessman who had been inquiring about the Hart Family Collection's potential for investment",
|
| 338 |
+
"persona_summary": "An aggressive and demanding partner who often pressured Evelyn to increase her collection.",
|
| 339 |
+
"demeanour": "guarded and weary, giving away as little as possible",
|
| 340 |
+
"is_culprit": false,
|
| 341 |
+
"physical_capability": {
|
| 342 |
+
"strength": true,
|
| 343 |
+
"mobility": true
|
| 344 |
+
},
|
| 345 |
+
"personality": {
|
| 346 |
+
"composure": 0.6,
|
| 347 |
+
"aggression": 0.25,
|
| 348 |
+
"evasiveness": 0.55
|
| 349 |
+
},
|
| 350 |
+
"tells": [
|
| 351 |
+
"He was just finishing his presentation when he received a call from Evelyn."
|
| 352 |
+
],
|
| 353 |
+
"knows_facts": [
|
| 354 |
+
"F_sec4"
|
| 355 |
+
],
|
| 356 |
+
"secrets": [
|
| 357 |
+
"He was secretly involved in a scheme to steal the Hart Family Collection’s most valuable pieces."
|
| 358 |
+
],
|
| 359 |
+
"true_whereabouts": [
|
| 360 |
+
{
|
| 361 |
+
"window": {
|
| 362 |
+
"start_min": 1260,
|
| 363 |
+
"end_min": 1320
|
| 364 |
+
},
|
| 365 |
+
"loc_id": "L1",
|
| 366 |
+
"activity": "going about the evening",
|
| 367 |
+
"co_present_sus_ids": []
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"stated_alibi": {
|
| 371 |
+
"claim_text": "I was in Entrance Hall the whole time.",
|
| 372 |
+
"claimed_segments": [
|
| 373 |
+
{
|
| 374 |
+
"window": {
|
| 375 |
+
"start_min": 1260,
|
| 376 |
+
"end_min": 1320
|
| 377 |
+
},
|
| 378 |
+
"loc_id": "L1",
|
| 379 |
+
"witness_sus_ids": []
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"must_lie_about": [
|
| 384 |
+
"F_sec4"
|
| 385 |
+
],
|
| 386 |
+
"anchored_lies": [
|
| 387 |
+
{
|
| 388 |
+
"lie_id": "LIE_sec4",
|
| 389 |
+
"topic": "He was secretly involved in a scheme to steal th",
|
| 390 |
+
"claimed": "He was simply trying to secure a lucrative deal by showcasing rare art from other collections.",
|
| 391 |
+
"truth_ref": "F_sec4",
|
| 392 |
+
"breaks_on": [
|
| 393 |
+
"C_h4"
|
| 394 |
+
],
|
| 395 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the disappearance."
|
| 396 |
+
}
|
| 397 |
+
],
|
| 398 |
+
"voice": null,
|
| 399 |
+
"visual": {
|
| 400 |
+
"subject_type": "suspect",
|
| 401 |
+
"palette": "noir",
|
| 402 |
+
"gender": "male",
|
| 403 |
+
"age_band": null,
|
| 404 |
+
"build": null,
|
| 405 |
+
"hair": null,
|
| 406 |
+
"attire": "A black suit and white shirt, polished appearance",
|
| 407 |
+
"mood": "tense",
|
| 408 |
+
"accent_color": "#6b8f71",
|
| 409 |
+
"location_tags": [],
|
| 410 |
+
"prop_tags": [],
|
| 411 |
+
"prompt_hint": "Dressed in formal attire with a stern expression, 50s, wearing a suit and tie, A black suit and white shirt, polished appearance"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
],
|
| 415 |
+
"culprit": {
|
| 416 |
+
"sus_id": "S1",
|
| 417 |
+
"true_motive": {
|
| 418 |
+
"motive_id": "M1",
|
| 419 |
+
"category": "revenge",
|
| 420 |
+
"summary": "James Hart wanted Evelyn Hart's collection, and the abduction was part of a scheme to take it back."
|
| 421 |
+
},
|
| 422 |
+
"method_narrative": "He lured her from the kitchen using a distraction around 21:00, then made false claims about never leaving the bathroom.",
|
| 423 |
+
"alibi_lie": {
|
| 424 |
+
"claimed_loc_id": "L4",
|
| 425 |
+
"actual_loc_id": "L3",
|
| 426 |
+
"contradicted_by_clue_ids": [
|
| 427 |
+
"C_b1",
|
| 428 |
+
"C_b2"
|
| 429 |
+
]
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"relationships": [],
|
| 433 |
+
"facts": [
|
| 434 |
+
{
|
| 435 |
+
"fact_id": "F_scene",
|
| 436 |
+
"statement": "James Hart was in Kitchen during the disappearance.",
|
| 437 |
+
"true_value": true,
|
| 438 |
+
"loc_id": "L3",
|
| 439 |
+
"at_min": 1280
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"fact_id": "F_sec1",
|
| 443 |
+
"statement": "He was secretly involved in a fraudulent art trade that had been discovered by his wife.",
|
| 444 |
+
"true_value": true,
|
| 445 |
+
"loc_id": null,
|
| 446 |
+
"at_min": null
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"fact_id": "F_sec2",
|
| 450 |
+
"statement": "She was secretly working on a thesis that could potentially expose her father’s fraudulent activities.",
|
| 451 |
+
"true_value": true,
|
| 452 |
+
"loc_id": null,
|
| 453 |
+
"at_min": null
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"fact_id": "F_sec3",
|
| 457 |
+
"statement": "He was secretly involved in a scheme to steal the Hart Family Collection's most valuable pieces.",
|
| 458 |
+
"true_value": true,
|
| 459 |
+
"loc_id": null,
|
| 460 |
+
"at_min": null
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"fact_id": "F_sec4",
|
| 464 |
+
"statement": "He was secretly involved in a scheme to steal the Hart Family Collection’s most valuable pieces.",
|
| 465 |
+
"true_value": true,
|
| 466 |
+
"loc_id": null,
|
| 467 |
+
"at_min": null
|
| 468 |
+
}
|
| 469 |
+
],
|
| 470 |
+
"clues": [
|
| 471 |
+
{
|
| 472 |
+
"clue_id": "C_b1",
|
| 473 |
+
"name": "Dropped cufflink",
|
| 474 |
+
"reveal_text": "A monogrammed cufflink wedged under the rug, dropped in haste.",
|
| 475 |
+
"discoverable_at_loc_id": "L3",
|
| 476 |
+
"discovery_method": "forensic",
|
| 477 |
+
"supports_fact_id": "F_scene",
|
| 478 |
+
"points_to_sus_id": "S1",
|
| 479 |
+
"contradicts_alibi_of": "S1",
|
| 480 |
+
"is_red_herring": false,
|
| 481 |
+
"weight": 1.0
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"clue_id": "C_b2",
|
| 485 |
+
"name": "Snagged thread",
|
| 486 |
+
"reveal_text": "A torn thread of dark cloth snagged on the kitchen doorframe.",
|
| 487 |
+
"discoverable_at_loc_id": "L1",
|
| 488 |
+
"discovery_method": "forensic",
|
| 489 |
+
"supports_fact_id": "F_scene",
|
| 490 |
+
"points_to_sus_id": "S1",
|
| 491 |
+
"contradicts_alibi_of": "S1",
|
| 492 |
+
"is_red_herring": false,
|
| 493 |
+
"weight": 0.7
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"clue_id": "C_h2",
|
| 497 |
+
"name": "A receipt from a university library, showing she had been researching art history extensively",
|
| 498 |
+
"reveal_text": "The receipt revealed that Sarah had borrowed books and materials related to the Hart Family Collection for weeks. Her notes were filled with references to fraudulent transactions.",
|
| 499 |
+
"discoverable_at_loc_id": "L2",
|
| 500 |
+
"discovery_method": "search",
|
| 501 |
+
"supports_fact_id": "F_sec2",
|
| 502 |
+
"points_to_sus_id": "S2",
|
| 503 |
+
"contradicts_alibi_of": null,
|
| 504 |
+
"is_red_herring": true,
|
| 505 |
+
"weight": 0.3
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"clue_id": "C_h3",
|
| 509 |
+
"name": "A false telegram promising a high-profile exhibition, signed 'Victor Langley'",
|
| 510 |
+
"reveal_text": "The telegram revealed that Rival had been planning the theft for months and had even arranged for a fake curator's credentials to be used. He was eager to present his work as an expert.",
|
| 511 |
+
"discoverable_at_loc_id": "L4",
|
| 512 |
+
"discovery_method": "search",
|
| 513 |
+
"supports_fact_id": "F_sec3",
|
| 514 |
+
"points_to_sus_id": "S3",
|
| 515 |
+
"contradicts_alibi_of": null,
|
| 516 |
+
"is_red_herring": true,
|
| 517 |
+
"weight": 0.3
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"clue_id": "C_h4",
|
| 521 |
+
"name": "A false note promising a high commission, signed 'Edmund Bell'",
|
| 522 |
+
"reveal_text": "The note revealed that Business had been planning the theft for months and had even arranged for a fake curator's credentials to be used. He was eager to present his work as an expert.",
|
| 523 |
+
"discoverable_at_loc_id": "L1",
|
| 524 |
+
"discovery_method": "search",
|
| 525 |
+
"supports_fact_id": "F_sec4",
|
| 526 |
+
"points_to_sus_id": "S4",
|
| 527 |
+
"contradicts_alibi_of": null,
|
| 528 |
+
"is_red_herring": true,
|
| 529 |
+
"weight": 0.3
|
| 530 |
+
}
|
| 531 |
+
],
|
| 532 |
+
"solution": {
|
| 533 |
+
"culprit_sus_id": "S1",
|
| 534 |
+
"weapon_id": "W1",
|
| 535 |
+
"motive_id": "M1",
|
| 536 |
+
"minimal_clue_set": [
|
| 537 |
+
"C_b1"
|
| 538 |
+
],
|
| 539 |
+
"deduction_chain": [
|
| 540 |
+
"James Hart's motive: He wanted Evelyn's collection, which led to her disappearance as an accident.",
|
| 541 |
+
"Evidence in the Kitchen: A dropped object with monogrammed initials shows it was left there by James Hart before he claimed she never left.",
|
| 542 |
+
"Fingerprint found on a piece of fabric inside a torn corner of a tablecloth points directly at him as the abductor."
|
| 543 |
+
]
|
| 544 |
+
}
|
| 545 |
+
}
|
docs/FIELD_NOTES.md
CHANGED
|
@@ -4,15 +4,22 @@
|
|
| 4 |
|
| 5 |
## The pitch
|
| 6 |
|
| 7 |
-
Case Zero is a
|
| 8 |
**1.5B-parameter model** (Qwen2.5-1.5B-Instruct, Q4_K_M GGUF) invents the entire mystery
|
| 9 |
-
every time you play -
|
| 10 |
-
|
|
|
|
| 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
|
|
@@ -54,9 +61,13 @@ The Space runs on HF `cpu-basic` (2 vCPUs). Two findings mattered most:
|
|
| 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.
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 4 |
|
| 5 |
## The pitch
|
| 6 |
|
| 7 |
+
Case Zero is a detective 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 - a homicide, a heist, a fraud, a blackmail ring, an arson, a
|
| 10 |
+
disappearance: the victim, the suspects, their secrets and motives, the timeline, the
|
| 11 |
+
evidence, and the one who did it - and then **role-plays every suspect live**.
|
| 12 |
They remember what you asked. They lie. And when you present the piece of evidence that
|
| 13 |
contradicts an alibi, the lie cracks on screen.
|
| 14 |
|
| 15 |
The whole thing runs on the CPU in front of you. No cloud, no GPU, no remote endpoint.
|
| 16 |
|
| 17 |
+
One structure, many crimes: the solver only ever checks *structure* (one culprit, a false
|
| 18 |
+
alibi contradicted by discoverable physical evidence, every innocent cleared), so the same
|
| 19 |
+
fairness guarantees hold whether the case is a murder or a vanished heirloom. A single
|
| 20 |
+
frozen "crime profile" table rewords the prompts, the suspect briefs, and the dossier
|
| 21 |
+
labels per kind - the engine itself never changes.
|
| 22 |
+
|
| 23 |
## The hard part: a tiny model that's still *fair*
|
| 24 |
|
| 25 |
The interesting tension in this project is that a 1.5B model is a wonderful improviser and a
|
|
|
|
| 61 |
2. **Count the cores you actually have.** Inside the container `os.cpu_count()` returns the
|
| 62 |
*host's* cores, not the 2-vCPU cgroup quota. Auto-threading then spawned ~8 threads for 2
|
| 63 |
real cores and pinned the CPU at 100%+ on context switches - replies crawled. The fix
|
| 64 |
+
reads `/sys/fs/cgroup/cpu.max` and sizes the llama thread pool to the real quota.
|
| 65 |
+
|
| 66 |
+
Background case generation runs even on the 2-vCPU box without ever making a player wait:
|
| 67 |
+
each generation call holds the single-flight model lock for just that one call, streams,
|
| 68 |
+
and **aborts between tokens** the moment a player asks a question - then resumes once the
|
| 69 |
+
table has been idle. Fresh AI cases join a shuffled pre-baked pool, so New Case is always
|
| 70 |
+
instant and the pool of mysteries grows for as long as the Space stays up.
|
| 71 |
|
| 72 |
There is also no image work on the request path: all pixel art is rendered **client-side on
|
| 73 |
canvas**, so the server spends ~0 CPU on visuals and devotes both vCPUs to the model. Voices
|
scripts/deploy_space.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Push the working tree to the Hugging Face Space as ONE normal commit (never squash).
|
| 2 |
+
|
| 3 |
+
Mirrors .gitignore: weights, caches, node_modules, dist, runtime cases, and local trace
|
| 4 |
+
exports stay out; the Docker build compiles llama.cpp and rebuilds the SPA bundle itself.
|
| 5 |
+
Remote-only files (e.g. partner-committed assets) are preserved - no delete patterns.
|
| 6 |
+
|
| 7 |
+
python scripts/deploy_space.py "commit message"
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
|
| 14 |
+
from huggingface_hub import HfApi
|
| 15 |
+
|
| 16 |
+
SPACE_ID = "build-small-hackathon/case0"
|
| 17 |
+
|
| 18 |
+
IGNORE = [
|
| 19 |
+
".git/**", ".venv/**", "**/__pycache__/**", "*.pyc", "*.egg-info/**",
|
| 20 |
+
".pytest_cache/**", ".mypy_cache/**", ".ruff_cache/**", ".coverage", "htmlcov/**",
|
| 21 |
+
"models/**", "assets/voices/*.onnx", "assets/voices/*.onnx.json",
|
| 22 |
+
"cases/runtime/**", ".cache/**", "*.log", ".DS_Store", "Thumbs.db",
|
| 23 |
+
"assets/sprites/cache/**", ".playwright-mcp/**",
|
| 24 |
+
"web/node_modules/**", "web/dist/**", "web/.vite/**",
|
| 25 |
+
".env", "traces/**", "web/gallery.html", "web/src/gallery-entry.ts", "*.png",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> int:
|
| 30 |
+
message = sys.argv[1] if len(sys.argv) > 1 else "chore: update Space"
|
| 31 |
+
api = HfApi()
|
| 32 |
+
info = api.upload_folder(
|
| 33 |
+
repo_id=SPACE_ID,
|
| 34 |
+
repo_type="space",
|
| 35 |
+
folder_path=".",
|
| 36 |
+
ignore_patterns=IGNORE,
|
| 37 |
+
commit_message=message,
|
| 38 |
+
)
|
| 39 |
+
print(f"pushed: {info.commit_url}")
|
| 40 |
+
return 0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
raise SystemExit(main())
|
scripts/export_traces.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export REAL agent traces for the Hub (Build Small "Sharing is Caring" badge).
|
| 2 |
+
|
| 3 |
+
Captures two genuine traces from the live, fully-local stack:
|
| 4 |
+
1. CASE GENERATION - every prompt the pipeline sends to the in-process llama.cpp model
|
| 5 |
+
and the raw completion that came back, for one complete authored case;
|
| 6 |
+
2. LIVE INTERROGATION - a short playthrough against the served case: questions (one with
|
| 7 |
+
evidence presented), the suspect's spoken reply, and the server-authoritative
|
| 8 |
+
suspicion/flags, with wall-clock latency per turn.
|
| 9 |
+
|
| 10 |
+
Writes ``traces/case0_traces.jsonl`` + ``traces/README.md``. Upload with:
|
| 11 |
+
|
| 12 |
+
python scripts/export_traces.py # produce the files
|
| 13 |
+
python scripts/export_traces.py --push # produce AND push to the Hub dataset
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 24 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 25 |
+
|
| 26 |
+
from case_zero.config import get_settings # noqa: E402
|
| 27 |
+
from case_zero.generator.pipeline import generate_case # noqa: E402
|
| 28 |
+
from case_zero.llm.backend import GenParams, LLMBackend, make_backend # noqa: E402
|
| 29 |
+
|
| 30 |
+
DATASET_ID = "HusseinEid/case0-traces"
|
| 31 |
+
OUT_DIR = ROOT / "traces"
|
| 32 |
+
|
| 33 |
+
_DATASET_README = """---
|
| 34 |
+
license: apache-2.0
|
| 35 |
+
tags:
|
| 36 |
+
- build-small-hackathon
|
| 37 |
+
- agent-trace
|
| 38 |
+
- text-generation
|
| 39 |
+
pretty_name: Case Zero agent traces
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
# Case Zero - agent traces
|
| 43 |
+
|
| 44 |
+
Real traces from [Case Zero](https://huggingface.co/spaces/build-small-hackathon/case0),
|
| 45 |
+
a procedural detective game where a single **Qwen2.5-1.5B** model (in-process llama.cpp,
|
| 46 |
+
CPU-only, no cloud APIs) authors a complete mystery and then role-plays every suspect
|
| 47 |
+
live under interrogation.
|
| 48 |
+
|
| 49 |
+
`case0_traces.jsonl` - one JSON object per line:
|
| 50 |
+
|
| 51 |
+
- `type: "generation_call"` - one pipeline LLM call while authoring a case: the exact
|
| 52 |
+
`prompt`, the raw `completion`, sampling params, and latency. Two calls author a full
|
| 53 |
+
case (world+cast, then mystery); deterministic Python assembles and solver-checks it.
|
| 54 |
+
- `type: "interrogation_turn"` - one live turn against the running game server: the
|
| 55 |
+
player's `question` (optionally `presented_clue`), the suspect's spoken `reply`, and
|
| 56 |
+
the server-authoritative `suspicion` / `flags` that came back, with latency.
|
| 57 |
+
|
| 58 |
+
Everything was produced by the shipped game code - no hand-editing, no cloud calls.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class _TracingBackend:
|
| 63 |
+
"""Wraps the real backend and records every (prompt, completion) pair."""
|
| 64 |
+
|
| 65 |
+
def __init__(self, inner: LLMBackend) -> None:
|
| 66 |
+
self._inner = inner
|
| 67 |
+
self.calls: list[dict] = []
|
| 68 |
+
|
| 69 |
+
def generate(self, prompt: str, params: GenParams) -> str:
|
| 70 |
+
t0 = time.time()
|
| 71 |
+
out = self._inner.generate(prompt, params)
|
| 72 |
+
self.calls.append({
|
| 73 |
+
"type": "generation_call",
|
| 74 |
+
"prompt": prompt,
|
| 75 |
+
"completion": out,
|
| 76 |
+
"temperature": params.temperature,
|
| 77 |
+
"max_tokens": params.max_tokens,
|
| 78 |
+
"constrained": bool(params.grammar or params.json_schema),
|
| 79 |
+
"latency_s": round(time.time() - t0, 2),
|
| 80 |
+
})
|
| 81 |
+
return out
|
| 82 |
+
|
| 83 |
+
def stream(self, prompt: str, params: GenParams):
|
| 84 |
+
yield self.generate(prompt, params)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _generation_trace(records: list[dict]) -> None:
|
| 88 |
+
backend = _TracingBackend(make_backend(get_settings()))
|
| 89 |
+
result = generate_case(backend, seed=77321)
|
| 90 |
+
for call in backend.calls:
|
| 91 |
+
records.append(call)
|
| 92 |
+
records.append({
|
| 93 |
+
"type": "generation_result",
|
| 94 |
+
"case_id": result.case.case_id,
|
| 95 |
+
"crime_kind": result.case.crime_kind.value,
|
| 96 |
+
"title": result.case.title,
|
| 97 |
+
"solvable": result.report.ok,
|
| 98 |
+
"attempts": result.attempts,
|
| 99 |
+
"n_suspects": len(result.case.suspects),
|
| 100 |
+
"n_clues": len(result.case.clues),
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _interrogation_trace(records: list[dict]) -> None:
|
| 105 |
+
from starlette.testclient import TestClient
|
| 106 |
+
|
| 107 |
+
from case_zero.api.server import build_server
|
| 108 |
+
|
| 109 |
+
client = TestClient(build_server())
|
| 110 |
+
case = client.post("/api/case", json={}).json()
|
| 111 |
+
run_id = case["runId"]
|
| 112 |
+
pub = case["case"]
|
| 113 |
+
records.append({
|
| 114 |
+
"type": "case_served",
|
| 115 |
+
"case_id": pub["id"],
|
| 116 |
+
"kind": pub.get("kind", "homicide"),
|
| 117 |
+
"title": pub["title"],
|
| 118 |
+
})
|
| 119 |
+
sus = pub["suspects"]
|
| 120 |
+
breaking = pub["evidence"][0]["id"]
|
| 121 |
+
plan = [
|
| 122 |
+
(sus[0]["id"], "Where were you when it happened?", None),
|
| 123 |
+
(sus[0]["id"], "Did you have any quarrel with the victim?", None),
|
| 124 |
+
(sus[1]["id"], "Walk me through your evening, minute by minute.", None),
|
| 125 |
+
(sus[1]["id"], "Explain this.", breaking),
|
| 126 |
+
]
|
| 127 |
+
for sus_id, question, clue in plan:
|
| 128 |
+
t0 = time.time()
|
| 129 |
+
body: dict = {"freeText": question}
|
| 130 |
+
if clue:
|
| 131 |
+
body["presentEvidenceId"] = clue
|
| 132 |
+
r = client.post(f"/api/run/{run_id}/interrogate/{sus_id}", json=body).json()
|
| 133 |
+
records.append({
|
| 134 |
+
"type": "interrogation_turn",
|
| 135 |
+
"suspect": sus_id,
|
| 136 |
+
"question": question,
|
| 137 |
+
"presented_clue": clue,
|
| 138 |
+
"reply": r.get("reply"),
|
| 139 |
+
"suspicion": r.get("suspicion"),
|
| 140 |
+
"suspicion_delta": r.get("suspicionDelta"),
|
| 141 |
+
"flags": r.get("flags"),
|
| 142 |
+
"latency_s": round(time.time() - t0, 2),
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def main() -> int:
|
| 147 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 148 |
+
records: list[dict] = []
|
| 149 |
+
print("tracing one full case generation (two model calls)...")
|
| 150 |
+
_generation_trace(records)
|
| 151 |
+
print("tracing a live interrogation playthrough...")
|
| 152 |
+
_interrogation_trace(records)
|
| 153 |
+
|
| 154 |
+
path = OUT_DIR / "case0_traces.jsonl"
|
| 155 |
+
with path.open("w", encoding="utf-8") as fh:
|
| 156 |
+
for rec in records:
|
| 157 |
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 158 |
+
(OUT_DIR / "README.md").write_text(_DATASET_README, encoding="utf-8")
|
| 159 |
+
print(f"wrote {len(records)} records -> {path}")
|
| 160 |
+
|
| 161 |
+
if "--push" in sys.argv:
|
| 162 |
+
from huggingface_hub import HfApi
|
| 163 |
+
|
| 164 |
+
api = HfApi()
|
| 165 |
+
api.create_repo(DATASET_ID, repo_type="dataset", exist_ok=True)
|
| 166 |
+
api.upload_folder(repo_id=DATASET_ID, repo_type="dataset", folder_path=str(OUT_DIR))
|
| 167 |
+
print(f"pushed -> https://huggingface.co/datasets/{DATASET_ID}")
|
| 168 |
+
return 0
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
if __name__ == "__main__":
|
| 172 |
+
raise SystemExit(main())
|
scripts/prebake_cases.py
CHANGED
|
@@ -9,6 +9,9 @@ New Case while still running every interrogation live (and generating fresh case
|
|
| 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 |
|
|
@@ -26,7 +29,26 @@ 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",
|
|
@@ -38,10 +60,19 @@ _PLACEHOLDER_NAMES = {
|
|
| 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"
|
| 44 |
-
return bool("," in n or any(c.isdigit() for c in n)
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
def _name_prefix(n: str) -> str:
|
|
@@ -83,21 +114,25 @@ def _is_exciting(case: CaseFile) -> tuple[bool, str]:
|
|
| 83 |
|
| 84 |
|
| 85 |
def main() -> int:
|
| 86 |
-
target = int(sys.argv[1]) if len(sys.argv) > 1 else
|
| 87 |
-
start_seed = int(sys.argv[2]) if len(sys.argv) > 2 else
|
| 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
|
|
@@ -110,12 +145,13 @@ def main() -> int:
|
|
| 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
|
|
|
|
| 9 |
hardware allows). The pre-baked cases are authored by the local model - no cloud, still
|
| 10 |
Off-the-Grid.
|
| 11 |
|
| 12 |
+
New cases are APPENDED after the existing pool (existing Case IDs keep working as share
|
| 13 |
+
links) and cycle through the crime kinds so the pool is not all murders.
|
| 14 |
+
|
| 15 |
python scripts/prebake_cases.py [target_count] [start_seed]
|
| 16 |
"""
|
| 17 |
|
|
|
|
| 29 |
from case_zero.llm.backend import make_backend # noqa: E402
|
| 30 |
from case_zero.persistence.case_store import save_case # noqa: E402
|
| 31 |
from case_zero.persistence.paths import prebaked_cases_dir # noqa: E402
|
| 32 |
+
from case_zero.schemas.case import CaseFile, GenerationKnobs # noqa: E402
|
| 33 |
+
from case_zero.schemas.enums import CrimeKind # noqa: E402
|
| 34 |
+
|
| 35 |
+
# The existing pool is homicide-heavy, so new bakes lean into the other kinds first.
|
| 36 |
+
_KIND_PLAN: tuple[CrimeKind, ...] = (
|
| 37 |
+
CrimeKind.THEFT, CrimeKind.BLACKMAIL, CrimeKind.ARSON, CrimeKind.MISSING,
|
| 38 |
+
CrimeKind.FRAUD, CrimeKind.THEFT, CrimeKind.HOMICIDE, CrimeKind.MISSING,
|
| 39 |
+
CrimeKind.ARSON, CrimeKind.FRAUD, CrimeKind.HOMICIDE, CrimeKind.BLACKMAIL,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
_SMALL = {"a", "an", "and", "at", "but", "by", "for", "in", "of", "on", "or", "the", "to"}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _titlecase(raw: str) -> str:
|
| 46 |
+
words = (raw or "").strip().split()
|
| 47 |
+
out = []
|
| 48 |
+
for i, w in enumerate(words):
|
| 49 |
+
lw = w.lower()
|
| 50 |
+
out.append(lw if (i not in (0, len(words) - 1) and lw in _SMALL) else lw.capitalize())
|
| 51 |
+
return " ".join(out)
|
| 52 |
|
| 53 |
_BAD_ROLE = re.compile(
|
| 54 |
r"\b(detective|officer|investigator|police|inspector|sergeant|constable|cop|agent)\b",
|
|
|
|
| 60 |
"mary major", "john q public", "tom johnson", "tom smith", "jack smith", "jane roe",
|
| 61 |
"john brown", "bob smith", "foo bar", "first last", "name surname",
|
| 62 |
}
|
| 63 |
+
# A "name" that is really a role description ("Rival Curator", "Business Partner").
|
| 64 |
+
_ROLE_AS_NAME = re.compile(
|
| 65 |
+
r"\b(rival|partner|business|curator|servant|butler|maid|cousin|nephew|niece|heir|"
|
| 66 |
+
r"the\s|guest|stranger|visitor|neighbou?r|colleague|assistant|clerk|owner|manager)\b",
|
| 67 |
+
re.IGNORECASE,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
def _name_malformed(n: str) -> bool:
|
| 72 |
# The model sometimes bakes a gender/age/label into the name: "John Smith, Male",
|
| 73 |
+
# "Lara White, 45" - or hands back a role instead of a name ("Rival Curator").
|
| 74 |
+
return bool("," in n or any(c.isdigit() for c in n)
|
| 75 |
+
or re.search(r"\b(male|female)\b", n, re.I) or _ROLE_AS_NAME.search(n))
|
| 76 |
|
| 77 |
|
| 78 |
def _name_prefix(n: str) -> str:
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
def main() -> int:
|
| 117 |
+
target = int(sys.argv[1]) if len(sys.argv) > 1 else 8
|
| 118 |
+
start_seed = int(sys.argv[2]) if len(sys.argv) > 2 else 51000
|
| 119 |
max_attempts = target * 4 + 8
|
| 120 |
|
| 121 |
backend = make_backend(get_settings())
|
| 122 |
out_dir = prebaked_cases_dir()
|
| 123 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 124 |
+
existing = len(list(out_dir.glob("CASE-*.json")))
|
| 125 |
+
print(f"pool has {existing} cases; appending {target} new ones across crime kinds")
|
| 126 |
|
| 127 |
kept: list[CaseFile] = []
|
| 128 |
seed = start_seed
|
| 129 |
attempts = 0
|
| 130 |
while len(kept) < target and attempts < max_attempts:
|
| 131 |
attempts += 1
|
| 132 |
+
kind = _KIND_PLAN[len(kept) % len(_KIND_PLAN)]
|
| 133 |
try:
|
| 134 |
+
result = generate_case(backend, seed=seed,
|
| 135 |
+
knobs=GenerationKnobs(crime_kind=kind))
|
| 136 |
except Exception as exc: # generation hiccup - skip this seed
|
| 137 |
print(f"[seed {seed}] generation error: {exc}")
|
| 138 |
seed += 1
|
|
|
|
| 145 |
if not ok:
|
| 146 |
print(f"[seed {seed - 1}] rejected: {why} -- '{result.case.title}'")
|
| 147 |
continue
|
| 148 |
+
case_id = f"CASE-{existing + len(kept) + 1:04d}"
|
| 149 |
+
case = result.case.model_copy(update={"case_id": case_id,
|
| 150 |
+
"title": _titlecase(result.case.title)})
|
| 151 |
save_case(case, out_dir / f"{case_id}.json")
|
| 152 |
kept.append(case)
|
| 153 |
cast = ", ".join(f"{s.name} ({s.visual.gender[:1].upper()})" for s in case.suspects)
|
| 154 |
+
print(f"[KEEP {case_id}] ({kind.value}) '{case.title}' - victim {case.victim.name} | {cast}")
|
| 155 |
|
| 156 |
print(f"\nDONE: kept {len(kept)}/{target} in {attempts} attempts -> {out_dir}")
|
| 157 |
return 0 if kept else 1
|
src/case_zero/api/case_adapter.py
CHANGED
|
@@ -9,6 +9,9 @@ Generated evidence renders as paper exhibits (reveal_text); richer per-type payl
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
|
|
|
|
|
|
| 12 |
from ..schemas.case import CaseFile
|
| 13 |
from ..schemas.clue import Clue
|
| 14 |
from .public_view import (
|
|
@@ -49,7 +52,27 @@ _DECOY_MOTIVES = (
|
|
| 49 |
"To settle an old score, finally.",
|
| 50 |
"Fear of being exposed and ruined.",
|
| 51 |
)
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
@@ -111,7 +134,7 @@ def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
|
|
| 111 |
id=clue.clue_id,
|
| 112 |
name=clue.name.upper(),
|
| 113 |
type=clue.discovery_method.value.upper(),
|
| 114 |
-
icon=
|
| 115 |
time=time,
|
| 116 |
found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
|
| 117 |
summary=clue.reveal_text,
|
|
@@ -119,7 +142,7 @@ def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
|
|
| 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))
|
|
@@ -129,7 +152,11 @@ def _timeline(case: CaseFile) -> tuple[TimelineBeat, ...]:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
beats.sort(key=lambda b: b.time)
|
| 134 |
return tuple(beats)
|
| 135 |
|
|
@@ -167,20 +194,30 @@ def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
|
|
| 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=
|
| 175 |
-
StoryBeat(scene="
|
| 176 |
text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
|
| 177 |
-
StoryBeat(scene="
|
| 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)
|
|
@@ -196,26 +233,32 @@ def casefile_to_public(case: CaseFile) -> PublicCase:
|
|
| 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"
|
| 200 |
cause=case.victim.cause_of_death,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
facts=(
|
| 202 |
("CITY", city),
|
| 203 |
("VICTIM", f"{case.victim.name}"),
|
| 204 |
("SCENE", scene),
|
| 205 |
-
(
|
| 206 |
-
("
|
| 207 |
-
("VERDICT",
|
| 208 |
),
|
| 209 |
boot_lines=(
|
| 210 |
"The phone drags you up out of half a sleep.",
|
| 211 |
-
f"{case.setting.name}.
|
| 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 |
)
|
|
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
from ..generator.crime_profiles import CrimeProfile, profile_for
|
| 15 |
from ..schemas.case import CaseFile
|
| 16 |
from ..schemas.clue import Clue
|
| 17 |
from .public_view import (
|
|
|
|
| 52 |
"To settle an old score, finally.",
|
| 53 |
"Fear of being exposed and ruined.",
|
| 54 |
)
|
| 55 |
+
# Keyword-matched icons so a letter looks like paper and a keycard like a key -
|
| 56 |
+
# never a phone icon on a bloodied letter. First match wins; unmatched rotate.
|
| 57 |
+
_ICON_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
|
| 58 |
+
(re.compile(r"photo|polaroid|snapshot|portrait|negative|film", re.I), "photoEv"),
|
| 59 |
+
(re.compile(r"cctv|camera|footage|surveillance|still\b", re.I), "cctv"),
|
| 60 |
+
(re.compile(r"voicemail|recording|tape|audio|cylinder|dictaphone", re.I), "voicemail"),
|
| 61 |
+
(re.compile(r"phone|telephone|telegram|message|wire\b", re.I), "phone"),
|
| 62 |
+
(re.compile(r"key\b|keys\b|keycard|access|badge|pass\b|lock\b", re.I), "keycard"),
|
| 63 |
+
(re.compile(r"receipt|ticket|ledger|letter|note\b|paper|document|contract|deed|cheque|"
|
| 64 |
+
r"check\b|bill\b|pawn|invoice|book\b|journal|diary|stub", re.I), "receipt"),
|
| 65 |
+
(re.compile(r"map\b|compass|route|itinerary|timetable|schedule", re.I), "compass"),
|
| 66 |
+
)
|
| 67 |
+
_ICONS = ("photoEv", "receipt", "compass")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _icon_for(name: str, reveal: str, idx: int) -> str:
|
| 71 |
+
hay = f"{name} {reveal}"
|
| 72 |
+
for rule, icon in _ICON_RULES:
|
| 73 |
+
if rule.search(hay):
|
| 74 |
+
return icon
|
| 75 |
+
return _ICONS[idx % len(_ICONS)]
|
| 76 |
# Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
|
| 77 |
_CITIES = (
|
| 78 |
"Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
|
|
|
|
| 134 |
id=clue.clue_id,
|
| 135 |
name=clue.name.upper(),
|
| 136 |
type=clue.discovery_method.value.upper(),
|
| 137 |
+
icon=_icon_for(clue.name, clue.reveal_text, idx),
|
| 138 |
time=time,
|
| 139 |
found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
|
| 140 |
summary=clue.reveal_text,
|
|
|
|
| 142 |
)
|
| 143 |
|
| 144 |
|
| 145 |
+
def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
|
| 146 |
beats: list[TimelineBeat] = []
|
| 147 |
w = case.setting.murder_window
|
| 148 |
beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
|
|
|
|
| 152 |
t = at if at is not None else w.start_min + 7 * (i + 1)
|
| 153 |
conflict = clue.contradicts_alibi_of == culprit
|
| 154 |
beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
|
| 155 |
+
incident = profile.timeline_line.format(
|
| 156 |
+
name=case.victim.name, instrument=case.weapon.name,
|
| 157 |
+
room=_loc_name(case, case.victim.found_at_loc_id),
|
| 158 |
+
)
|
| 159 |
+
beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=incident, locked=True))
|
| 160 |
beats.sort(key=lambda b: b.time)
|
| 161 |
return tuple(beats)
|
| 162 |
|
|
|
|
| 194 |
return tuple(chosen[rot:] + chosen[:rot])
|
| 195 |
|
| 196 |
|
| 197 |
+
def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[StoryBeat, ...]:
|
| 198 |
+
"""Each beat's backdrop is the place its text describes: the city for the call, the
|
| 199 |
+
REAL crime scene for the incident, another room of the same building for the cast,
|
| 200 |
+
and the detective's desk for the hand-off."""
|
| 201 |
v = case.victim
|
| 202 |
+
crime_loc = case.victim.found_at_loc_id
|
| 203 |
+
building = case.setting.name
|
| 204 |
+
other_room = next(
|
| 205 |
+
(loc.name for loc in case.setting.locations if loc.loc_id != crime_loc),
|
| 206 |
+
_loc_name(case, crime_loc),
|
| 207 |
+
)
|
| 208 |
return (
|
| 209 |
StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
|
| 210 |
+
StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
|
| 211 |
+
StoryBeat(scene=f"{building} — {other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
|
| 212 |
text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
|
| 213 |
+
StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
|
| 214 |
text="One of them is lying to your face. Find the crack in the account and follow it down."),
|
| 215 |
)
|
| 216 |
|
| 217 |
|
| 218 |
def casefile_to_public(case: CaseFile) -> PublicCase:
|
| 219 |
seed = case.seed
|
| 220 |
+
profile = profile_for(case.crime_kind)
|
| 221 |
tod = case.victim.time_of_death.start_min
|
| 222 |
building = case.setting.name
|
| 223 |
room = _loc_name(case, case.victim.found_at_loc_id)
|
|
|
|
| 233 |
victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
|
| 234 |
scene=scene,
|
| 235 |
tod=_clock(tod),
|
| 236 |
+
found=f"{profile.found_verb} {scene} at {_clock(case.victim.found_at_min)}.",
|
| 237 |
cause=case.victim.cause_of_death,
|
| 238 |
+
kind=profile.kind.value,
|
| 239 |
+
kind_label=profile.kind_label,
|
| 240 |
+
division=profile.division,
|
| 241 |
+
victim_status=profile.victim_status,
|
| 242 |
+
tod_label=profile.tod_label,
|
| 243 |
+
verdict=profile.verdict,
|
| 244 |
facts=(
|
| 245 |
("CITY", city),
|
| 246 |
("VICTIM", f"{case.victim.name}"),
|
| 247 |
("SCENE", scene),
|
| 248 |
+
(profile.tod_label, _clock(tod)),
|
| 249 |
+
("WHAT HAPPENED", case.victim.cause_of_death),
|
| 250 |
+
("VERDICT", profile.verdict),
|
| 251 |
),
|
| 252 |
boot_lines=(
|
| 253 |
"The phone drags you up out of half a sleep.",
|
| 254 |
+
f"{case.setting.name}. {profile.boot_line}",
|
| 255 |
f"{case.victim.name} - {case.victim.role}.",
|
| 256 |
"They're holding the scene. It's yours now, detective.",
|
| 257 |
),
|
| 258 |
+
story_beats=_story_beats(case, profile, scene),
|
| 259 |
suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
|
| 260 |
evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
|
| 261 |
+
timeline=_timeline(case, profile),
|
| 262 |
flashback=_flashback(case),
|
| 263 |
motives=_motives(case, seed),
|
| 264 |
)
|
src/case_zero/api/hints.py
CHANGED
|
@@ -6,9 +6,9 @@ progress shape the guidance.
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
_BASE = {
|
| 9 |
-
"briefing": "Read the file before you read people. Note the time of
|
| 10 |
-
"turns on who could be where, and when.",
|
| 11 |
-
"story": "Don't look for the
|
| 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.",
|
|
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
_BASE = {
|
| 9 |
+
"briefing": "Read the file before you read people. Note the time of the incident - "
|
| 10 |
+
"everything turns on who could be where, and when.",
|
| 11 |
+
"story": "Don't look for the culprit 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.",
|
src/case_zero/api/public_view.py
CHANGED
|
@@ -116,6 +116,14 @@ class PublicCase(_Wire):
|
|
| 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, ...]
|
|
|
|
| 116 |
tod: str
|
| 117 |
found: str
|
| 118 |
cause: str
|
| 119 |
+
# Case-kind display labels. Defaulted to homicide so the golden case and every
|
| 120 |
+
# pre-kind stored case keep their exact current wording.
|
| 121 |
+
kind: str = "homicide"
|
| 122 |
+
kind_label: str = "HOMICIDE" # title screen: "A PROCEDURAL {kindLabel}"
|
| 123 |
+
division: str = "HOMICIDE DIVISION"
|
| 124 |
+
victim_status: str = "DECEASED" # dossier stamp next to the victim
|
| 125 |
+
tod_label: str = "T.O.D."
|
| 126 |
+
verdict: str = "Homicide" # KEY FACTS verdict line
|
| 127 |
facts: tuple[tuple[str, str], ...]
|
| 128 |
boot_lines: tuple[str, ...]
|
| 129 |
story_beats: tuple[StoryBeat, ...]
|
src/case_zero/api/runtime.py
CHANGED
|
@@ -3,23 +3,29 @@ 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 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -28,6 +34,40 @@ 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:
|
|
@@ -47,15 +87,19 @@ class GameRuntime:
|
|
| 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 |
-
#
|
| 56 |
-
#
|
| 57 |
-
|
| 58 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
# ---- backend ----
|
| 61 |
def _get_backend(self) -> LLMBackend | None:
|
|
@@ -74,22 +118,48 @@ class GameRuntime:
|
|
| 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 |
-
|
| 82 |
-
|
|
|
|
| 83 |
save_runtime_case(result.case)
|
| 84 |
return result.case
|
| 85 |
|
| 86 |
def _prebuild(self) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
try:
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
def _load_prebaked(self) -> None:
|
| 95 |
if self._prebaked_loaded:
|
|
@@ -103,19 +173,15 @@ class GameRuntime:
|
|
| 103 |
self._prebaked.append(load_case(path))
|
| 104 |
except Exception:
|
| 105 |
continue
|
| 106 |
-
#
|
| 107 |
-
#
|
| 108 |
-
|
| 109 |
-
self._prebaked_idx = self._seed % len(self._prebaked)
|
| 110 |
|
| 111 |
def start_buffer(self) -> None:
|
| 112 |
-
"""Make the first New Case instant
|
| 113 |
-
|
| 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 |
-
|
| 118 |
-
threading.Thread(target=self._prebuild, daemon=True).start()
|
| 119 |
|
| 120 |
def _take_buffered(self) -> CaseFile | None:
|
| 121 |
with self._buffer_lock:
|
|
@@ -127,14 +193,18 @@ class GameRuntime:
|
|
| 127 |
self._load_prebaked()
|
| 128 |
if not self._prebaked:
|
| 129 |
return None
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
self._prebaked_idx += 1
|
| 132 |
return case
|
| 133 |
|
| 134 |
def _maybe_refill(self) -> None:
|
| 135 |
-
"""
|
| 136 |
-
if self.
|
| 137 |
-
|
| 138 |
|
| 139 |
def new_generated_run(self) -> tuple[PublicCase, str] | None:
|
| 140 |
if not self.available():
|
|
@@ -190,11 +260,17 @@ class GameRuntime:
|
|
| 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
|
|
|
|
| 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 |
+
|
| 7 |
+
Background case generation runs on EVERY box, but it can never make a player wait:
|
| 8 |
+
each generation call holds the lock for just that one call (never the whole pipeline),
|
| 9 |
+
and on a small box it streams with an interrupt check between tokens - the moment a
|
| 10 |
+
player asks a question the in-flight generation aborts within a token, the lock frees,
|
| 11 |
+
and the turn runs. Generation resumes once the table has been idle for a while. Fresh
|
| 12 |
+
AI cases land in the buffer (served on the very next New Case) AND join the shuffled
|
| 13 |
+
rotation, so the pool of mysteries grows for as long as the Space stays up.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import random
|
| 19 |
import threading
|
| 20 |
import time
|
| 21 |
import uuid
|
| 22 |
+
from collections.abc import Iterator
|
| 23 |
from dataclasses import dataclass
|
| 24 |
|
| 25 |
from ..config import effective_cpus, get_settings
|
| 26 |
from ..engine.session import Session
|
| 27 |
from ..generator.pipeline import generate_case
|
| 28 |
+
from ..llm.backend import GenParams, LLMBackend, LLMError, make_backend
|
| 29 |
from ..persistence.case_store import load_case, save_runtime_case
|
| 30 |
from ..persistence.paths import prebaked_cases_dir, runtime_cases_dir
|
| 31 |
from ..schemas.accusation import Accusation
|
|
|
|
| 34 |
from .case_adapter import casefile_to_public
|
| 35 |
from .public_view import PublicCase
|
| 36 |
|
| 37 |
+
# How long the table must be quiet (no interrogation turn) before a small box starts -
|
| 38 |
+
# or resumes - a background generation.
|
| 39 |
+
_IDLE_SECS = 90.0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class _SharedLockBackend:
|
| 43 |
+
"""Per-call single-flight wrapper. Each model call holds the runtime lock for JUST
|
| 44 |
+
that call, so a player's turn waits behind at most one in-flight call - never a
|
| 45 |
+
whole multi-call generation. With an ``interrupt`` event the call streams internally
|
| 46 |
+
and aborts between tokens the moment a player shows up, freeing the lock at once."""
|
| 47 |
+
|
| 48 |
+
def __init__(self, inner: LLMBackend, lock: threading.Lock,
|
| 49 |
+
interrupt: threading.Event | None = None) -> None:
|
| 50 |
+
self._inner = inner
|
| 51 |
+
self._lock = lock
|
| 52 |
+
self._interrupt = interrupt
|
| 53 |
+
|
| 54 |
+
def generate(self, prompt: str, params: GenParams) -> str:
|
| 55 |
+
with self._lock:
|
| 56 |
+
if self._interrupt is None:
|
| 57 |
+
return self._inner.generate(prompt, params)
|
| 58 |
+
if self._interrupt.is_set():
|
| 59 |
+
raise LLMError("generation interrupted by player")
|
| 60 |
+
parts: list[str] = []
|
| 61 |
+
for delta in self._inner.stream(prompt, params):
|
| 62 |
+
parts.append(delta)
|
| 63 |
+
if self._interrupt.is_set():
|
| 64 |
+
raise LLMError("generation interrupted by player")
|
| 65 |
+
return "".join(parts)
|
| 66 |
+
|
| 67 |
+
def stream(self, prompt: str, params: GenParams) -> Iterator[str]:
|
| 68 |
+
with self._lock:
|
| 69 |
+
yield from self._inner.stream(prompt, params)
|
| 70 |
+
|
| 71 |
|
| 72 |
@dataclass
|
| 73 |
class LiveRun:
|
|
|
|
| 87 |
self._buffer: CaseFile | None = None
|
| 88 |
self._buffer_lock = threading.Lock()
|
| 89 |
self._seed = int(time.time()) % 900_000 + 1000
|
| 90 |
+
self._rng = random.Random(self._seed)
|
| 91 |
# Pre-baked pool: full, model-authored cases shipped with the Space, served instantly
|
| 92 |
# on New Case so nobody waits ~2 min for live generation. Interrogation is still live.
|
| 93 |
self._prebaked: list[CaseFile] = []
|
| 94 |
self._prebaked_idx = 0
|
| 95 |
self._prebaked_loaded = False
|
| 96 |
+
# Background generation: a fast box generates immediately; a small box (the 2-vCPU
|
| 97 |
+
# Space) waits for an idle table and aborts between tokens when a player shows up.
|
| 98 |
+
self._fast_box = effective_cpus() > 4
|
| 99 |
+
self._gen_interrupt = threading.Event()
|
| 100 |
+
self._gen_running = False
|
| 101 |
+
self._gen_flag_lock = threading.Lock()
|
| 102 |
+
self._last_player_ts = 0.0
|
| 103 |
|
| 104 |
# ---- backend ----
|
| 105 |
def _get_backend(self) -> LLMBackend | None:
|
|
|
|
| 118 |
return self._seed
|
| 119 |
|
| 120 |
# ---- generation ----
|
| 121 |
+
def _generate(self, seed: int, *, interruptible: bool = False) -> CaseFile:
|
| 122 |
backend = self._get_backend()
|
| 123 |
if backend is None:
|
| 124 |
raise LLMError("no backend")
|
| 125 |
+
wrapped = _SharedLockBackend(backend, self._lock,
|
| 126 |
+
self._gen_interrupt if interruptible else None)
|
| 127 |
+
result = generate_case(wrapped, seed=seed)
|
| 128 |
save_runtime_case(result.case)
|
| 129 |
return result.case
|
| 130 |
|
| 131 |
def _prebuild(self) -> None:
|
| 132 |
+
"""Generate ONE fresh AI case in the background, yielding to players. On a small
|
| 133 |
+
box: wait until the table is idle, abort between tokens if a player interrupts,
|
| 134 |
+
then wait for idle again and retry. The finished case is served on the very next
|
| 135 |
+
New Case and joins the rotation for good."""
|
| 136 |
try:
|
| 137 |
+
for _ in range(8):
|
| 138 |
+
try:
|
| 139 |
+
if not self._fast_box:
|
| 140 |
+
while time.time() - self._last_player_ts < _IDLE_SECS:
|
| 141 |
+
time.sleep(5)
|
| 142 |
+
case = self._generate(self._next_seed(), interruptible=not self._fast_box)
|
| 143 |
+
except LLMError:
|
| 144 |
+
continue # interrupted by a player, or malformed output - try again
|
| 145 |
+
except Exception:
|
| 146 |
+
break
|
| 147 |
+
with self._buffer_lock:
|
| 148 |
+
self._buffer = case
|
| 149 |
+
self._prebaked.append(case)
|
| 150 |
+
break
|
| 151 |
+
finally:
|
| 152 |
+
with self._gen_flag_lock:
|
| 153 |
+
self._gen_running = False
|
| 154 |
+
|
| 155 |
+
def _spawn_gen(self) -> None:
|
| 156 |
+
if not self.available():
|
| 157 |
+
return
|
| 158 |
+
with self._gen_flag_lock:
|
| 159 |
+
if self._gen_running:
|
| 160 |
+
return
|
| 161 |
+
self._gen_running = True
|
| 162 |
+
threading.Thread(target=self._prebuild, daemon=True).start()
|
| 163 |
|
| 164 |
def _load_prebaked(self) -> None:
|
| 165 |
if self._prebaked_loaded:
|
|
|
|
| 173 |
self._prebaked.append(load_case(path))
|
| 174 |
except Exception:
|
| 175 |
continue
|
| 176 |
+
# Shuffle per process (the seed is time-based) so the FIRST case of every fresh
|
| 177 |
+
# visit/restart is randomized - never the same opening mystery twice in a row.
|
| 178 |
+
self._rng.shuffle(self._prebaked)
|
|
|
|
| 179 |
|
| 180 |
def start_buffer(self) -> None:
|
| 181 |
+
"""Make the first New Case instant (shipped pool, shuffled) and start growing the
|
| 182 |
+
pool with a fresh AI-generated case in the background."""
|
|
|
|
|
|
|
| 183 |
self._load_prebaked()
|
| 184 |
+
self._spawn_gen()
|
|
|
|
| 185 |
|
| 186 |
def _take_buffered(self) -> CaseFile | None:
|
| 187 |
with self._buffer_lock:
|
|
|
|
| 193 |
self._load_prebaked()
|
| 194 |
if not self._prebaked:
|
| 195 |
return None
|
| 196 |
+
if self._prebaked_idx >= len(self._prebaked):
|
| 197 |
+
# Bag exhausted: reshuffle for a fresh order on the next lap.
|
| 198 |
+
self._rng.shuffle(self._prebaked)
|
| 199 |
+
self._prebaked_idx = 0
|
| 200 |
+
case = self._prebaked[self._prebaked_idx]
|
| 201 |
self._prebaked_idx += 1
|
| 202 |
return case
|
| 203 |
|
| 204 |
def _maybe_refill(self) -> None:
|
| 205 |
+
"""Keep one fresh AI case cooking in the background whenever the buffer is empty."""
|
| 206 |
+
if self._buffer is None:
|
| 207 |
+
self._spawn_gen()
|
| 208 |
|
| 209 |
def new_generated_run(self) -> tuple[PublicCase, str] | None:
|
| 210 |
if not self.available():
|
|
|
|
| 260 |
self, run: LiveRun, sus_id: str, question: str, clue_id: str | None
|
| 261 |
) -> dict:
|
| 262 |
prev = self._suspicion(run, sus_id)
|
| 263 |
+
# Tell any in-flight background generation to yield the lock NOW (it aborts
|
| 264 |
+
# between tokens), then take the table.
|
| 265 |
+
self._gen_interrupt.set()
|
| 266 |
+
self._last_player_ts = time.time()
|
| 267 |
with self._lock:
|
| 268 |
+
self._gen_interrupt.clear()
|
| 269 |
final = None
|
| 270 |
for ev in run.session.interrogate(sus_id, question, presented_clue_id=clue_id):
|
| 271 |
if ev.final is not None:
|
| 272 |
final = ev.final
|
| 273 |
+
self._last_player_ts = time.time()
|
| 274 |
reply = final.turn.spoken if final else "…I have nothing to say to that."
|
| 275 |
after = self._suspicion(run, sus_id)
|
| 276 |
adj = final.adjudication if final else None
|
src/case_zero/generator/assemble.py
CHANGED
|
@@ -36,6 +36,7 @@ from ..schemas.timeline import (
|
|
| 36 |
WhereaboutsSegment,
|
| 37 |
)
|
| 38 |
from ..schemas.visual import VisualDescriptor
|
|
|
|
| 39 |
from .stages import MysteryOut, WorldCastOut
|
| 40 |
|
| 41 |
_FALLBACK_ALIBI = "All right - I stepped out for a moment, but I had nothing to do with this."
|
|
@@ -46,7 +47,8 @@ _FALLBACK_ALIBI = "All right - I stepped out for a moment, but I had nothing to
|
|
| 46 |
# output passes through untouched.
|
| 47 |
_CONFESSION_RE = re.compile(
|
| 48 |
r"\b(i|i'?m|i'?ve|i'?d|my|me|we|us|murder|murdered|killed|kill|killing|slain|stabbed|"
|
| 49 |
-
r"
|
|
|
|
| 50 |
re.IGNORECASE,
|
| 51 |
)
|
| 52 |
|
|
@@ -57,13 +59,13 @@ _TRACE_NAMES = (
|
|
| 57 |
)
|
| 58 |
_TRACE_REVEALS = (
|
| 59 |
"A fresh partial fingerprint on the {weapon}, unaccounted for among the guests.",
|
| 60 |
-
"A tumbler
|
| 61 |
"A torn thread of dark cloth snagged on the {room} doorframe.",
|
| 62 |
-
"Half-dried footprints crossing the {room}, left
|
| 63 |
"A monogrammed cufflink wedged under the rug, dropped in haste.",
|
| 64 |
-
"A freshly scuffed floorboard where someone braced
|
| 65 |
-
"A cigarette stubbed out mid-smoke -
|
| 66 |
-
"A mantel clock knocked still at the very minute
|
| 67 |
)
|
| 68 |
# Personal items that hint at an innocent's hidden secret - never about the murder.
|
| 69 |
_HERRING_NAMES = (
|
|
@@ -139,22 +141,17 @@ def _temperament(seed: int, index: int) -> tuple[float, float, float, str, str]:
|
|
| 139 |
_TITLE_BANNED = re.compile(r"whisper|shadow|midnight|\bdark|secret|silen|echo|veil|\bnight\b", re.IGNORECASE)
|
| 140 |
|
| 141 |
|
| 142 |
-
def _fresh_title(raw: str, seed: int, setting: str, victim: str, room: str
|
|
|
|
| 143 |
raw = (raw or "").strip()
|
| 144 |
if raw and not _TITLE_BANNED.search(raw):
|
| 145 |
return raw
|
| 146 |
last = victim.split()[-1] if victim else "the Victim"
|
| 147 |
-
templates =
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
f"Blood in the {room}",
|
| 153 |
-
f"Last Call at {setting}",
|
| 154 |
-
f"The {setting} Killing",
|
| 155 |
-
f"The {room} Verdict",
|
| 156 |
-
)
|
| 157 |
-
return templates[seed % len(templates)]
|
| 158 |
|
| 159 |
|
| 160 |
def _visual(gen, index: int) -> VisualDescriptor: # type: ignore[no-untyped-def]
|
|
@@ -180,7 +177,10 @@ def assemble_case(
|
|
| 180 |
culprit_idx: int,
|
| 181 |
crime_idx: int,
|
| 182 |
claimed_idx: int,
|
|
|
|
| 183 |
) -> CaseFile:
|
|
|
|
|
|
|
| 184 |
n = len(world.suspects)
|
| 185 |
n_loc = len(world.locations)
|
| 186 |
culprit_idx = _clamp_index(culprit_idx, n)
|
|
@@ -206,7 +206,8 @@ def assemble_case(
|
|
| 206 |
|
| 207 |
facts: list[Fact] = [
|
| 208 |
Fact(fact_id="F_scene", statement=f"{culprit_name} was in {locations[crime_idx].name} "
|
| 209 |
-
f"during the
|
|
|
|
| 210 |
]
|
| 211 |
banned = (culprit_name, world.victim_name)
|
| 212 |
weapon_name, crime_room_name = world.weapon_name, locations[crime_idx].name
|
|
@@ -216,6 +217,13 @@ def assemble_case(
|
|
| 216 |
b2_name, b2_reveal = _natural_clue(mystery.breaker_two_name, mystery.breaker_two_reveal,
|
| 217 |
herring=False, seed=(seed >> 3) + 1, weapon=weapon_name,
|
| 218 |
room=crime_room_name, banned=banned)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
clues: list[Clue] = [
|
| 220 |
Clue(clue_id="C_b1", name=b1_name, reveal_text=b1_reveal,
|
| 221 |
discoverable_at_loc_id=crime_loc, discovery_method=DiscoveryMethod.FORENSIC,
|
|
@@ -239,11 +247,12 @@ def assemble_case(
|
|
| 239 |
WhereaboutsSegment(window=TimeWindow(start_min=window.start_min, end_min=tod.start_min),
|
| 240 |
loc_id=claimed_loc, activity="mingling in plain sight"),
|
| 241 |
WhereaboutsSegment(window=tod, loc_id=crime_loc,
|
| 242 |
-
activity="
|
| 243 |
)
|
| 244 |
alibi = StatedAlibi(claim_text=mystery.alibi_claim,
|
| 245 |
claimed_segments=(AlibiSegment(window=window, loc_id=claimed_loc),))
|
| 246 |
-
lies = (AnchoredLie(lie_id="LIE_alibi",
|
|
|
|
| 247 |
claimed=mystery.alibi_claim, truth_ref="F_scene",
|
| 248 |
breaks_on=("C_b1", "C_b2"), fallback=_FALLBACK_ALIBI),)
|
| 249 |
must_lie = ("F_scene",)
|
|
@@ -265,7 +274,8 @@ def assemble_case(
|
|
| 265 |
points_to_sus_id=sus_id, is_red_herring=True, weight=0.3))
|
| 266 |
lies = (AnchoredLie(lie_id=f"LIE_sec{i + 1}", topic=gen.secret[:48], claimed=gen.cover_story,
|
| 267 |
truth_ref=secret_fact, breaks_on=(f"C_h{i + 1}",),
|
| 268 |
-
fallback="Fine, that part is true - but it has nothing to do
|
|
|
|
| 269 |
must_lie = (secret_fact,)
|
| 270 |
|
| 271 |
comp, aggr, evas, demeanour, temp_tell = _temperament(seed, i)
|
|
@@ -286,9 +296,10 @@ def assemble_case(
|
|
| 286 |
found_at_min = min(window.end_min + 5, DAY_MINUTES)
|
| 287 |
|
| 288 |
title = _fresh_title(world.title, seed, world.setting_name, world.victim_name,
|
| 289 |
-
locations[crime_idx].name)
|
| 290 |
return CaseFile(
|
| 291 |
case_id=case_id, seed=seed, title=title, briefing=world.briefing, knobs=knobs,
|
|
|
|
| 292 |
setting=Setting(name=world.setting_name, description=world.setting_description,
|
| 293 |
locations=locations, murder_window=window),
|
| 294 |
victim=Victim(vic_id="V1", name=world.victim_name, role=world.victim_role,
|
|
|
|
| 36 |
WhereaboutsSegment,
|
| 37 |
)
|
| 38 |
from ..schemas.visual import VisualDescriptor
|
| 39 |
+
from .crime_profiles import CrimeProfile, profile_for
|
| 40 |
from .stages import MysteryOut, WorldCastOut
|
| 41 |
|
| 42 |
_FALLBACK_ALIBI = "All right - I stepped out for a moment, but I had nothing to do with this."
|
|
|
|
| 47 |
# output passes through untouched.
|
| 48 |
_CONFESSION_RE = re.compile(
|
| 49 |
r"\b(i|i'?m|i'?ve|i'?d|my|me|we|us|murder|murdered|killed|kill|killing|slain|stabbed|"
|
| 50 |
+
r"stole|stolen|robbed|forged|defrauded|swindled|blackmail|blackmailed|extorted|torched|"
|
| 51 |
+
r"abducted|kidnapped|planned|revenge|confess|culprit|victim|the weapon|the murder)\b",
|
| 52 |
re.IGNORECASE,
|
| 53 |
)
|
| 54 |
|
|
|
|
| 59 |
)
|
| 60 |
_TRACE_REVEALS = (
|
| 61 |
"A fresh partial fingerprint on the {weapon}, unaccounted for among the guests.",
|
| 62 |
+
"A tumbler abandoned at the scene, its rim marked with a recent lip-print.",
|
| 63 |
"A torn thread of dark cloth snagged on the {room} doorframe.",
|
| 64 |
+
"Half-dried footprints crossing the {room}, left within the hour.",
|
| 65 |
"A monogrammed cufflink wedged under the rug, dropped in haste.",
|
| 66 |
+
"A freshly scuffed floorboard where someone braced in a hurry.",
|
| 67 |
+
"A cigarette stubbed out mid-smoke - a brand nobody in the house admits to.",
|
| 68 |
+
"A mantel clock knocked still at the very minute it happened.",
|
| 69 |
)
|
| 70 |
# Personal items that hint at an innocent's hidden secret - never about the murder.
|
| 71 |
_HERRING_NAMES = (
|
|
|
|
| 141 |
_TITLE_BANNED = re.compile(r"whisper|shadow|midnight|\bdark|secret|silen|echo|veil|\bnight\b", re.IGNORECASE)
|
| 142 |
|
| 143 |
|
| 144 |
+
def _fresh_title(raw: str, seed: int, setting: str, victim: str, room: str,
|
| 145 |
+
profile: CrimeProfile) -> str:
|
| 146 |
raw = (raw or "").strip()
|
| 147 |
if raw and not _TITLE_BANNED.search(raw):
|
| 148 |
return raw
|
| 149 |
last = victim.split()[-1] if victim else "the Victim"
|
| 150 |
+
templates = profile.fallback_titles
|
| 151 |
+
title = templates[seed % len(templates)].format(room=room, setting=setting, last=last)
|
| 152 |
+
# Setting/room names often carry their own article ("the Old Clock Tower") - collapse
|
| 153 |
+
# the doubled article a template can produce ("The the Old Clock Tower Demand").
|
| 154 |
+
return re.sub(r"\b(the)\s+the\b", r"\1", title, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def _visual(gen, index: int) -> VisualDescriptor: # type: ignore[no-untyped-def]
|
|
|
|
| 177 |
culprit_idx: int,
|
| 178 |
crime_idx: int,
|
| 179 |
claimed_idx: int,
|
| 180 |
+
profile: CrimeProfile | None = None,
|
| 181 |
) -> CaseFile:
|
| 182 |
+
if profile is None:
|
| 183 |
+
profile = profile_for(knobs.crime_kind or "homicide")
|
| 184 |
n = len(world.suspects)
|
| 185 |
n_loc = len(world.locations)
|
| 186 |
culprit_idx = _clamp_index(culprit_idx, n)
|
|
|
|
| 206 |
|
| 207 |
facts: list[Fact] = [
|
| 208 |
Fact(fact_id="F_scene", statement=f"{culprit_name} was in {locations[crime_idx].name} "
|
| 209 |
+
f"during the {profile.incident_noun}.", true_value=True, loc_id=crime_loc,
|
| 210 |
+
at_min=tod.start_min),
|
| 211 |
]
|
| 212 |
banned = (culprit_name, world.victim_name)
|
| 213 |
weapon_name, crime_room_name = world.weapon_name, locations[crime_idx].name
|
|
|
|
| 217 |
b2_name, b2_reveal = _natural_clue(mystery.breaker_two_name, mystery.breaker_two_reveal,
|
| 218 |
herring=False, seed=(seed >> 3) + 1, weapon=weapon_name,
|
| 219 |
room=crime_room_name, banned=banned)
|
| 220 |
+
# Two breakers with the same fallback trace read as a copy-paste bug to the player -
|
| 221 |
+
# bump the seed until the second one draws a different object.
|
| 222 |
+
bump = 2
|
| 223 |
+
while b2_name == b1_name and bump < 12:
|
| 224 |
+
b2_name, b2_reveal = _natural_clue("", "", herring=False, seed=(seed >> 3) + bump,
|
| 225 |
+
weapon=weapon_name, room=crime_room_name, banned=banned)
|
| 226 |
+
bump += 1
|
| 227 |
clues: list[Clue] = [
|
| 228 |
Clue(clue_id="C_b1", name=b1_name, reveal_text=b1_reveal,
|
| 229 |
discoverable_at_loc_id=crime_loc, discovery_method=DiscoveryMethod.FORENSIC,
|
|
|
|
| 247 |
WhereaboutsSegment(window=TimeWindow(start_min=window.start_min, end_min=tod.start_min),
|
| 248 |
loc_id=claimed_loc, activity="mingling in plain sight"),
|
| 249 |
WhereaboutsSegment(window=tod, loc_id=crime_loc,
|
| 250 |
+
activity=f"at the scene of the {profile.incident_noun}"),
|
| 251 |
)
|
| 252 |
alibi = StatedAlibi(claim_text=mystery.alibi_claim,
|
| 253 |
claimed_segments=(AlibiSegment(window=window, loc_id=claimed_loc),))
|
| 254 |
+
lies = (AnchoredLie(lie_id="LIE_alibi",
|
| 255 |
+
topic=f"where you were during the {profile.incident_noun}",
|
| 256 |
claimed=mystery.alibi_claim, truth_ref="F_scene",
|
| 257 |
breaks_on=("C_b1", "C_b2"), fallback=_FALLBACK_ALIBI),)
|
| 258 |
must_lie = ("F_scene",)
|
|
|
|
| 274 |
points_to_sus_id=sus_id, is_red_herring=True, weight=0.3))
|
| 275 |
lies = (AnchoredLie(lie_id=f"LIE_sec{i + 1}", topic=gen.secret[:48], claimed=gen.cover_story,
|
| 276 |
truth_ref=secret_fact, breaks_on=(f"C_h{i + 1}",),
|
| 277 |
+
fallback=f"Fine, that part is true - but it has nothing to do "
|
| 278 |
+
f"with the {profile.incident_noun}."),)
|
| 279 |
must_lie = (secret_fact,)
|
| 280 |
|
| 281 |
comp, aggr, evas, demeanour, temp_tell = _temperament(seed, i)
|
|
|
|
| 296 |
found_at_min = min(window.end_min + 5, DAY_MINUTES)
|
| 297 |
|
| 298 |
title = _fresh_title(world.title, seed, world.setting_name, world.victim_name,
|
| 299 |
+
locations[crime_idx].name, profile)
|
| 300 |
return CaseFile(
|
| 301 |
case_id=case_id, seed=seed, title=title, briefing=world.briefing, knobs=knobs,
|
| 302 |
+
crime_kind=profile.kind,
|
| 303 |
setting=Setting(name=world.setting_name, description=world.setting_description,
|
| 304 |
locations=locations, murder_window=window),
|
| 305 |
victim=Victim(vic_id="V1", name=world.victim_name, role=world.victim_role,
|
src/case_zero/generator/crime_profiles.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Crime profiles: one frozen table that tells every layer how to talk about a case.
|
| 2 |
+
|
| 3 |
+
The solver and engine are crime-agnostic - they only check structure (one culprit, a
|
| 4 |
+
false alibi contradicted by discoverable evidence, innocents cleared). A profile maps a
|
| 5 |
+
``CrimeKind`` to the words each layer needs: how the generator prompts the model, how
|
| 6 |
+
the suspect brief states the culprit's deed, and how the dossier/verdict label the case.
|
| 7 |
+
|
| 8 |
+
Field semantics are REUSED rather than renamed, so no schema churn:
|
| 9 |
+
- ``victim`` = the wronged party (the deceased / the one robbed / the missing person);
|
| 10 |
+
- ``weapon`` = the instrument or object of the crime (knife / stolen jewel / forged deed);
|
| 11 |
+
- ``time_of_death`` = the moment of the incident;
|
| 12 |
+
- ``cause_of_death`` = one line describing what happened to the victim.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from ..schemas.enums import CrimeKind
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class CrimeProfile:
|
| 24 |
+
kind: CrimeKind
|
| 25 |
+
# --- generator prompts ---
|
| 26 |
+
author_noun: str # "murder mystery"
|
| 27 |
+
incident_noun: str # "murder" - used in "during the {incident_noun}"
|
| 28 |
+
incident_line: str # world prompt: what happened that evening
|
| 29 |
+
victim_ask: str # world prompt: who the victim is + what cause_of_death holds
|
| 30 |
+
instrument_ask: str # world prompt: what weapon_name/kind hold
|
| 31 |
+
deed_line: str # mystery prompt: "{culprit} ... {victim} ... {room} ... {instrument}"
|
| 32 |
+
trace_ask: str # mystery prompt: what the two breaker clues prove
|
| 33 |
+
# --- engine ---
|
| 34 |
+
brief_deed: str # suspect brief, second person: "You killed {victim}."
|
| 35 |
+
confession_verbs: str # extra regex alternatives for the confession scrubber
|
| 36 |
+
# --- display ---
|
| 37 |
+
perp_noun: str # "killer"
|
| 38 |
+
division: str # "HOMICIDE DIVISION"
|
| 39 |
+
victim_status: str # dossier stamp: "DECEASED"
|
| 40 |
+
verdict: str # KEY FACTS verdict line: "Homicide"
|
| 41 |
+
kind_label: str # title screen: "A PROCEDURAL {kind_label}"
|
| 42 |
+
tod_label: str # "T.O.D." vs "LAST SEEN" vs "INCIDENT"
|
| 43 |
+
found_verb: str # "Found in" vs "Last seen in"
|
| 44 |
+
timeline_line: str # "{name} is killed. Time of death."
|
| 45 |
+
boot_line: str # boot screen: "A death no one saw coming."
|
| 46 |
+
fallback_titles: tuple[str, ...]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
_P = CrimeProfile
|
| 50 |
+
|
| 51 |
+
PROFILES: dict[CrimeKind, CrimeProfile] = {
|
| 52 |
+
CrimeKind.HOMICIDE: _P(
|
| 53 |
+
kind=CrimeKind.HOMICIDE,
|
| 54 |
+
author_noun="murder mystery",
|
| 55 |
+
incident_noun="murder",
|
| 56 |
+
incident_line="The murder happened between {start} and {end} one evening.",
|
| 57 |
+
victim_ask="the victim (name, role, cause of death) and which room index (0-based) "
|
| 58 |
+
"they were found in",
|
| 59 |
+
instrument_ask="the murder weapon (name, kind)",
|
| 60 |
+
deed_line="They murdered {victim} in the {room} with the {instrument} between "
|
| 61 |
+
"{start} and {end}, then lied that they never left the {claimed}.",
|
| 62 |
+
trace_ask="quietly place {culprit} in the {room} during the murder",
|
| 63 |
+
brief_deed="You killed {victim}.",
|
| 64 |
+
confession_verbs=r"killed|murdered|stabbed|poisoned|strangled|shot|drowned|smothered",
|
| 65 |
+
perp_noun="killer",
|
| 66 |
+
division="HOMICIDE DIVISION",
|
| 67 |
+
victim_status="DECEASED",
|
| 68 |
+
verdict="Homicide",
|
| 69 |
+
kind_label="HOMICIDE",
|
| 70 |
+
tod_label="T.O.D.",
|
| 71 |
+
found_verb="Found in",
|
| 72 |
+
timeline_line="{name} is killed. Time of death.",
|
| 73 |
+
boot_line="A death no one saw coming.",
|
| 74 |
+
fallback_titles=(
|
| 75 |
+
"A Death in the {room}", "The {room} Affair", "Murder at {setting}",
|
| 76 |
+
"The {last} File", "Blood in the {room}", "Last Call at {setting}",
|
| 77 |
+
"The {setting} Killing", "The {room} Verdict",
|
| 78 |
+
),
|
| 79 |
+
),
|
| 80 |
+
CrimeKind.THEFT: _P(
|
| 81 |
+
kind=CrimeKind.THEFT,
|
| 82 |
+
author_noun="heist mystery - a prized object stolen from under everyone's nose",
|
| 83 |
+
incident_noun="theft",
|
| 84 |
+
incident_line="The theft happened between {start} and {end} one evening.",
|
| 85 |
+
victim_ask="the victim - the OWNER of what was stolen (name, role, and in "
|
| 86 |
+
"cause_of_death one line on how they discovered the loss) and which room "
|
| 87 |
+
"index (0-based) the theft happened in",
|
| 88 |
+
instrument_ask="the STOLEN OBJECT itself (name, kind) - one specific prized thing: "
|
| 89 |
+
"a jewel, a painting, a rare manuscript, a strongbox",
|
| 90 |
+
deed_line="They stole the {instrument} from {victim}'s {room} between {start} and "
|
| 91 |
+
"{end}, then lied that they never left the {claimed}.",
|
| 92 |
+
trace_ask="quietly place {culprit} in the {room} during the theft",
|
| 93 |
+
brief_deed="You stole the {instrument} from {victim}.",
|
| 94 |
+
confession_verbs=r"stole|robbed|took\s+the|lifted\s+the|pocketed",
|
| 95 |
+
perp_noun="thief",
|
| 96 |
+
division="ROBBERY DIVISION",
|
| 97 |
+
victim_status="ROBBED",
|
| 98 |
+
verdict="Grand Larceny",
|
| 99 |
+
kind_label="HEIST",
|
| 100 |
+
tod_label="TAKEN AT",
|
| 101 |
+
found_verb="Taken from",
|
| 102 |
+
timeline_line="The {instrument} vanishes from the {room}.",
|
| 103 |
+
boot_line="A theft no one saw happen.",
|
| 104 |
+
fallback_titles=(
|
| 105 |
+
"The Empty Case in the {room}", "The {setting} Job", "The {last} Collection",
|
| 106 |
+
"What Left the {room}", "The {setting} Take", "A Hole in the {room}",
|
| 107 |
+
),
|
| 108 |
+
),
|
| 109 |
+
CrimeKind.FRAUD: _P(
|
| 110 |
+
kind=CrimeKind.FRAUD,
|
| 111 |
+
author_noun="financial-fraud mystery - a swindle unravelling among people who "
|
| 112 |
+
"trusted each other",
|
| 113 |
+
incident_noun="fraud",
|
| 114 |
+
incident_line="The decisive forged transaction was pushed through between {start} "
|
| 115 |
+
"and {end} one evening.",
|
| 116 |
+
victim_ask="the victim - the person DEFRAUDED (name, role, and in cause_of_death "
|
| 117 |
+
"one line on what they lost and how they found out) and which room index "
|
| 118 |
+
"(0-based) the scheme was run from",
|
| 119 |
+
instrument_ask="the INSTRUMENT of the fraud (name, kind) - a forged deed, a doctored "
|
| 120 |
+
"ledger, a fake certificate, a rigged contract",
|
| 121 |
+
deed_line="They defrauded {victim} using the {instrument}, working from the {room} "
|
| 122 |
+
"between {start} and {end}, then lied that they never left the {claimed}.",
|
| 123 |
+
trace_ask="quietly place {culprit} in the {room} when the {instrument} was made",
|
| 124 |
+
brief_deed="You defrauded {victim} using the {instrument}.",
|
| 125 |
+
confession_verbs=r"forged|defrauded|swindled|scammed|embezzled|doctored\s+the",
|
| 126 |
+
perp_noun="fraudster",
|
| 127 |
+
division="FRAUD DIVISION",
|
| 128 |
+
victim_status="DEFRAUDED",
|
| 129 |
+
verdict="Fraud",
|
| 130 |
+
kind_label="SWINDLE",
|
| 131 |
+
tod_label="SIGNED AT",
|
| 132 |
+
found_verb="Uncovered in",
|
| 133 |
+
timeline_line="The {instrument} is signed in the {room}.",
|
| 134 |
+
boot_line="A fortune signed away in one evening.",
|
| 135 |
+
fallback_titles=(
|
| 136 |
+
"The {room} Signature", "The {setting} Accounts", "The {last} Trust",
|
| 137 |
+
"Ink in the {room}", "The Paper Fortune", "The {setting} Ledger",
|
| 138 |
+
),
|
| 139 |
+
),
|
| 140 |
+
CrimeKind.BLACKMAIL: _P(
|
| 141 |
+
kind=CrimeKind.BLACKMAIL,
|
| 142 |
+
author_noun="blackmail mystery - someone is being bled, and the extortionist sat "
|
| 143 |
+
"at the same table",
|
| 144 |
+
incident_noun="blackmail drop",
|
| 145 |
+
incident_line="The latest blackmail demand was planted between {start} and {end} "
|
| 146 |
+
"one evening.",
|
| 147 |
+
victim_ask="the victim - the person BLACKMAILED (name, role, and in cause_of_death "
|
| 148 |
+
"one line on the demand and what it is costing them) and which room index "
|
| 149 |
+
"(0-based) the demand was planted in",
|
| 150 |
+
instrument_ask="the LEVERAGE (name, kind) - compromising photographs, stolen letters, "
|
| 151 |
+
"an incriminating ledger page",
|
| 152 |
+
deed_line="They are blackmailing {victim} with the {instrument}, and planted the "
|
| 153 |
+
"demand in the {room} between {start} and {end}, then lied that they "
|
| 154 |
+
"never left the {claimed}.",
|
| 155 |
+
trace_ask="quietly place {culprit} in the {room} when the demand was planted",
|
| 156 |
+
brief_deed="You are blackmailing {victim} with the {instrument}.",
|
| 157 |
+
confession_verbs=r"blackmailed|blackmailing|extorted|bled\s+(him|her|them)",
|
| 158 |
+
perp_noun="blackmailer",
|
| 159 |
+
division="EXTORTION UNIT",
|
| 160 |
+
victim_status="EXTORTED",
|
| 161 |
+
verdict="Extortion",
|
| 162 |
+
kind_label="EXTORTION",
|
| 163 |
+
tod_label="PLANTED AT",
|
| 164 |
+
found_verb="Planted in",
|
| 165 |
+
timeline_line="The demand is planted in the {room}.",
|
| 166 |
+
boot_line="An envelope no one will admit to leaving.",
|
| 167 |
+
fallback_titles=(
|
| 168 |
+
"The {room} Envelope", "The Price of {last}", "The {setting} Demand",
|
| 169 |
+
"Postage Due at {setting}", "The {room} Letters", "What {last} Pays For",
|
| 170 |
+
),
|
| 171 |
+
),
|
| 172 |
+
CrimeKind.ARSON: _P(
|
| 173 |
+
kind=CrimeKind.ARSON,
|
| 174 |
+
author_noun="arson mystery - a fire set by a familiar hand",
|
| 175 |
+
incident_noun="fire",
|
| 176 |
+
incident_line="The fire was set between {start} and {end} one evening.",
|
| 177 |
+
victim_ask="the victim - the person whose property BURNED (name, role, and in "
|
| 178 |
+
"cause_of_death one line on what the fire took from them) and which room "
|
| 179 |
+
"index (0-based) the fire started in",
|
| 180 |
+
instrument_ask="the MEANS of the fire (name, kind) - a kerosene tin, an oil lamp, "
|
| 181 |
+
"a candle and curtains",
|
| 182 |
+
deed_line="They set the fire in {victim}'s {room} using the {instrument} between "
|
| 183 |
+
"{start} and {end}, then lied that they never left the {claimed}.",
|
| 184 |
+
trace_ask="quietly place {culprit} in the {room} just before the fire started",
|
| 185 |
+
brief_deed="You set the fire in {victim}'s {room} using the {instrument}.",
|
| 186 |
+
confession_verbs=r"torched|set\s+the\s+fire|burned\s+it|lit\s+the|started\s+the\s+fire",
|
| 187 |
+
perp_noun="arsonist",
|
| 188 |
+
division="ARSON UNIT",
|
| 189 |
+
victim_status="BURNED OUT",
|
| 190 |
+
verdict="Arson",
|
| 191 |
+
kind_label="ARSON",
|
| 192 |
+
tod_label="IGNITED AT",
|
| 193 |
+
found_verb="Started in",
|
| 194 |
+
timeline_line="The fire starts in the {room}.",
|
| 195 |
+
boot_line="A fire that started exactly where it hurt most.",
|
| 196 |
+
fallback_titles=(
|
| 197 |
+
"The {room} Burn", "Ashes at {setting}", "The {last} Fire",
|
| 198 |
+
"What the {room} Kept", "Kindling at {setting}", "The {setting} Blaze",
|
| 199 |
+
),
|
| 200 |
+
),
|
| 201 |
+
CrimeKind.MISSING: _P(
|
| 202 |
+
kind=CrimeKind.MISSING,
|
| 203 |
+
author_noun="missing-person mystery - someone walked into a room and never walked out",
|
| 204 |
+
incident_noun="disappearance",
|
| 205 |
+
incident_line="The disappearance happened between {start} and {end} one evening.",
|
| 206 |
+
victim_ask="the victim - the person who VANISHED (name, role, and in cause_of_death "
|
| 207 |
+
"one line on how their absence was noticed) and which room index (0-based) "
|
| 208 |
+
"they were last seen in",
|
| 209 |
+
instrument_ask="the LURE (name, kind) - the thing used to draw them away: a forged "
|
| 210 |
+
"note, a false telegram, a promised meeting",
|
| 211 |
+
deed_line="They are behind {victim}'s disappearance - they lured them from the {room} "
|
| 212 |
+
"using the {instrument} between {start} and {end}, then lied that they "
|
| 213 |
+
"never left the {claimed}.",
|
| 214 |
+
trace_ask="quietly place {culprit} in the {room} when {victim} was last seen",
|
| 215 |
+
brief_deed="You are behind {victim}'s disappearance; you lured them away with the "
|
| 216 |
+
"{instrument}. {victim} is alive, but you must hide what you did.",
|
| 217 |
+
confession_verbs=r"abducted|kidnapped|lured\s+(him|her|them)|took\s+(him|her|them)",
|
| 218 |
+
perp_noun="abductor",
|
| 219 |
+
division="MISSING PERSONS",
|
| 220 |
+
victim_status="MISSING",
|
| 221 |
+
verdict="Missing Person",
|
| 222 |
+
kind_label="DISAPPEARANCE",
|
| 223 |
+
tod_label="LAST SEEN",
|
| 224 |
+
found_verb="Last seen in",
|
| 225 |
+
timeline_line="{name} is seen for the last time.",
|
| 226 |
+
boot_line="A chair still warm, and nobody in it.",
|
| 227 |
+
fallback_titles=(
|
| 228 |
+
"The Empty Chair in the {room}", "Where {last} Went", "The {setting} Absence",
|
| 229 |
+
"Last Seen in the {room}", "The {last} Vanishing", "Gone from {setting}",
|
| 230 |
+
),
|
| 231 |
+
),
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
# Weighted draw: homicide stays the backbone of a noir game; the rest add variety.
|
| 235 |
+
_KIND_BAG: tuple[CrimeKind, ...] = (
|
| 236 |
+
CrimeKind.HOMICIDE, CrimeKind.HOMICIDE, CrimeKind.HOMICIDE,
|
| 237 |
+
CrimeKind.THEFT, CrimeKind.THEFT,
|
| 238 |
+
CrimeKind.FRAUD, CrimeKind.BLACKMAIL, CrimeKind.ARSON, CrimeKind.MISSING,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def profile_for(kind: CrimeKind | str) -> CrimeProfile:
|
| 243 |
+
return PROFILES[CrimeKind(kind)]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def kind_for_seed(seed: int) -> CrimeKind:
|
| 247 |
+
return _KIND_BAG[seed % len(_KIND_BAG)]
|
src/case_zero/generator/pipeline.py
CHANGED
|
@@ -14,12 +14,19 @@ from ..schemas.case import CaseFile, GenerationKnobs
|
|
| 14 |
from ..schemas.timeline import TimeWindow
|
| 15 |
from ..solver.checker import CheckReport, check
|
| 16 |
from .assemble import assemble_case
|
|
|
|
| 17 |
from .stages import MysteryOut, WorldCastOut, mystery_prompt, world_cast_prompt
|
| 18 |
|
| 19 |
-
# A fixed
|
| 20 |
MURDER_WINDOW = TimeWindow(start_min=21 * 60, end_min=22 * 60)
|
| 21 |
TIME_OF_DEATH = TimeWindow(start_min=21 * 60 + 20, end_min=21 * 60 + 50)
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@dataclass(frozen=True)
|
| 25 |
class GenerationResult:
|
|
@@ -48,9 +55,10 @@ def generate_case(
|
|
| 48 |
*,
|
| 49 |
seed: int,
|
| 50 |
knobs: GenerationKnobs | None = None,
|
| 51 |
-
max_attempts: int =
|
| 52 |
) -> GenerationResult:
|
| 53 |
knobs = knobs or GenerationKnobs()
|
|
|
|
| 54 |
case: CaseFile | None = None
|
| 55 |
report: CheckReport | None = None
|
| 56 |
|
|
@@ -58,9 +66,9 @@ def generate_case(
|
|
| 58 |
attempt_seed = seed + attempt
|
| 59 |
world = generate_model(
|
| 60 |
backend,
|
| 61 |
-
world_cast_prompt(knobs.setting_hint, knobs.era_hint, knobs.tone_hint,
|
| 62 |
knobs.n_suspects, MURDER_WINDOW.start_min, MURDER_WINDOW.end_min),
|
| 63 |
-
WorldCastOut, temperature=0.
|
| 64 |
)
|
| 65 |
world = _ensure_female(world, attempt_seed)
|
| 66 |
n = len(world.suspects)
|
|
@@ -72,15 +80,16 @@ def generate_case(
|
|
| 72 |
|
| 73 |
mystery = generate_model(
|
| 74 |
backend,
|
| 75 |
-
mystery_prompt(culprit.name, culprit.role, world.victim_name,
|
| 76 |
-
world.
|
|
|
|
| 77 |
MURDER_WINDOW.start_min, MURDER_WINDOW.end_min),
|
| 78 |
-
MysteryOut, temperature=0.6, max_tokens=
|
| 79 |
)
|
| 80 |
|
| 81 |
case = assemble_case(
|
| 82 |
case_id=f"gen-{seed:06d}", seed=attempt_seed, knobs=knobs, world=world, mystery=mystery,
|
| 83 |
-
window=MURDER_WINDOW, tod=TIME_OF_DEATH,
|
| 84 |
culprit_idx=culprit_idx, crime_idx=crime_idx, claimed_idx=claimed_idx,
|
| 85 |
)
|
| 86 |
report = check(case)
|
|
|
|
| 14 |
from ..schemas.timeline import TimeWindow
|
| 15 |
from ..solver.checker import CheckReport, check
|
| 16 |
from .assemble import assemble_case
|
| 17 |
+
from .crime_profiles import kind_for_seed, profile_for
|
| 18 |
from .stages import MysteryOut, WorldCastOut, mystery_prompt, world_cast_prompt
|
| 19 |
|
| 20 |
+
# A fixed incident window keeps alibi reasoning simple; the model still invents the rest.
|
| 21 |
MURDER_WINDOW = TimeWindow(start_min=21 * 60, end_min=22 * 60)
|
| 22 |
TIME_OF_DEATH = TimeWindow(start_min=21 * 60 + 20, end_min=21 * 60 + 50)
|
| 23 |
|
| 24 |
+
# Output caps sized to healthy generations (~600-1100 / ~200-400 tokens). The caps only
|
| 25 |
+
# bite on runaway outputs, where on 2 vCPU an uncapped 4096-token decode wastes minutes
|
| 26 |
+
# before the retry can even start.
|
| 27 |
+
_WORLD_MAX_TOKENS = 1800
|
| 28 |
+
_MYSTERY_MAX_TOKENS = 900
|
| 29 |
+
|
| 30 |
|
| 31 |
@dataclass(frozen=True)
|
| 32 |
class GenerationResult:
|
|
|
|
| 55 |
*,
|
| 56 |
seed: int,
|
| 57 |
knobs: GenerationKnobs | None = None,
|
| 58 |
+
max_attempts: int = 2,
|
| 59 |
) -> GenerationResult:
|
| 60 |
knobs = knobs or GenerationKnobs()
|
| 61 |
+
profile = profile_for(knobs.crime_kind) if knobs.crime_kind else profile_for(kind_for_seed(seed))
|
| 62 |
case: CaseFile | None = None
|
| 63 |
report: CheckReport | None = None
|
| 64 |
|
|
|
|
| 66 |
attempt_seed = seed + attempt
|
| 67 |
world = generate_model(
|
| 68 |
backend,
|
| 69 |
+
world_cast_prompt(profile, knobs.setting_hint, knobs.era_hint, knobs.tone_hint,
|
| 70 |
knobs.n_suspects, MURDER_WINDOW.start_min, MURDER_WINDOW.end_min),
|
| 71 |
+
WorldCastOut, temperature=0.85, max_tokens=_WORLD_MAX_TOKENS, seed=attempt_seed,
|
| 72 |
)
|
| 73 |
world = _ensure_female(world, attempt_seed)
|
| 74 |
n = len(world.suspects)
|
|
|
|
| 80 |
|
| 81 |
mystery = generate_model(
|
| 82 |
backend,
|
| 83 |
+
mystery_prompt(profile, culprit.name, culprit.role, world.victim_name,
|
| 84 |
+
world.weapon_name, world.locations[crime_idx],
|
| 85 |
+
world.locations[claimed_idx],
|
| 86 |
MURDER_WINDOW.start_min, MURDER_WINDOW.end_min),
|
| 87 |
+
MysteryOut, temperature=0.6, max_tokens=_MYSTERY_MAX_TOKENS, seed=attempt_seed,
|
| 88 |
)
|
| 89 |
|
| 90 |
case = assemble_case(
|
| 91 |
case_id=f"gen-{seed:06d}", seed=attempt_seed, knobs=knobs, world=world, mystery=mystery,
|
| 92 |
+
profile=profile, window=MURDER_WINDOW, tod=TIME_OF_DEATH,
|
| 93 |
culprit_idx=culprit_idx, crime_idx=crime_idx, claimed_idx=claimed_idx,
|
| 94 |
)
|
| 95 |
report = check(case)
|
src/case_zero/generator/stages.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 1 |
"""LLM stage schemas and prompts for case generation.
|
| 2 |
|
| 3 |
The model does all creative authorship across two constrained calls:
|
| 4 |
-
1. World + Cast - the setting, victim,
|
| 5 |
cover story, and the evidence that would expose that secret);
|
| 6 |
2. Mystery - given which suspect Python has chosen as the culprit, the motive, method,
|
| 7 |
false alibi, the two breaking clues, and the deduction chain.
|
| 8 |
|
| 9 |
Python only decides the *structure* (who is guilty, who was where) so the case is
|
| 10 |
-
always solvable; it never writes prose.
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -15,6 +17,7 @@ from __future__ import annotations
|
|
| 15 |
from pydantic import BaseModel, Field
|
| 16 |
|
| 17 |
from ..schemas.enums import MotiveCategory
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class GenSuspect(BaseModel):
|
|
@@ -71,29 +74,28 @@ def _clock(minute: int) -> str:
|
|
| 71 |
return f"{minute // 60:02d}:{minute % 60:02d}"
|
| 72 |
|
| 73 |
|
| 74 |
-
def world_cast_prompt(setting_hint: str, era_hint: str, tone_hint: str,
|
| 75 |
-
window_start: int, window_end: int) -> str:
|
| 76 |
hint = ", ".join(h for h in (setting_hint, era_hint, tone_hint) if h) or "your choice"
|
|
|
|
| 77 |
return (
|
| 78 |
-
"You are the author of a brand-new
|
| 79 |
-
f"atmospheric (theme: {hint}).
|
| 80 |
-
|
| 81 |
-
"no padding.\n\n"
|
| 82 |
"Invent:\n"
|
| 83 |
"- a specific, evocative title that MUST NOT contain any of these overused words: "
|
| 84 |
"whispers, shadows, midnight, dark, darkness, secret, secrets, silence, silent, echo, "
|
| 85 |
"veil, night, death, murder, mystery - instead name something concrete to THIS story "
|
| 86 |
"(a place, an object, an act, a person), and a one-sentence case briefing for the detective;\n"
|
| 87 |
"- a setting name and EXACTLY 4 distinct rooms (just the room names);\n"
|
| 88 |
-
"-
|
| 89 |
-
"
|
| 90 |
-
"- the murder weapon (name, kind);\n"
|
| 91 |
f"- exactly {n_suspects} suspects, each a person in the victim's life (family, colleague, "
|
| 92 |
"rival, lover, business partner, servant, friend) with a plausible reason to be there. "
|
| 93 |
"NONE of them is a detective, police officer, or investigator. For EACH suspect give a "
|
| 94 |
"proper full NAME (first and last - never a description like 'the lover'), a role, a "
|
| 95 |
"one-line persona, "
|
| 96 |
-
"a private SECRET they are ashamed of (NOT
|
| 97 |
"to hide that secret, and one piece of physical EVIDENCE that quietly hints at that secret "
|
| 98 |
"- a real object a detective would find (a letter, a receipt, a photograph, a pawn ticket, "
|
| 99 |
"a stain) described by what it IS and shows, never a confession or first-person words (its "
|
|
@@ -111,23 +113,29 @@ def world_cast_prompt(setting_hint: str, era_hint: str, tone_hint: str, n_suspec
|
|
| 111 |
)
|
| 112 |
|
| 113 |
|
| 114 |
-
def mystery_prompt(
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
return (
|
| 117 |
-
f"In this mystery the
|
| 118 |
-
f"in the {crime_room} with the {weapon_name} between {_clock(window_start)} and "
|
| 119 |
-
f"{_clock(window_end)}, then lied that they never left the {claimed_room}.\n\n"
|
| 120 |
"Write the framing:\n"
|
| 121 |
"- their true MOTIVE (pick a category and a one-line summary);\n"
|
| 122 |
"- a one or two sentence METHOD narrative (how they did it);\n"
|
| 123 |
f"- the exact false ALIBI line they would say (claiming they were in the {claimed_room});\n"
|
| 124 |
"- TWO subtle pieces of PHYSICAL evidence in the "
|
| 125 |
-
f"{crime_room} that, on close inspection,
|
| 126 |
-
"
|
| 127 |
"dropped or monogrammed object, a torn fibre, a footprint, a stain, a stopped clock. "
|
| 128 |
-
"Describe ONLY the object and what it physically shows. Do NOT name the
|
| 129 |
-
"reveal, do NOT mention the
|
| 130 |
-
"first-person words. (a short name + a
|
|
|
|
| 131 |
"- a 3 to 4 step deduction chain of natural detective reasoning from those clues.\n\n"
|
| 132 |
"Keep it consistent with the facts above. Reply with ONLY this JSON object (exact keys; "
|
| 133 |
"motive_category must be one of greed, revenge, jealousy, fear, ambition, love, "
|
|
|
|
| 1 |
"""LLM stage schemas and prompts for case generation.
|
| 2 |
|
| 3 |
The model does all creative authorship across two constrained calls:
|
| 4 |
+
1. World + Cast - the setting, victim, instrument, and every suspect (persona, secret,
|
| 5 |
cover story, and the evidence that would expose that secret);
|
| 6 |
2. Mystery - given which suspect Python has chosen as the culprit, the motive, method,
|
| 7 |
false alibi, the two breaking clues, and the deduction chain.
|
| 8 |
|
| 9 |
Python only decides the *structure* (who is guilty, who was where) so the case is
|
| 10 |
+
always solvable; it never writes prose. A ``CrimeProfile`` parameterizes the prompts so
|
| 11 |
+
the same two calls author a homicide, a heist, a fraud, a blackmail, an arson, or a
|
| 12 |
+
missing-person case.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
|
|
| 17 |
from pydantic import BaseModel, Field
|
| 18 |
|
| 19 |
from ..schemas.enums import MotiveCategory
|
| 20 |
+
from .crime_profiles import CrimeProfile
|
| 21 |
|
| 22 |
|
| 23 |
class GenSuspect(BaseModel):
|
|
|
|
| 74 |
return f"{minute // 60:02d}:{minute % 60:02d}"
|
| 75 |
|
| 76 |
|
| 77 |
+
def world_cast_prompt(profile: CrimeProfile, setting_hint: str, era_hint: str, tone_hint: str,
|
| 78 |
+
n_suspects: int, window_start: int, window_end: int) -> str:
|
| 79 |
hint = ", ".join(h for h in (setting_hint, era_hint, tone_hint) if h) or "your choice"
|
| 80 |
+
incident = profile.incident_line.format(start=_clock(window_start), end=_clock(window_end))
|
| 81 |
return (
|
| 82 |
+
f"You are the author of a brand-new {profile.author_noun}. Invent something specific and "
|
| 83 |
+
f"atmospheric (theme: {hint}). {incident} Keep every field short - a phrase or one "
|
| 84 |
+
"sentence; no padding.\n\n"
|
|
|
|
| 85 |
"Invent:\n"
|
| 86 |
"- a specific, evocative title that MUST NOT contain any of these overused words: "
|
| 87 |
"whispers, shadows, midnight, dark, darkness, secret, secrets, silence, silent, echo, "
|
| 88 |
"veil, night, death, murder, mystery - instead name something concrete to THIS story "
|
| 89 |
"(a place, an object, an act, a person), and a one-sentence case briefing for the detective;\n"
|
| 90 |
"- a setting name and EXACTLY 4 distinct rooms (just the room names);\n"
|
| 91 |
+
f"- {profile.victim_ask};\n"
|
| 92 |
+
f"- {profile.instrument_ask};\n"
|
|
|
|
| 93 |
f"- exactly {n_suspects} suspects, each a person in the victim's life (family, colleague, "
|
| 94 |
"rival, lover, business partner, servant, friend) with a plausible reason to be there. "
|
| 95 |
"NONE of them is a detective, police officer, or investigator. For EACH suspect give a "
|
| 96 |
"proper full NAME (first and last - never a description like 'the lover'), a role, a "
|
| 97 |
"one-line persona, "
|
| 98 |
+
"a private SECRET they are ashamed of (NOT the crime itself), the COVER STORY they tell "
|
| 99 |
"to hide that secret, and one piece of physical EVIDENCE that quietly hints at that secret "
|
| 100 |
"- a real object a detective would find (a letter, a receipt, a photograph, a pawn ticket, "
|
| 101 |
"a stain) described by what it IS and shows, never a confession or first-person words (its "
|
|
|
|
| 113 |
)
|
| 114 |
|
| 115 |
|
| 116 |
+
def mystery_prompt(profile: CrimeProfile, culprit_name: str, culprit_role: str, victim_name: str,
|
| 117 |
+
weapon_name: str, crime_room: str, claimed_room: str,
|
| 118 |
+
window_start: int, window_end: int) -> str:
|
| 119 |
+
deed = profile.deed_line.format(
|
| 120 |
+
victim=victim_name, room=crime_room, instrument=weapon_name,
|
| 121 |
+
claimed=claimed_room, start=_clock(window_start), end=_clock(window_end),
|
| 122 |
+
)
|
| 123 |
+
trace = profile.trace_ask.format(culprit=culprit_name, room=crime_room,
|
| 124 |
+
instrument=weapon_name, victim=victim_name)
|
| 125 |
return (
|
| 126 |
+
f"In this mystery the {profile.perp_noun} is {culprit_name} ({culprit_role}). {deed}\n\n"
|
|
|
|
|
|
|
| 127 |
"Write the framing:\n"
|
| 128 |
"- their true MOTIVE (pick a category and a one-line summary);\n"
|
| 129 |
"- a one or two sentence METHOD narrative (how they did it);\n"
|
| 130 |
f"- the exact false ALIBI line they would say (claiming they were in the {claimed_room});\n"
|
| 131 |
"- TWO subtle pieces of PHYSICAL evidence in the "
|
| 132 |
+
f"{crime_room} that, on close inspection, {trace}. "
|
| 133 |
+
"Each must be a real forensic trace a detective would find - a fingerprint, a "
|
| 134 |
"dropped or monogrammed object, a torn fibre, a footprint, a stain, a stopped clock. "
|
| 135 |
+
"Describe ONLY the object and what it physically shows. Do NOT name the "
|
| 136 |
+
f"{profile.perp_noun} in the reveal, do NOT mention the {profile.incident_noun} or the "
|
| 137 |
+
"motive, and NEVER write a confession or any first-person words. (a short name + a "
|
| 138 |
+
"one-line physical description for each);\n"
|
| 139 |
"- a 3 to 4 step deduction chain of natural detective reasoning from those clues.\n\n"
|
| 140 |
"Keep it consistent with the facts above. Reply with ONLY this JSON object (exact keys; "
|
| 141 |
"motive_category must be one of greed, revenge, jealousy, fear, ambition, love, "
|
src/case_zero/llm/decoding.py
CHANGED
|
@@ -271,9 +271,12 @@ def generate_model[M: BaseModel](
|
|
| 271 |
except (json.JSONDecodeError, ValidationError, LLMError) as exc:
|
| 272 |
last_err = exc
|
| 273 |
|
| 274 |
-
# Reliable fallback: grammar-constrained (slower, guarantees structure).
|
|
|
|
|
|
|
| 275 |
schema = model_cls.model_json_schema()
|
| 276 |
-
gparams = GenParams(json_schema=schema, temperature=0.0,
|
|
|
|
| 277 |
try:
|
| 278 |
raw = backend.generate(prompt, gparams)
|
| 279 |
return model_cls.model_validate_json(_extract_json(raw))
|
|
|
|
| 271 |
except (json.JSONDecodeError, ValidationError, LLMError) as exc:
|
| 272 |
last_err = exc
|
| 273 |
|
| 274 |
+
# Reliable fallback: grammar-constrained (slower, guarantees structure). Grammar
|
| 275 |
+
# sampling is ~8x slower per token on CPU, so cap its budget at half - a healthy
|
| 276 |
+
# constrained output is far smaller than the free-form cap anyway.
|
| 277 |
schema = model_cls.model_json_schema()
|
| 278 |
+
gparams = GenParams(json_schema=schema, temperature=0.0,
|
| 279 |
+
max_tokens=max(256, max_tokens // 2), seed=seed)
|
| 280 |
try:
|
| 281 |
raw = backend.generate(prompt, gparams)
|
| 282 |
return model_cls.model_validate_json(_extract_json(raw))
|
src/case_zero/projections/suspect_brief.py
CHANGED
|
@@ -83,10 +83,15 @@ def build_suspect_brief(case: CaseFile, suspect: Suspect) -> SuspectBrief:
|
|
| 83 |
i_must_conceal: list[str] = list(suspect.secrets)
|
| 84 |
|
| 85 |
if suspect.is_culprit:
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
| 89 |
)
|
|
|
|
|
|
|
| 90 |
i_must_conceal.append(
|
| 91 |
f"Your alibi is a lie: you claim {case.culprit.alibi_lie.claimed_loc_id} "
|
| 92 |
f"but were actually at {case.culprit.alibi_lie.actual_loc_id}."
|
|
|
|
| 83 |
i_must_conceal: list[str] = list(suspect.secrets)
|
| 84 |
|
| 85 |
if suspect.is_culprit:
|
| 86 |
+
from ..generator.crime_profiles import profile_for
|
| 87 |
+
|
| 88 |
+
locs = {loc.loc_id: loc.name for loc in case.setting.locations}
|
| 89 |
+
deed = profile_for(case.crime_kind).brief_deed.format(
|
| 90 |
+
victim=case.victim.name, instrument=case.weapon.name,
|
| 91 |
+
room=locs.get(case.victim.found_at_loc_id, "the scene"),
|
| 92 |
)
|
| 93 |
+
i_did.append(case.culprit.method_narrative)
|
| 94 |
+
i_must_conceal.append(f"{deed} You must never admit this; deflect and deny.")
|
| 95 |
i_must_conceal.append(
|
| 96 |
f"Your alibi is a lie: you claim {case.culprit.alibi_lie.claimed_loc_id} "
|
| 97 |
f"but were actually at {case.culprit.alibi_lie.actual_loc_id}."
|
src/case_zero/schemas/case.py
CHANGED
|
@@ -12,7 +12,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
| 12 |
|
| 13 |
from ..constants import CASE_SCHEMA_VERSION, MAX_SUSPECTS, MIN_SUSPECTS
|
| 14 |
from .clue import Clue, Fact
|
| 15 |
-
from .enums import Difficulty, MotiveCategory
|
| 16 |
from .suspect import Suspect
|
| 17 |
from .timeline import Location, TimeWindow
|
| 18 |
|
|
@@ -30,6 +30,8 @@ class GenerationKnobs(BaseModel):
|
|
| 30 |
n_red_herrings: int = Field(default=2, ge=0, le=6)
|
| 31 |
alibi_tightness: float = Field(default=0.6, ge=0.0, le=1.0)
|
| 32 |
difficulty: Difficulty = Difficulty.STANDARD
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
class Setting(BaseModel):
|
|
@@ -120,6 +122,8 @@ class CaseFile(BaseModel):
|
|
| 120 |
title: str
|
| 121 |
briefing: str
|
| 122 |
knobs: GenerationKnobs
|
|
|
|
|
|
|
| 123 |
|
| 124 |
setting: Setting
|
| 125 |
victim: Victim
|
|
|
|
| 12 |
|
| 13 |
from ..constants import CASE_SCHEMA_VERSION, MAX_SUSPECTS, MIN_SUSPECTS
|
| 14 |
from .clue import Clue, Fact
|
| 15 |
+
from .enums import CrimeKind, Difficulty, MotiveCategory
|
| 16 |
from .suspect import Suspect
|
| 17 |
from .timeline import Location, TimeWindow
|
| 18 |
|
|
|
|
| 30 |
n_red_herrings: int = Field(default=2, ge=0, le=6)
|
| 31 |
alibi_tightness: float = Field(default=0.6, ge=0.0, le=1.0)
|
| 32 |
difficulty: Difficulty = Difficulty.STANDARD
|
| 33 |
+
# None -> the pipeline draws a kind from the seed (weighted toward homicide).
|
| 34 |
+
crime_kind: CrimeKind | None = None
|
| 35 |
|
| 36 |
|
| 37 |
class Setting(BaseModel):
|
|
|
|
| 122 |
title: str
|
| 123 |
briefing: str
|
| 124 |
knobs: GenerationKnobs
|
| 125 |
+
# Defaulted so every pre-kind case file (golden, prebaked) loads unchanged.
|
| 126 |
+
crime_kind: CrimeKind = CrimeKind.HOMICIDE
|
| 127 |
|
| 128 |
setting: Setting
|
| 129 |
victim: Victim
|
src/case_zero/schemas/enums.py
CHANGED
|
@@ -17,6 +17,20 @@ class Difficulty(StrEnum):
|
|
| 17 |
FIENDISH = "fiendish"
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
class DiscoveryMethod(StrEnum):
|
| 21 |
"""How a clue can be obtained. Non-INTERROGATION methods guarantee a clue is
|
| 22 |
reachable even if no suspect ever volunteers it (the discoverability gate)."""
|
|
|
|
| 17 |
FIENDISH = "fiendish"
|
| 18 |
|
| 19 |
|
| 20 |
+
class CrimeKind(StrEnum):
|
| 21 |
+
"""What kind of case this is. The structure (one culprit, a false alibi broken by
|
| 22 |
+
physical evidence, innocents with unrelated secrets) is identical for every kind;
|
| 23 |
+
only the story the fields tell changes. HOMICIDE stays the default so every
|
| 24 |
+
existing case file loads unchanged."""
|
| 25 |
+
|
| 26 |
+
HOMICIDE = "homicide"
|
| 27 |
+
THEFT = "theft"
|
| 28 |
+
FRAUD = "fraud"
|
| 29 |
+
BLACKMAIL = "blackmail"
|
| 30 |
+
ARSON = "arson"
|
| 31 |
+
MISSING = "missing_person"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
class DiscoveryMethod(StrEnum):
|
| 35 |
"""How a clue can be obtained. Non-INTERROGATION methods guarantee a clue is
|
| 36 |
reachable even if no suspect ever volunteers it (the discoverability gate)."""
|
src/case_zero/suspects/persona.py
CHANGED
|
@@ -14,7 +14,7 @@ from ..schemas.enums import Relevance
|
|
| 14 |
from .deception import most_relevant_lie
|
| 15 |
|
| 16 |
_SYSTEM = (
|
| 17 |
-
"You are role-playing ONE character in a
|
| 18 |
"with a past, feelings, and something to hide, never an AI or narrator. "
|
| 19 |
"Speak only as this person, in the first person. Your words are spoken ALOUD, so write "
|
| 20 |
"JUST what you say - never describe your own speech, never write 'I say', 'what I'm "
|
|
@@ -32,7 +32,7 @@ _SYSTEM = (
|
|
| 32 |
"You are an ordinary person under suspicion, NOT a criminal mastermind: never cast yourself "
|
| 33 |
"as scheming, plotting, or sinister, and never use words like 'scheme', 'plot', 'mastermind', "
|
| 34 |
"or 'my next move'. Being a rival, a creditor, or holding a grudge is not a crime - own that "
|
| 35 |
-
"ordinary, unflattering truth plainly without ever making yourself sound like the
|
| 36 |
"React to the detective's TONE like a real person would: if they are calm, stay guarded; "
|
| 37 |
"if they are harsh, insulting, swearing, or threatening, show real emotion - a timid "
|
| 38 |
"person gets frightened, rattled, even pleading, while a bold one bristles and snaps "
|
|
|
|
| 14 |
from .deception import most_relevant_lie
|
| 15 |
|
| 16 |
_SYSTEM = (
|
| 17 |
+
"You are role-playing ONE character in a crime-case interrogation - a real person "
|
| 18 |
"with a past, feelings, and something to hide, never an AI or narrator. "
|
| 19 |
"Speak only as this person, in the first person. Your words are spoken ALOUD, so write "
|
| 20 |
"JUST what you say - never describe your own speech, never write 'I say', 'what I'm "
|
|
|
|
| 32 |
"You are an ordinary person under suspicion, NOT a criminal mastermind: never cast yourself "
|
| 33 |
"as scheming, plotting, or sinister, and never use words like 'scheme', 'plot', 'mastermind', "
|
| 34 |
"or 'my next move'. Being a rival, a creditor, or holding a grudge is not a crime - own that "
|
| 35 |
+
"ordinary, unflattering truth plainly without ever making yourself sound like the guilty party. "
|
| 36 |
"React to the detective's TONE like a real person would: if they are calm, stay guarded; "
|
| 37 |
"if they are harsh, insulting, swearing, or threatening, show real emotion - a timid "
|
| 38 |
"person gets frightened, rattled, even pleading, while a bold one bristles and snaps "
|
src/case_zero/suspects/scrub.py
CHANGED
|
@@ -21,14 +21,17 @@ import re
|
|
| 21 |
_ADMIT = re.compile(
|
| 22 |
r"""\b(
|
| 23 |
i\s+(killed|murdered|stabbed|poisoned|strangled|shot|drowned|smothered)\b
|
|
|
|
|
|
|
|
|
|
| 24 |
| i\s+did\s+it\b
|
| 25 |
| i\s+planned\s+(it|the\s+\w+)
|
| 26 |
| i\s+committed\b
|
| 27 |
| i\s+confess\b
|
| 28 |
-
| i\s+am\s+(the\s+)?(killer|guilty|to\s+blame|responsible)
|
| 29 |
-
| i'?m\s+(the\s+)?(killer|guilty|to\s+blame|responsible)
|
| 30 |
| it\s+was\s+me\b
|
| 31 |
-
| i\s+hid\s+the\s+(body|weapon|knife|gun)
|
| 32 |
| caught\s+me\s+(red[\s-]?handed|trying|with|in\s+the|hiding|leaving|sneaking|taking)
|
| 33 |
| my\s+secret\s+is\s+out
|
| 34 |
)""",
|
|
|
|
| 21 |
_ADMIT = re.compile(
|
| 22 |
r"""\b(
|
| 23 |
i\s+(killed|murdered|stabbed|poisoned|strangled|shot|drowned|smothered)\b
|
| 24 |
+
| i\s+(stole|robbed|forged|defrauded|swindled|blackmailed|extorted|torched|abducted|kidnapped)\b
|
| 25 |
+
| i\s+(set|started|lit)\s+the\s+fire\b
|
| 26 |
+
| i\s+took\s+(it|him|her|them|the\s+\w+)\b
|
| 27 |
| i\s+did\s+it\b
|
| 28 |
| i\s+planned\s+(it|the\s+\w+)
|
| 29 |
| i\s+committed\b
|
| 30 |
| i\s+confess\b
|
| 31 |
+
| i\s+am\s+(the\s+)?(killer|thief|arsonist|blackmailer|fraudster|abductor|guilty|to\s+blame|responsible)
|
| 32 |
+
| i'?m\s+(the\s+)?(killer|thief|arsonist|blackmailer|fraudster|abductor|guilty|to\s+blame|responsible)
|
| 33 |
| it\s+was\s+me\b
|
| 34 |
+
| i\s+hid\s+the\s+(body|weapon|knife|gun|jewels?|money|ledger|letters?)
|
| 35 |
| caught\s+me\s+(red[\s-]?handed|trying|with|in\s+the|hiding|leaving|sneaking|taking)
|
| 36 |
| my\s+secret\s+is\s+out
|
| 37 |
)""",
|
tests/test_crime_kinds.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-crime-type cases: every kind assembles, solves, and projects with the right
|
| 2 |
+
display labels - and homicide stays the unchanged default for old case files.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from case_zero.api.case_adapter import casefile_to_public
|
| 10 |
+
from case_zero.generator.assemble import assemble_case
|
| 11 |
+
from case_zero.generator.crime_profiles import PROFILES, kind_for_seed, profile_for
|
| 12 |
+
from case_zero.generator.stages import GenSuspect, MysteryOut, WorldCastOut, mystery_prompt, world_cast_prompt
|
| 13 |
+
from case_zero.projections.suspect_brief import build_suspect_brief
|
| 14 |
+
from case_zero.schemas.case import GenerationKnobs
|
| 15 |
+
from case_zero.schemas.enums import CrimeKind, MotiveCategory
|
| 16 |
+
from case_zero.schemas.timeline import TimeWindow
|
| 17 |
+
from case_zero.solver.checker import check
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _world() -> WorldCastOut:
|
| 21 |
+
return WorldCastOut(
|
| 22 |
+
title="THE LANTERN ROOM",
|
| 23 |
+
briefing="Trouble at a winter salon.",
|
| 24 |
+
setting_name="Ashgrove House",
|
| 25 |
+
locations=["The Study", "The Parlor", "The Conservatory", "The Cellar"],
|
| 26 |
+
victim_name="Edmund Crane",
|
| 27 |
+
victim_role="Master Bookbinder",
|
| 28 |
+
cause_of_death="The loss came to light just before midnight.",
|
| 29 |
+
found_at_index=0,
|
| 30 |
+
weapon_name="bookbinding press",
|
| 31 |
+
weapon_kind="blunt",
|
| 32 |
+
suspects=[
|
| 33 |
+
GenSuspect(name="Cora Vale", role="Apprentice", persona_summary="Quiet, ink-stained.", secret="She forged a signature once.", cover_story="I was cataloguing.", evidence_name="ink bottle", evidence_reveal="A spilled bottle of rare ink.", gender="female"),
|
| 34 |
+
GenSuspect(name="Miles Ardent", role="Patron", persona_summary="Wealthy, indebted.", secret="He owes the victim money.", cover_story="I never left the salon.", evidence_name="promissory note", evidence_reveal="A note for a large sum, unpaid.", gender="male"),
|
| 35 |
+
GenSuspect(name="Greta Holt", role="Housekeeper", persona_summary="Long-serving.", secret="She skims the accounts.", cover_story="I was fetching wine.", evidence_name="ledger", evidence_reveal="A ledger with altered figures.", gender="female"),
|
| 36 |
+
],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _mystery() -> MysteryOut:
|
| 41 |
+
return MysteryOut(
|
| 42 |
+
motive_category=MotiveCategory.GREED,
|
| 43 |
+
motive_summary="To erase a debt that would have ruined him.",
|
| 44 |
+
method_narrative="He waited until the study emptied.",
|
| 45 |
+
alibi_claim="I was in the parlor with the others all evening.",
|
| 46 |
+
alibi_claimed_loc_index=1,
|
| 47 |
+
breaker_one_name="thread",
|
| 48 |
+
breaker_one_reveal="A torn thread of dark cloth on the study latch.",
|
| 49 |
+
breaker_two_name="footprint",
|
| 50 |
+
breaker_two_reveal="A damp footprint by the press, fresh at the hour.",
|
| 51 |
+
deduction_chain=["The study was locked.", "One route stayed open.", "The thread matches."],
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _case(kind: CrimeKind):
|
| 56 |
+
return assemble_case(
|
| 57 |
+
case_id="gen-000099", seed=99, knobs=GenerationKnobs(crime_kind=kind),
|
| 58 |
+
world=_world(), mystery=_mystery(), profile=profile_for(kind),
|
| 59 |
+
window=TimeWindow(start_min=21 * 60, end_min=22 * 60),
|
| 60 |
+
tod=TimeWindow(start_min=21 * 60 + 20, end_min=21 * 60 + 50),
|
| 61 |
+
culprit_idx=1, crime_idx=0, claimed_idx=1,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@pytest.mark.parametrize("kind", list(CrimeKind))
|
| 66 |
+
def test_every_kind_assembles_solvable_and_labelled(kind: CrimeKind):
|
| 67 |
+
case = _case(kind)
|
| 68 |
+
assert case.crime_kind == kind
|
| 69 |
+
report = check(case)
|
| 70 |
+
assert report.ok, f"{kind}: {report.issues}"
|
| 71 |
+
pub = casefile_to_public(case)
|
| 72 |
+
profile = PROFILES[kind]
|
| 73 |
+
assert pub.kind == kind.value
|
| 74 |
+
assert pub.division == profile.division
|
| 75 |
+
assert pub.victim_status == profile.victim_status
|
| 76 |
+
assert ("VERDICT", profile.verdict) in pub.facts
|
| 77 |
+
# the incident story beat sits on the REAL crime scene, not a generic painter name
|
| 78 |
+
assert pub.story_beats[1].scene == pub.scene
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.mark.parametrize("kind", list(CrimeKind))
|
| 82 |
+
def test_culprit_brief_states_the_right_deed(kind: CrimeKind):
|
| 83 |
+
case = _case(kind)
|
| 84 |
+
culprit = case.suspect(case.culprit.sus_id)
|
| 85 |
+
brief = build_suspect_brief(case, culprit)
|
| 86 |
+
deed = PROFILES[kind].brief_deed.format(
|
| 87 |
+
victim=case.victim.name, instrument=case.weapon.name, room="The Study",
|
| 88 |
+
)
|
| 89 |
+
assert any(deed in line for line in brief.i_must_conceal)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_prompts_carry_the_kind():
|
| 93 |
+
p = profile_for(CrimeKind.THEFT)
|
| 94 |
+
w = world_cast_prompt(p, "", "", "", 4, 21 * 60, 22 * 60)
|
| 95 |
+
assert "heist" in w and "STOLEN OBJECT" in w
|
| 96 |
+
m = mystery_prompt(p, "Miles Ardent", "Patron", "Edmund Crane", "the Falcon Diamond",
|
| 97 |
+
"The Study", "The Parlor", 21 * 60, 22 * 60)
|
| 98 |
+
assert "thief" in m and "stole" in m
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_kind_for_seed_covers_all_kinds():
|
| 102 |
+
kinds = {kind_for_seed(s) for s in range(64)}
|
| 103 |
+
assert kinds == set(CrimeKind)
|
| 104 |
+
# homicide stays the most common draw
|
| 105 |
+
draws = [kind_for_seed(s) for s in range(90)]
|
| 106 |
+
assert draws.count(CrimeKind.HOMICIDE) > draws.count(CrimeKind.THEFT)
|
tests/test_runtime_gen.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background-generation safety: a player interrupt aborts an in-flight generation call
|
| 2 |
+
between tokens and frees the single-flight lock immediately.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import threading
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from case_zero.api.runtime import _SharedLockBackend
|
| 12 |
+
from case_zero.llm.backend import GenParams, LLMError
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class _SlowStreamBackend:
|
| 16 |
+
"""Yields tokens one by one; records how many were consumed."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, tokens: list[str]) -> None:
|
| 19 |
+
self.tokens = tokens
|
| 20 |
+
self.consumed = 0
|
| 21 |
+
|
| 22 |
+
def generate(self, prompt: str, params: GenParams) -> str:
|
| 23 |
+
return "".join(self.tokens)
|
| 24 |
+
|
| 25 |
+
def stream(self, prompt: str, params: GenParams):
|
| 26 |
+
for t in self.tokens:
|
| 27 |
+
self.consumed += 1
|
| 28 |
+
yield t
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_uninterrupted_generate_joins_stream() -> None:
|
| 32 |
+
inner = _SlowStreamBackend(["a", "b", "c"])
|
| 33 |
+
lock = threading.Lock()
|
| 34 |
+
wrapped = _SharedLockBackend(inner, lock, threading.Event())
|
| 35 |
+
assert wrapped.generate("p", GenParams()) == "abc"
|
| 36 |
+
assert not lock.locked(), "lock must be released after the call"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_interrupt_aborts_between_tokens_and_frees_lock() -> None:
|
| 40 |
+
inner = _SlowStreamBackend(["a", "b", "c", "d", "e"])
|
| 41 |
+
lock = threading.Lock()
|
| 42 |
+
interrupt = threading.Event()
|
| 43 |
+
|
| 44 |
+
class _TripWire(_SlowStreamBackend):
|
| 45 |
+
def stream(self, prompt: str, params: GenParams):
|
| 46 |
+
for i, t in enumerate(self.tokens):
|
| 47 |
+
if i == 2:
|
| 48 |
+
interrupt.set() # the player shows up mid-stream
|
| 49 |
+
self.consumed += 1
|
| 50 |
+
yield t
|
| 51 |
+
|
| 52 |
+
tripwire = _TripWire(["a", "b", "c", "d", "e"])
|
| 53 |
+
wrapped = _SharedLockBackend(tripwire, lock, interrupt)
|
| 54 |
+
with pytest.raises(LLMError, match="interrupted"):
|
| 55 |
+
wrapped.generate("p", GenParams())
|
| 56 |
+
assert tripwire.consumed <= 3, "must abort within ~a token of the interrupt"
|
| 57 |
+
assert not lock.locked(), "lock must be freed for the player immediately"
|
| 58 |
+
assert inner.consumed == 0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_no_interrupt_event_uses_plain_generate() -> None:
|
| 62 |
+
inner = _SlowStreamBackend(["x", "y"])
|
| 63 |
+
wrapped = _SharedLockBackend(inner, threading.Lock(), None)
|
| 64 |
+
assert wrapped.generate("p", GenParams()) == "xy"
|
| 65 |
+
assert inner.consumed == 0, "plain path must not stream"
|
web/src/engine/art.ts
CHANGED
|
@@ -574,22 +574,515 @@ const paintBedroom: ScenePainter = (ctx, w, h) => {
|
|
| 574 |
ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 10, w, 10)
|
| 575 |
}
|
| 576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
export const SCENES: Record<string, ScenePainter> = {
|
| 578 |
skyline: paintSkyline, desk: paintDesk, atrium: paintAtrium, interro: paintInterro,
|
| 579 |
seawall: paintSeawall, mezzanine: paintMezzanine, map: paintMap,
|
| 580 |
kitchen: paintKitchen, study: paintStudy, parlor: paintParlor, bedroom: paintBedroom,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
}
|
| 582 |
|
| 583 |
-
// Map a free-text location name (generated cases invent rooms) to the closest
|
|
|
|
| 584 |
const _ROOM_MAP: [RegExp, string][] = [
|
| 585 |
-
[/
|
| 586 |
-
[/
|
| 587 |
-
[/
|
| 588 |
-
[/
|
| 589 |
-
[/
|
| 590 |
-
[/
|
| 591 |
-
[/
|
| 592 |
-
[/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
]
|
| 594 |
|
| 595 |
export function sceneForRoom(name: string): ScenePainter {
|
|
@@ -597,6 +1090,340 @@ export function sceneForRoom(name: string): ScenePainter {
|
|
| 597 |
return SCENES.parlor // generic interior
|
| 598 |
}
|
| 599 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
export function sceneFor(name: string): ScenePainter {
|
| 601 |
if (SCENES[name]) return SCENES[name]
|
| 602 |
// "Building — Room" -> key the painter off the room (the part after the last dash).
|
|
|
|
| 574 |
ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 10, w, 10)
|
| 575 |
}
|
| 576 |
|
| 577 |
+
const paintAlley: ScenePainter = (ctx, w, h, t) => {
|
| 578 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
|
| 579 |
+
// facing walls funneling to a lit back street
|
| 580 |
+
ctx.fillStyle = '#0a121a'; ctx.fillRect(0, 0, Math.floor(w * 0.3), h)
|
| 581 |
+
ctx.fillStyle = '#0c141c'; ctx.fillRect(Math.floor(w * 0.7), 0, Math.ceil(w * 0.3), h)
|
| 582 |
+
for (let y = 8; y < h - 30; y += 14) {
|
| 583 |
+
ctx.fillStyle = C.bldgL; ctx.fillRect(8, y, 14, 8); ctx.fillRect(w - 24, y + 5, 14, 8)
|
| 584 |
+
ctx.fillStyle = (y % 28) ? C.winDim : C.win; ctx.fillRect(11, y + 2, 3, 3); ctx.fillRect(w - 21, y + 7, 3, 3)
|
| 585 |
+
}
|
| 586 |
+
// fire escape zig-zag on the left wall
|
| 587 |
+
ctx.fillStyle = _METAL
|
| 588 |
+
for (let i = 0; i < 4; i++) { const y = 16 + i * 22; ctx.fillRect(26, y, 26, 2); ctx.fillRect(i % 2 ? 26 : 50, y - 12, 2, 14) }
|
| 589 |
+
// wet cobbled lane
|
| 590 |
+
ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 26, w, 26)
|
| 591 |
+
for (let x = 2; x < w; x += 9) { ctx.fillStyle = (x % 18) ? '#101a22' : C.slate; ctx.fillRect(x, h - 24 + (x % 3), 5, 2) }
|
| 592 |
+
// dumpster + crates
|
| 593 |
+
ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 18)
|
| 594 |
+
ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 3)
|
| 595 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.32) + 40, h - 34, 12, 10); ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.32) + 40, h - 34, 12, 2)
|
| 596 |
+
// buzzing sign at the alley mouth
|
| 597 |
+
const sx = Math.floor(w * 0.62)
|
| 598 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(sx, 18, 3, 16)
|
| 599 |
+
ctx.fillStyle = C.ox; ctx.fillRect(sx - 12, 20, 12, 12)
|
| 600 |
+
ctx.fillStyle = '#b8443f'; ctx.fillRect(sx - 10, 22, 8, 8)
|
| 601 |
+
lampCone(ctx, sx - 6, 32, 30, h - 60)
|
| 602 |
+
rainStreaks(ctx, w, h, t)
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
const paintBar: ScenePainter = (ctx, w, h) => {
|
| 606 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
|
| 607 |
+
// back shelf with bottles
|
| 608 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(10, 12, w - 20, 34)
|
| 609 |
+
ctx.fillStyle = '#241a10'; ctx.fillRect(10, 28, w - 20, 3); ctx.fillRect(10, 43, w - 20, 3)
|
| 610 |
+
for (let x = 16; x < w - 18; x += 7) {
|
| 611 |
+
ctx.fillStyle = _BOOK[(x >> 2) % _BOOK.length]; ctx.fillRect(x, 18, 4, 10); ctx.fillRect(x + 1, 15, 2, 3)
|
| 612 |
+
if ((x >> 3) % 2) { ctx.fillStyle = _BOOK[(x >> 1) % _BOOK.length]; ctx.fillRect(x, 33, 4, 10); ctx.fillRect(x + 1, 31, 2, 2) }
|
| 613 |
+
}
|
| 614 |
+
// mirror strip behind the top row
|
| 615 |
+
ctx.fillStyle = C.slateL; ctx.fillRect(12, 13, w - 24, 2)
|
| 616 |
+
// counter
|
| 617 |
+
const cy = Math.floor(h * 0.62)
|
| 618 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(0, cy, w, 5)
|
| 619 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(0, cy + 5, w, h - cy - 5)
|
| 620 |
+
for (let x = 6; x < w; x += 22) { ctx.fillStyle = C.shadow; ctx.fillRect(x, cy + 5, 1, h - cy - 5) }
|
| 621 |
+
// stools
|
| 622 |
+
for (let i = 0; i < 4; i++) {
|
| 623 |
+
const x = 24 + i * Math.floor(w * 0.22)
|
| 624 |
+
ctx.fillStyle = C.ox; ctx.fillRect(x, h - 22, 14, 4)
|
| 625 |
+
ctx.fillStyle = _METAL; ctx.fillRect(x + 6, h - 18, 2, 18)
|
| 626 |
+
}
|
| 627 |
+
// glass + bottle left on the counter
|
| 628 |
+
ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.3), cy - 6, 4, 6)
|
| 629 |
+
ctx.fillStyle = C.amberD; ctx.fillRect(Math.floor(w * 0.3) + 10, cy - 10, 4, 10); ctx.fillStyle = C.amber; ctx.fillRect(Math.floor(w * 0.3) + 11, cy - 12, 2, 2)
|
| 630 |
+
// low pendant lamps
|
| 631 |
+
for (const fx of [0.25, 0.65]) {
|
| 632 |
+
const lx = Math.floor(w * fx)
|
| 633 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 8)
|
| 634 |
+
ctx.fillStyle = C.amberD; ctx.fillRect(lx - 5, 8, 11, 4); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 3, 12, 7, 2)
|
| 635 |
+
lampCone(ctx, lx, 13, 34, cy - 16)
|
| 636 |
+
}
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
const paintCasino: ScenePainter = (ctx, w, h) => {
|
| 640 |
+
ditherGrad(ctx, 0, 0, w, h, '#131a14', '#0a0f0a')
|
| 641 |
+
// wall pattern + framed rules board
|
| 642 |
+
for (let x = 0; x < w; x += 16) { ctx.fillStyle = '#0d1410'; ctx.fillRect(x, 0, 2, Math.floor(h * 0.5)) }
|
| 643 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(w - 58, 10, 40, 24); ctx.fillStyle = C.bone; ctx.fillRect(w - 54, 13, 32, 18)
|
| 644 |
+
ctx.fillStyle = C.shadow; for (let i = 0; i < 4; i++) ctx.fillRect(w - 50, 16 + i * 4, 24, 1)
|
| 645 |
+
// felt table, elliptical feel via stacked rects
|
| 646 |
+
const ty = Math.floor(h * 0.55)
|
| 647 |
+
ctx.fillStyle = '#14401f'; ctx.fillRect(Math.floor(w * 0.1), ty, Math.floor(w * 0.8), 26)
|
| 648 |
+
ctx.fillStyle = '#1d5a2c'; ctx.fillRect(Math.floor(w * 0.13), ty, Math.floor(w * 0.74), 4)
|
| 649 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.08), ty + 26, Math.floor(w * 0.84), 5)
|
| 650 |
+
// cards + chip stacks
|
| 651 |
+
ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.3), ty + 8, 7, 9); ctx.fillRect(Math.floor(w * 0.34), ty + 6, 7, 9)
|
| 652 |
+
ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.36), ty + 8, 2, 2)
|
| 653 |
+
for (let i = 0; i < 3; i++) {
|
| 654 |
+
const x = Math.floor(w * 0.55) + i * 9
|
| 655 |
+
ctx.fillStyle = [C.ox, C.bone, C.amber][i]; ctx.fillRect(x, ty + 10 - (i % 2) * 2, 6, 6 + (i % 2) * 2)
|
| 656 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(x, ty + 14, 6, 1)
|
| 657 |
+
}
|
| 658 |
+
// dice
|
| 659 |
+
ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.46), ty + 12, 4, 4); ctx.fillStyle = C.shadow; ctx.fillRect(Math.floor(w * 0.47), ty + 13, 1, 1)
|
| 660 |
+
// green-shaded pendant
|
| 661 |
+
const lx = Math.floor(w * 0.5)
|
| 662 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 10)
|
| 663 |
+
ctx.fillStyle = '#1d5a2c'; ctx.fillRect(lx - 8, 10, 17, 5); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 5, 15, 11, 2)
|
| 664 |
+
lampCone(ctx, lx, 16, 50, ty - 16)
|
| 665 |
+
ctx.fillStyle = '#0a0f0a'; ctx.fillRect(0, h - 10, w, 10)
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
const paintTheater: ScenePainter = (ctx, w, h) => {
|
| 669 |
+
ditherGrad(ctx, 0, 0, w, h, '#1a0e10', '#0c0608')
|
| 670 |
+
// stage floor + proscenium
|
| 671 |
+
const sy = Math.floor(h * 0.58)
|
| 672 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(0, sy, w, 8)
|
| 673 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(0, sy, w, 2)
|
| 674 |
+
// heavy curtains left/right with fold lines
|
| 675 |
+
for (const [x0, dir] of [[0, 1], [w - Math.floor(w * 0.18), -1]] as [number, number][]) {
|
| 676 |
+
const cw = Math.floor(w * 0.18)
|
| 677 |
+
ctx.fillStyle = '#5e1c1c'; ctx.fillRect(x0, 0, cw, sy)
|
| 678 |
+
for (let x = x0 + 2; x < x0 + cw; x += 5) { ctx.fillStyle = '#481414'; ctx.fillRect(x, 0, 2, sy) }
|
| 679 |
+
ctx.fillStyle = '#87292a'; ctx.fillRect(dir > 0 ? x0 : x0 + cw - 3, 0, 3, sy)
|
| 680 |
+
}
|
| 681 |
+
// curtain valance
|
| 682 |
+
ctx.fillStyle = '#5e1c1c'; ctx.fillRect(0, 0, w, 14)
|
| 683 |
+
for (let x = 3; x < w; x += 9) { ctx.fillStyle = '#481414'; ctx.fillRect(x, 10, 5, 6) }
|
| 684 |
+
ctx.fillStyle = C.amberD; ctx.fillRect(0, 14, w, 2)
|
| 685 |
+
// backdrop + lone prop chair
|
| 686 |
+
ditherGrad(ctx, Math.floor(w * 0.2), 18, Math.floor(w * 0.6), sy - 20, C.sky2, C.sky0)
|
| 687 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.46), sy - 18, 3, 18); ctx.fillRect(Math.floor(w * 0.55), sy - 18, 3, 18); ctx.fillRect(Math.floor(w * 0.46), sy - 18, 12, 3); ctx.fillRect(Math.floor(w * 0.46), sy - 30, 3, 12)
|
| 688 |
+
// footlights
|
| 689 |
+
for (let x = 10; x < w - 8; x += 18) { ctx.fillStyle = C.lamp; ctx.fillRect(x, sy - 2, 4, 2); lampCone(ctx, x + 2, sy - 2, 10, 12) }
|
| 690 |
+
// audience rows in the dark
|
| 691 |
+
ctx.fillStyle = '#070b0f'; ctx.fillRect(0, sy + 8, w, h - sy - 8)
|
| 692 |
+
for (let y = sy + 12; y < h - 4; y += 9) for (let x = 8 + (y % 2) * 6; x < w - 8; x += 14) { ctx.fillStyle = '#10161c'; ctx.fillRect(x, y, 9, 5) }
|
| 693 |
+
lampCone(ctx, Math.floor(w * 0.5), 16, 60, sy - 18)
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
const paintWarehouse: ScenePainter = (ctx, w, h, t) => {
|
| 697 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
|
| 698 |
+
// high ribbon windows letting moonlight in
|
| 699 |
+
for (let x = 8; x < w - 8; x += 34) {
|
| 700 |
+
ctx.fillStyle = C.slate; ctx.fillRect(x, 6, 26, 12)
|
| 701 |
+
ditherGrad(ctx, x + 2, 8, 22, 8, C.slateL, C.sky1)
|
| 702 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(x + 12, 6, 2, 12)
|
| 703 |
+
}
|
| 704 |
+
// roof trusses
|
| 705 |
+
ctx.fillStyle = _METAL
|
| 706 |
+
for (let x = 0; x < w; x += 40) { ctx.fillRect(x, 20, 2, 10); ctx.fillRect(x, 28, 40, 2) }
|
| 707 |
+
// crate stacks
|
| 708 |
+
const crate = (x: number, y: number, s: number) => {
|
| 709 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(x, y, s, s)
|
| 710 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(x, y, s, 2); ctx.fillRect(x, y, 2, s)
|
| 711 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(x + 2, y + Math.floor(s / 2), s - 4, 1)
|
| 712 |
+
}
|
| 713 |
+
crate(14, h - 50, 24); crate(40, h - 50, 24); crate(26, h - 74, 24)
|
| 714 |
+
crate(w - 44, h - 44, 18); crate(w - 70, h - 44, 18)
|
| 715 |
+
// hanging chain + hook
|
| 716 |
+
ctx.fillStyle = _METAL; for (let y = 0; y < 36; y += 4) ctx.fillRect(Math.floor(w * 0.58), y, 2, 3)
|
| 717 |
+
ctx.fillRect(Math.floor(w * 0.58) - 2, 36, 6, 3)
|
| 718 |
+
// floor
|
| 719 |
+
ctx.fillStyle = '#0c1014'; ctx.fillRect(0, h - 26, w, 26)
|
| 720 |
+
for (let x = 0; x < w; x += 26) { ctx.fillStyle = '#11161b'; ctx.fillRect(x, h - 26, 1, 26) }
|
| 721 |
+
lampCone(ctx, Math.floor(w * 0.4), 30, 44, h - 60)
|
| 722 |
+
rainStreaks(ctx, w, 18, t)
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
const paintRooftop: ScenePainter = (ctx, w, h, t) => {
|
| 726 |
+
// city far below beyond the parapet
|
| 727 |
+
ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.55), C.sky2, C.sky0)
|
| 728 |
+
for (let x = 0; x < w; x += 14) {
|
| 729 |
+
const bh = 6 + ((x * 13) % 22)
|
| 730 |
+
ctx.fillStyle = C.bldg; ctx.fillRect(x, Math.floor(h * 0.55) - bh, 12, bh)
|
| 731 |
+
ctx.fillStyle = (x % 28) ? C.winDim : C.win; ctx.fillRect(x + 3, Math.floor(h * 0.55) - bh + 3, 2, 2)
|
| 732 |
+
}
|
| 733 |
+
// parapet ledge
|
| 734 |
+
ctx.fillStyle = '#10181f'; ctx.fillRect(0, Math.floor(h * 0.55), w, 6)
|
| 735 |
+
ctx.fillStyle = C.slate; ctx.fillRect(0, Math.floor(h * 0.55), w, 2)
|
| 736 |
+
// tar roof
|
| 737 |
+
ditherGrad(ctx, 0, Math.floor(h * 0.55) + 6, w, h, '#0c1117', '#070b0f')
|
| 738 |
+
// vents, pipe, water tank
|
| 739 |
+
ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.18), h - 34, 18, 14)
|
| 740 |
+
ctx.fillStyle = '#3a444e'; ctx.fillRect(Math.floor(w * 0.18), h - 34, 18, 3)
|
| 741 |
+
ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.24), h - 40, 4, 6)
|
| 742 |
+
const wx = Math.floor(w * 0.72)
|
| 743 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(wx, h - 64, 30, 26)
|
| 744 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(wx, h - 64, 30, 3)
|
| 745 |
+
ctx.fillStyle = _METAL; ctx.fillRect(wx + 4, h - 38, 2, 12); ctx.fillRect(wx + 24, h - 38, 2, 12)
|
| 746 |
+
ctx.fillStyle = '#241c12'; ctx.fillRect(wx + 6, h - 70, 18, 6)
|
| 747 |
+
// door bulkhead with a weak lamp
|
| 748 |
+
ctx.fillStyle = '#0e151c'; ctx.fillRect(8, h - 56, 26, 30)
|
| 749 |
+
ctx.fillStyle = C.slate; ctx.fillRect(8, h - 56, 26, 2)
|
| 750 |
+
ctx.fillStyle = '#1a232c'; ctx.fillRect(14, h - 48, 12, 22)
|
| 751 |
+
ctx.fillStyle = C.lamp; ctx.fillRect(19, h - 60, 4, 2)
|
| 752 |
+
lampCone(ctx, 21, h - 58, 22, 28)
|
| 753 |
+
rainStreaks(ctx, w, h, t)
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
const paintOffice: ScenePainter = (ctx, w, h, t) => {
|
| 757 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
|
| 758 |
+
// blinds with light slicing through
|
| 759 |
+
const bw = Math.floor(w * 0.3)
|
| 760 |
+
ctx.fillStyle = C.sky2; ctx.fillRect(w - bw - 10, 8, bw, 44)
|
| 761 |
+
for (let y = 10; y < 50; y += 4) { ctx.fillStyle = (y % 8) ? C.shadow : C.winDim; ctx.fillRect(w - bw - 10, y, bw, 2) }
|
| 762 |
+
// filing cabinets
|
| 763 |
+
for (let i = 0; i < 2; i++) {
|
| 764 |
+
const x = 10 + i * 22
|
| 765 |
+
ctx.fillStyle = _METAL; ctx.fillRect(x, h - 64, 18, 40)
|
| 766 |
+
for (let d = 0; d < 3; d++) { ctx.fillStyle = '#3a444e'; ctx.fillRect(x + 2, h - 60 + d * 12, 14, 2); ctx.fillStyle = C.amberD; ctx.fillRect(x + 7, h - 57 + d * 12, 4, 1) }
|
| 767 |
+
}
|
| 768 |
+
// desk with papers, phone, banker's lamp
|
| 769 |
+
const dy = h - 30
|
| 770 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.3), dy, Math.floor(w * 0.52), 6)
|
| 771 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.3), dy, Math.floor(w * 0.52), 2)
|
| 772 |
+
ctx.fillStyle = '#241a12'; ctx.fillRect(Math.floor(w * 0.33), dy + 6, 8, h - dy - 6); ctx.fillRect(Math.floor(w * 0.72), dy + 6, 8, h - dy - 6)
|
| 773 |
+
ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.38), dy - 4, 16, 4); ctx.fillRect(Math.floor(w * 0.42), dy - 6, 14, 2)
|
| 774 |
+
ctx.fillStyle = '#15181d'; ctx.fillRect(Math.floor(w * 0.6), dy - 6, 12, 6)
|
| 775 |
+
const lx = Math.floor(w * 0.5)
|
| 776 |
+
ctx.fillStyle = C.amberD; ctx.fillRect(lx - 6, dy - 14, 13, 4); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, dy - 10, 9, 2)
|
| 777 |
+
lampCone(ctx, lx, dy - 9, 30, 26)
|
| 778 |
+
// coat stand
|
| 779 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(w - 16, h - 58, 2, 34)
|
| 780 |
+
ctx.fillStyle = C.sky2; ctx.fillRect(w - 22, h - 54, 10, 16)
|
| 781 |
+
ctx.fillStyle = '#0c1117'; ctx.fillRect(0, h - 12, w, 12)
|
| 782 |
+
rainStreaks(ctx, w - bw - 10 + 2, 50, t)
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
const paintLobby: ScenePainter = (ctx, w, h) => {
|
| 786 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0)
|
| 787 |
+
// key wall behind the front desk
|
| 788 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.28), 10, Math.floor(w * 0.44), 30)
|
| 789 |
+
for (let y = 14; y < 36; y += 7) for (let x = Math.floor(w * 0.3); x < Math.floor(w * 0.7); x += 8) {
|
| 790 |
+
ctx.fillStyle = '#241a10'; ctx.fillRect(x, y, 5, 5)
|
| 791 |
+
if ((x + y) % 3) { ctx.fillStyle = C.amber; ctx.fillRect(x + 2, y + 1, 1, 3) }
|
| 792 |
+
}
|
| 793 |
+
// big lobby clock
|
| 794 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(w - 36, 8, 22, 26)
|
| 795 |
+
ctx.fillStyle = C.bone; ctx.fillRect(w - 33, 11, 16, 16)
|
| 796 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(w - 26, 14, 2, 6); ctx.fillRect(w - 26, 19, 5, 2)
|
| 797 |
+
// front desk
|
| 798 |
+
const dy = Math.floor(h * 0.56)
|
| 799 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.2), dy, Math.floor(w * 0.6), 5)
|
| 800 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.2), dy + 5, Math.floor(w * 0.6), 22)
|
| 801 |
+
for (let x = Math.floor(w * 0.24); x < Math.floor(w * 0.78); x += 16) { ctx.fillStyle = '#241a10'; ctx.fillRect(x, dy + 8, 10, 14) }
|
| 802 |
+
// service bell + register book
|
| 803 |
+
ctx.fillStyle = C.amber; ctx.fillRect(Math.floor(w * 0.44), dy - 4, 6, 3); ctx.fillStyle = C.amberD; ctx.fillRect(Math.floor(w * 0.46), dy - 6, 2, 2)
|
| 804 |
+
ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.56), dy - 3, 14, 3)
|
| 805 |
+
// checkered marble floor
|
| 806 |
+
for (let y = dy + 27; y < h; y += 6) for (let x = ((y / 6) % 2) * 6; x < w; x += 12) { ctx.fillStyle = '#11181f'; ctx.fillRect(x, y, 6, 6) }
|
| 807 |
+
ctx.fillStyle = '#0b1016'; ctx.fillRect(0, dy + 27, w, 1)
|
| 808 |
+
// twin sconces
|
| 809 |
+
for (const fx of [0.12, 0.88]) {
|
| 810 |
+
const sx = Math.floor(w * fx)
|
| 811 |
+
ctx.fillStyle = C.amberD; ctx.fillRect(sx - 2, 22, 5, 6); ctx.fillStyle = C.lamp; ctx.fillRect(sx - 1, 20, 3, 2)
|
| 812 |
+
lampCone(ctx, sx, 28, 20, 30)
|
| 813 |
+
}
|
| 814 |
+
lampCone(ctx, Math.floor(w * 0.5), 6, 54, dy - 8)
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
const paintStation: ScenePainter = (ctx, w, h, t) => {
|
| 818 |
+
ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.5), C.sky1, C.sky0)
|
| 819 |
+
// canopy + supports
|
| 820 |
+
ctx.fillStyle = '#10181f'; ctx.fillRect(0, 0, w, 12)
|
| 821 |
+
ctx.fillStyle = C.slate; ctx.fillRect(0, 12, w, 2)
|
| 822 |
+
for (let x = 20; x < w; x += 60) { ctx.fillStyle = _METAL; ctx.fillRect(x, 14, 3, Math.floor(h * 0.62) - 14) }
|
| 823 |
+
// hanging station clock
|
| 824 |
+
const cx = Math.floor(w * 0.5)
|
| 825 |
+
ctx.fillStyle = _METAL; ctx.fillRect(cx - 1, 14, 2, 8)
|
| 826 |
+
ctx.fillStyle = '#10181f'; ctx.fillRect(cx - 8, 22, 16, 16)
|
| 827 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx - 6, 24, 12, 12)
|
| 828 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(cx - 1, 27, 2, 4); ctx.fillRect(cx - 1, 29, 4, 2)
|
| 829 |
+
// platform edge
|
| 830 |
+
const py = Math.floor(h * 0.62)
|
| 831 |
+
ctx.fillStyle = '#11181f'; ctx.fillRect(0, py, w, 8)
|
| 832 |
+
ctx.fillStyle = C.bone; for (let x = 0; x < w; x += 10) ctx.fillRect(x, py, 6, 2)
|
| 833 |
+
// tracks below
|
| 834 |
+
ctx.fillStyle = '#070b0f'; ctx.fillRect(0, py + 8, w, h - py - 8)
|
| 835 |
+
for (let x = 0; x < w; x += 12) { ctx.fillStyle = _WOOD; ctx.fillRect(x, py + 14, 8, 3) }
|
| 836 |
+
ctx.fillStyle = _METAL; ctx.fillRect(0, py + 12, w, 2); ctx.fillRect(0, py + 22, w, 2)
|
| 837 |
+
// bench + abandoned suitcase
|
| 838 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.14), py - 14, 30, 4); ctx.fillRect(Math.floor(w * 0.14) + 2, py - 10, 3, 10); ctx.fillRect(Math.floor(w * 0.14) + 25, py - 10, 3, 10)
|
| 839 |
+
ctx.fillStyle = '#5a4a2a'; ctx.fillRect(Math.floor(w * 0.55), py - 10, 14, 10); ctx.fillStyle = '#3e331d'; ctx.fillRect(Math.floor(w * 0.55) + 6, py - 10, 2, 10)
|
| 840 |
+
// departures board
|
| 841 |
+
ctx.fillStyle = '#0a0f14'; ctx.fillRect(w - 64, 18, 48, 22)
|
| 842 |
+
for (let i = 0; i < 3; i++) { ctx.fillStyle = i === 1 ? C.amber : C.winDim; ctx.fillRect(w - 60, 22 + i * 6, 28 - i * 6, 2) }
|
| 843 |
+
lampCone(ctx, Math.floor(w * 0.3), 14, 40, py - 20)
|
| 844 |
+
rainStreaks(ctx, w, 12, t)
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
const paintGarage: ScenePainter = (ctx, w, h) => {
|
| 848 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
|
| 849 |
+
// segmented rolling door
|
| 850 |
+
const dw = Math.floor(w * 0.4)
|
| 851 |
+
ctx.fillStyle = '#10181f'; ctx.fillRect(w - dw - 8, 8, dw, Math.floor(h * 0.6))
|
| 852 |
+
for (let y = 10; y < Math.floor(h * 0.6); y += 8) { ctx.fillStyle = C.slate; ctx.fillRect(w - dw - 8, y, dw, 2) }
|
| 853 |
+
// pegboard of tools
|
| 854 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(10, 10, Math.floor(w * 0.3), 34)
|
| 855 |
+
for (let x = 14; x < Math.floor(w * 0.3); x += 8) { ctx.fillStyle = _METAL; ctx.fillRect(x, 14, 2, 10 + (x % 3) * 4) }
|
| 856 |
+
ctx.fillStyle = '#3a444e'; ctx.fillRect(14, 36, Math.floor(w * 0.26), 2)
|
| 857 |
+
// car under tarp
|
| 858 |
+
const cy = h - 40
|
| 859 |
+
ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.16), cy, Math.floor(w * 0.5), 18)
|
| 860 |
+
ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.2), cy - 8, Math.floor(w * 0.34), 8)
|
| 861 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(Math.floor(w * 0.22), cy + 18, 10, 5); ctx.fillRect(Math.floor(w * 0.52), cy + 18, 10, 5)
|
| 862 |
+
// oil stain + dropped wrench
|
| 863 |
+
ctx.fillStyle = '#05080b'; ctx.fillRect(Math.floor(w * 0.72), h - 16, 22, 6)
|
| 864 |
+
ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.78), h - 22, 10, 2); ctx.fillRect(Math.floor(w * 0.86), h - 24, 3, 6)
|
| 865 |
+
ctx.fillStyle = '#0c1014'; ctx.fillRect(0, h - 12, w, 12)
|
| 866 |
+
// caged work light
|
| 867 |
+
const lx = Math.floor(w * 0.38)
|
| 868 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 10)
|
| 869 |
+
ctx.fillStyle = _METAL; ctx.fillRect(lx - 4, 10, 9, 6)
|
| 870 |
+
ctx.fillStyle = C.lamp; ctx.fillRect(lx - 2, 12, 5, 3)
|
| 871 |
+
lampCone(ctx, lx, 16, 40, cy - 14)
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
const paintChapel: ScenePainter = (ctx, w, h) => {
|
| 875 |
+
ditherGrad(ctx, 0, 0, w, h, '#100c14', '#080610')
|
| 876 |
+
// tall stained-glass window
|
| 877 |
+
const gx = Math.floor(w * 0.42)
|
| 878 |
+
const gw = Math.floor(w * 0.16)
|
| 879 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(gx - 2, 6, gw + 4, 52)
|
| 880 |
+
const tints = ['#5e1c1c', '#284149', '#5a4a2a', '#46506b']
|
| 881 |
+
for (let y = 8; y < 54; y += 6) for (let x = gx; x < gx + gw; x += 5) { ctx.fillStyle = tints[((x + y) >> 2) % tints.length]; ctx.fillRect(x, y, 4, 5) }
|
| 882 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(gx + Math.floor(gw / 2), 6, 2, 52); ctx.fillRect(gx - 2, 28, gw + 4, 2)
|
| 883 |
+
// moonlight pooling beneath the glass
|
| 884 |
+
lampCone(ctx, gx + Math.floor(gw / 2), 58, 40, 40)
|
| 885 |
+
// altar with candles
|
| 886 |
+
const ay = Math.floor(h * 0.6)
|
| 887 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(gx - 10, ay, gw + 20, 6)
|
| 888 |
+
ctx.fillStyle = C.bone; ctx.fillRect(gx - 8, ay - 2, gw + 16, 2)
|
| 889 |
+
for (let i = 0; i < 4; i++) {
|
| 890 |
+
const x = gx - 6 + i * Math.floor((gw + 12) / 3)
|
| 891 |
+
ctx.fillStyle = C.bone; ctx.fillRect(x, ay - 8, 2, 6)
|
| 892 |
+
ctx.fillStyle = C.amber; ctx.fillRect(x, ay - 10, 2, 2)
|
| 893 |
+
}
|
| 894 |
+
// pews receding
|
| 895 |
+
for (let i = 0; i < 3; i++) {
|
| 896 |
+
const y = ay + 14 + i * 12
|
| 897 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(14 - i * 2, y, w - 28 + i * 4, 5)
|
| 898 |
+
ctx.fillStyle = '#241a10'; ctx.fillRect(14 - i * 2, y + 5, w - 28 + i * 4, 2)
|
| 899 |
+
}
|
| 900 |
+
ctx.fillStyle = '#06040a'; ctx.fillRect(0, h - 8, w, 8)
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
const paintGallery: ScenePainter = (ctx, w, h) => {
|
| 904 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0)
|
| 905 |
+
// hung canvases, one conspicuously missing
|
| 906 |
+
const art = [[14, 0.26, '#284149'], [Math.floor(w * 0.3), 0.2, '#5a4a2a'], [Math.floor(w * 0.62), 0.24, '#46506b']] as [number, number, string][]
|
| 907 |
+
for (const [x, fw, tint] of art) {
|
| 908 |
+
const aw = Math.floor(w * fw)
|
| 909 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(x - 2, 12, aw + 4, 34)
|
| 910 |
+
ditherGrad(ctx, x, 14, aw, 30, tint, C.sky0)
|
| 911 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(x, 44, aw, 2)
|
| 912 |
+
}
|
| 913 |
+
// the empty frame - pale rectangle where a painting was
|
| 914 |
+
const ex = Math.floor(w * 0.48)
|
| 915 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(ex - 2, 12, 24, 34)
|
| 916 |
+
ctx.fillStyle = '#1d2832'; ctx.fillRect(ex, 14, 20, 30)
|
| 917 |
+
ctx.fillStyle = C.bone; ctx.fillRect(ex + 2, 16, 16, 26)
|
| 918 |
+
ctx.fillStyle = C.ox; ctx.fillRect(ex + 6, 26, 8, 2)
|
| 919 |
+
// velvet rope line
|
| 920 |
+
for (let x = 10; x < w - 10; x += 36) { ctx.fillStyle = _METAL; ctx.fillRect(x, h - 38, 2, 16) }
|
| 921 |
+
ctx.fillStyle = C.ox; for (let x = 12; x < w - 12; x += 4) ctx.fillRect(x, h - 34 + ((x >> 2) % 2), 3, 2)
|
| 922 |
+
// pedestal with small bust
|
| 923 |
+
const px2 = Math.floor(w * 0.82)
|
| 924 |
+
ctx.fillStyle = C.slate; ctx.fillRect(px2, h - 44, 12, 22)
|
| 925 |
+
ctx.fillStyle = C.slateL; ctx.fillRect(px2, h - 44, 12, 2)
|
| 926 |
+
ctx.fillStyle = C.bone; ctx.fillRect(px2 + 3, h - 52, 6, 8)
|
| 927 |
+
// polished floor
|
| 928 |
+
ctx.fillStyle = '#0d141b'; ctx.fillRect(0, h - 20, w, 20)
|
| 929 |
+
for (let x = 0; x < w; x += 18) { ctx.fillStyle = '#121a22'; ctx.fillRect(x, h - 20, 1, 20) }
|
| 930 |
+
for (const [x, fw] of art) lampCone(ctx, x + Math.floor((w * fw) / 2), 8, Math.floor(w * fw), 30)
|
| 931 |
+
lampCone(ctx, ex + 10, 8, 26, 30)
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
const paintCellar: ScenePainter = (ctx, w, h) => {
|
| 935 |
+
ditherGrad(ctx, 0, 0, w, h, '#120e0a', '#080604')
|
| 936 |
+
// stone arch ribs
|
| 937 |
+
for (let x = 8; x < w; x += 44) {
|
| 938 |
+
ctx.fillStyle = '#1d1812'; ctx.fillRect(x, 8, 6, h - 28)
|
| 939 |
+
ctx.fillStyle = '#28201666'; ctx.fillRect(x, 8, 2, h - 28)
|
| 940 |
+
}
|
| 941 |
+
ctx.fillStyle = '#1d1812'; ctx.fillRect(0, 6, w, 4)
|
| 942 |
+
// wine racks
|
| 943 |
+
for (let y = 22; y < h - 40; y += 14) for (let x = 18; x < Math.floor(w * 0.4); x += 7) {
|
| 944 |
+
ctx.fillStyle = '#241a10'; ctx.fillRect(x, y, 6, 10)
|
| 945 |
+
ctx.fillStyle = '#0f2418'; ctx.fillRect(x + 1, y + 2, 4, 4)
|
| 946 |
+
}
|
| 947 |
+
// barrels on their sides
|
| 948 |
+
for (let i = 0; i < 2; i++) {
|
| 949 |
+
const x = Math.floor(w * 0.55) + i * 34
|
| 950 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(x, h - 48, 26, 22)
|
| 951 |
+
ctx.fillStyle = _WOOD_L; ctx.fillRect(x, h - 48, 26, 3)
|
| 952 |
+
ctx.fillStyle = _METAL; ctx.fillRect(x, h - 44, 26, 2); ctx.fillRect(x, h - 32, 26, 2)
|
| 953 |
+
ctx.fillStyle = '#241a10'; ctx.fillRect(x + 11, h - 40, 4, 6)
|
| 954 |
+
}
|
| 955 |
+
// candle lantern on a crate
|
| 956 |
+
const cx = Math.floor(w * 0.34)
|
| 957 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(cx, h - 38, 16, 14)
|
| 958 |
+
ctx.fillStyle = _METAL; ctx.fillRect(cx + 4, h - 50, 8, 12)
|
| 959 |
+
ctx.fillStyle = C.amber; ctx.fillRect(cx + 6, h - 46, 4, 6)
|
| 960 |
+
lampCone(ctx, cx + 8, h - 44, 26, 22)
|
| 961 |
+
// dirt floor
|
| 962 |
+
ctx.fillStyle = '#0c0906'; ctx.fillRect(0, h - 18, w, 18)
|
| 963 |
+
for (let x = 4; x < w; x += 11) { ctx.fillStyle = '#141009'; ctx.fillRect(x, h - 14 + (x % 3), 4, 2) }
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
const paintGreenhouse: ScenePainter = (ctx, w, h, t) => {
|
| 967 |
+
// glass panes against the night sky
|
| 968 |
+
ditherGrad(ctx, 0, 0, w, h, '#0e1d1a', '#081210')
|
| 969 |
+
ctx.fillStyle = _METAL
|
| 970 |
+
for (let x = 0; x < w; x += 24) ctx.fillRect(x, 0, 2, Math.floor(h * 0.6))
|
| 971 |
+
for (let y = 0; y < Math.floor(h * 0.6); y += 16) ctx.fillRect(0, y, w, 2)
|
| 972 |
+
// slanted roof line
|
| 973 |
+
for (let x = 0; x < w; x += 8) { ctx.fillStyle = '#10202b44'; ctx.fillRect(x, Math.floor(x / 12), 6, 1) }
|
| 974 |
+
// planting benches with foliage
|
| 975 |
+
const by = Math.floor(h * 0.62)
|
| 976 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(6, by, Math.floor(w * 0.38), 4); ctx.fillRect(Math.floor(w * 0.56), by, Math.floor(w * 0.38), 4)
|
| 977 |
+
ctx.fillStyle = '#241a10'; ctx.fillRect(10, by + 4, 3, h - by - 14); ctx.fillRect(Math.floor(w * 0.38) - 6, by + 4, 3, h - by - 14)
|
| 978 |
+
ctx.fillRect(Math.floor(w * 0.56) + 4, by + 4, 3, h - by - 14); ctx.fillRect(Math.floor(w * 0.92), by + 4, 3, h - by - 14)
|
| 979 |
+
const leaf = ['#1d4a2c', '#14401f', '#2c5e38']
|
| 980 |
+
for (let x = 8; x < Math.floor(w * 0.42); x += 6) { ctx.fillStyle = leaf[(x >> 1) % 3]; ctx.fillRect(x, by - 6 - (x % 5), 4, 6 + (x % 5)) }
|
| 981 |
+
for (let x = Math.floor(w * 0.58); x < Math.floor(w * 0.92); x += 6) { ctx.fillStyle = leaf[(x >> 2) % 3]; ctx.fillRect(x, by - 5 - (x % 4), 4, 5 + (x % 4)) }
|
| 982 |
+
// overturned pot + trowel on the path
|
| 983 |
+
ctx.fillStyle = '#6b3a2e'; ctx.fillRect(Math.floor(w * 0.46), h - 16, 10, 6)
|
| 984 |
+
ctx.fillStyle = '#0c0906'; ctx.fillRect(Math.floor(w * 0.44), h - 11, 8, 3)
|
| 985 |
+
ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.5), h - 9, 8, 2)
|
| 986 |
+
// stone path
|
| 987 |
+
ctx.fillStyle = '#10161c'; ctx.fillRect(Math.floor(w * 0.42), by + 4, Math.floor(w * 0.14), h - by - 4)
|
| 988 |
+
lampCone(ctx, Math.floor(w * 0.5), 4, 50, by - 6)
|
| 989 |
+
rainStreaks(ctx, w, Math.floor(h * 0.55), t)
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
const paintDiner: ScenePainter = (ctx, w, h, t) => {
|
| 993 |
+
ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
|
| 994 |
+
// big window with the street outside
|
| 995 |
+
const wx = Math.floor(w * 0.55)
|
| 996 |
+
ctx.fillStyle = C.shadow; ctx.fillRect(wx - 2, 8, w - wx - 8, 40)
|
| 997 |
+
ditherGrad(ctx, wx, 10, w - wx - 12, 36, C.sky2, C.sky0)
|
| 998 |
+
for (let x = wx + 4; x < w - 16; x += 12) { ctx.fillStyle = C.bldg; ctx.fillRect(x, 26, 8, 20) ; ctx.fillStyle = C.winDim; ctx.fillRect(x + 2, 30, 2, 2) }
|
| 999 |
+
// reversed window lettering
|
| 1000 |
+
ctx.fillStyle = C.amberD; for (let i = 0; i < 5; i++) ctx.fillRect(wx + 6 + i * 9, 14, 6, 2)
|
| 1001 |
+
// counter with pie stand and coffee pot
|
| 1002 |
+
const cy = Math.floor(h * 0.58)
|
| 1003 |
+
ctx.fillStyle = '#3a6b6b'; ctx.fillRect(0, cy, Math.floor(w * 0.5), 5)
|
| 1004 |
+
ctx.fillStyle = '#284149'; ctx.fillRect(0, cy + 5, Math.floor(w * 0.5), h - cy - 17)
|
| 1005 |
+
ctx.fillStyle = C.bone; ctx.fillRect(14, cy - 10, 12, 8); ctx.fillStyle = C.slateL; ctx.fillRect(12, cy - 12, 16, 2)
|
| 1006 |
+
ctx.fillStyle = _METAL; ctx.fillRect(38, cy - 9, 8, 9); ctx.fillStyle = C.amberD; ctx.fillRect(40, cy - 11, 4, 2)
|
| 1007 |
+
// stools
|
| 1008 |
+
for (let i = 0; i < 3; i++) { const x = 10 + i * 22; ctx.fillStyle = C.ox; ctx.fillRect(x, h - 20, 12, 3); ctx.fillStyle = _METAL; ctx.fillRect(x + 5, h - 17, 2, 17) }
|
| 1009 |
+
// booth by the window
|
| 1010 |
+
ctx.fillStyle = C.ox; ctx.fillRect(wx + 2, h - 36, 8, 24); ctx.fillRect(w - 22, h - 36, 8, 24)
|
| 1011 |
+
ctx.fillStyle = '#6b2222'; ctx.fillRect(wx + 2, h - 36, 8, 3); ctx.fillRect(w - 22, h - 36, 8, 3)
|
| 1012 |
+
ctx.fillStyle = _WOOD; ctx.fillRect(wx + 12, h - 26, w - wx - 36, 4)
|
| 1013 |
+
ctx.fillStyle = C.bone; ctx.fillRect(wx + 18, h - 30, 8, 4)
|
| 1014 |
+
// checkered floor
|
| 1015 |
+
for (let y = h - 12; y < h; y += 6) for (let x = ((y / 6) % 2) * 6; x < w; x += 12) { ctx.fillStyle = '#11181f'; ctx.fillRect(x, y, 6, 6) }
|
| 1016 |
+
lampCone(ctx, Math.floor(w * 0.25), 6, 40, cy - 10)
|
| 1017 |
+
rainStreaks(ctx, w - wx, 38, t)
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
const paintVault: ScenePainter = (ctx, w, h) => {
|
| 1021 |
+
ditherGrad(ctx, 0, 0, w, h, '#11141c', '#080a10')
|
| 1022 |
+
// wall of deposit boxes
|
| 1023 |
+
for (let y = 10; y < h - 40; y += 10) for (let x = 8; x < Math.floor(w * 0.38); x += 12) {
|
| 1024 |
+
ctx.fillStyle = _METAL; ctx.fillRect(x, y, 10, 8)
|
| 1025 |
+
ctx.fillStyle = '#3a444e'; ctx.fillRect(x, y, 10, 1)
|
| 1026 |
+
ctx.fillStyle = C.amberD; ctx.fillRect(x + 4, y + 3, 2, 2)
|
| 1027 |
+
}
|
| 1028 |
+
// one box pulled open
|
| 1029 |
+
ctx.fillStyle = '#05080b'; ctx.fillRect(32, 40, 10, 8)
|
| 1030 |
+
ctx.fillStyle = _METAL; ctx.fillRect(30, 46, 14, 3)
|
| 1031 |
+
// massive round vault door ajar on the right
|
| 1032 |
+
const cx = Math.floor(w * 0.72)
|
| 1033 |
+
const cy2 = Math.floor(h * 0.45)
|
| 1034 |
+
for (let r = 26; r > 4; r -= 4) {
|
| 1035 |
+
ctx.fillStyle = r % 8 ? '#2d3640' : '#3a444e'
|
| 1036 |
+
ctx.fillRect(cx - r, cy2 - r, r * 2, r * 2)
|
| 1037 |
+
}
|
| 1038 |
+
ctx.fillStyle = '#10141a'; ctx.fillRect(cx - 4, cy2 - 4, 8, 8)
|
| 1039 |
+
// spoke handle
|
| 1040 |
+
ctx.fillStyle = C.bone
|
| 1041 |
+
ctx.fillRect(cx - 14, cy2 - 1, 28, 2); ctx.fillRect(cx - 1, cy2 - 14, 2, 28)
|
| 1042 |
+
// hinge gap of darkness - the door stands open
|
| 1043 |
+
ctx.fillStyle = '#020304'; ctx.fillRect(cx + 24, cy2 - 28, 10, 56)
|
| 1044 |
+
// scattered bills on the floor
|
| 1045 |
+
ctx.fillStyle = '#0b0e14'; ctx.fillRect(0, h - 24, w, 24)
|
| 1046 |
+
for (let i = 0; i < 6; i++) { const x = 20 + i * Math.floor(w * 0.13); ctx.fillStyle = i % 2 ? '#1d5a2c' : C.bone; ctx.fillRect(x, h - 18 + (i % 3) * 3, 8, 4) }
|
| 1047 |
+
lampCone(ctx, Math.floor(w * 0.4), 2, 50, h - 30)
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
export const SCENES: Record<string, ScenePainter> = {
|
| 1051 |
skyline: paintSkyline, desk: paintDesk, atrium: paintAtrium, interro: paintInterro,
|
| 1052 |
seawall: paintSeawall, mezzanine: paintMezzanine, map: paintMap,
|
| 1053 |
kitchen: paintKitchen, study: paintStudy, parlor: paintParlor, bedroom: paintBedroom,
|
| 1054 |
+
alley: paintAlley, bar: paintBar, casino: paintCasino, theater: paintTheater,
|
| 1055 |
+
warehouse: paintWarehouse, rooftop: paintRooftop, office: paintOffice, lobby: paintLobby,
|
| 1056 |
+
station: paintStation, garage: paintGarage, chapel: paintChapel, gallery: paintGallery,
|
| 1057 |
+
cellar: paintCellar, greenhouse: paintGreenhouse, diner: paintDiner, vault: paintVault,
|
| 1058 |
}
|
| 1059 |
|
| 1060 |
+
// Map a free-text location name (generated cases invent rooms) to the closest set.
|
| 1061 |
+
// First match wins - keep the specific venues above the generic interiors.
|
| 1062 |
const _ROOM_MAP: [RegExp, string][] = [
|
| 1063 |
+
[/alley|backstreet|side\s*street|passage(way)?\b/i, 'alley'],
|
| 1064 |
+
[/\bbar\b|tavern|pub\b|saloon|speakeas|taproom|cantina|nightclub|club\s*floor/i, 'bar'],
|
| 1065 |
+
[/casino|card\s*room|gambling|poker|roulette|betting/i, 'casino'],
|
| 1066 |
+
[/theat|stage|auditorium|opera|cabaret|backstage|dressing\s*room|rehearsal/i, 'theater'],
|
| 1067 |
+
[/warehouse|store\s*room|storage|loading|depot|freight|cargo/i, 'warehouse'],
|
| 1068 |
+
[/rooftop|roof\b|widow'?s\s*walk/i, 'rooftop'],
|
| 1069 |
+
[/office|bureau|headquarters|newsroom|press\s*room|precinct/i, 'office'],
|
| 1070 |
+
[/lobby|reception|front\s*desk|concierge|check[-\s]?in/i, 'lobby'],
|
| 1071 |
+
[/station|platform|train|railway|terminus|tram/i, 'station'],
|
| 1072 |
+
[/garage|workshop|mechanic|motor|boathouse|shed|barn|stable/i, 'garage'],
|
| 1073 |
+
[/chapel|church|cathedral|shrine|sanctuary|cloister|vestry/i, 'chapel'],
|
| 1074 |
+
[/gallery|museum|exhibit|auction|studio|atelier/i, 'gallery'],
|
| 1075 |
+
[/cellar|basement|wine\b|crypt|undercroft|catacomb|tunnel/i, 'cellar'],
|
| 1076 |
+
[/vault|safe\s*room|strong\s*room|\bbank\b|deposit|counting\s*house/i, 'vault'],
|
| 1077 |
+
[/greenhouse|conservatory|garden|orchard|arboretum|nursery\s*garden/i, 'greenhouse'],
|
| 1078 |
+
[/diner|caf[eé]|coffee|canteen|tea\s*room|bistro|restaurant/i, 'diner'],
|
| 1079 |
+
[/kitchen|pantry|galley|scullery|bakery/i, 'kitchen'],
|
| 1080 |
+
[/librar|study|den\b|archive|records|reading\s*room/i, 'study'],
|
| 1081 |
+
[/bed|chamber|boudoir|suite|nursery|dormitor/i, 'bedroom'],
|
| 1082 |
+
[/mezzanine|\brail\b|balcon|landing|stairwell/i, 'mezzanine'],
|
| 1083 |
+
[/dock|harbou?r|pier|seawall|wharf|seaside|waterfront|quay|marina|lighthouse/i, 'seawall'],
|
| 1084 |
+
[/parlou?r|lounge|living|sitting|drawing|salon|terrace|veranda|smoking\s*room/i, 'parlor'],
|
| 1085 |
+
[/hall|foyer|ballroom|atrium|dining|entrance|stair|court/i, 'atrium'],
|
| 1086 |
]
|
| 1087 |
|
| 1088 |
export function sceneForRoom(name: string): ScenePainter {
|
|
|
|
| 1090 |
return SCENES.parlor // generic interior
|
| 1091 |
}
|
| 1092 |
|
| 1093 |
+
// ---- exhibit illustrations ----
|
| 1094 |
+
// Each exhibit gets a procedural "evidence photo": the object, large, on a forensic
|
| 1095 |
+
// table under a spot, with a measuring strip. The kind is read from the exhibit's
|
| 1096 |
+
// own words so a letter looks like a letter and a vial looks like a vial.
|
| 1097 |
+
export type ExhibitKind =
|
| 1098 |
+
| 'letter' | 'photo' | 'ledger' | 'key' | 'blade' | 'bottle' | 'watch' | 'fabric'
|
| 1099 |
+
| 'flame' | 'ticket' | 'jewel' | 'phone' | 'recorder' | 'print' | 'cash' | 'rope'
|
| 1100 |
+
| 'boot' | 'bag'
|
| 1101 |
+
|
| 1102 |
+
// ORDER MATTERS - specific kinds sit above broad word-matches so "letter opener" is a
|
| 1103 |
+
// blade (not a letter), "matchbook" is a flame (not a book), "footprint" is a boot
|
| 1104 |
+
// (not a fingerprint), and "recording" is a tape (not a ledger record).
|
| 1105 |
+
const _EX_RULES: [RegExp, ExhibitKind][] = [
|
| 1106 |
+
[/photo|polaroid|portrait|snapshot|negative|film/i, 'photo'],
|
| 1107 |
+
[/tape|recorder|recording|reel\b|voicemail|dictaphone|cylinder/i, 'recorder'],
|
| 1108 |
+
[/knife|blade|dagger|letter\s*opener|razor|scissor|shard|glass\s+fragment/i, 'blade'],
|
| 1109 |
+
[/vial|poison|bottle|flask|tonic|medicine|pill|powder|arsenic|cyanide|decanter|wine|whisky/i, 'bottle'],
|
| 1110 |
+
[/watch|clock|timepiece/i, 'watch'],
|
| 1111 |
+
[/match|lighter|accelerant|kerosene|petrol|gasoline|\bash(es)?\b|burn|scorch|cigar|cigarette/i, 'flame'],
|
| 1112 |
+
[/footprint|boot|shoe|heel|track\b|tread/i, 'boot'],
|
| 1113 |
+
[/fingerprint|\bprints?\b|smudge|handprint/i, 'print'],
|
| 1114 |
+
[/keycard|access|badge|pass\b|\bkeys?\b|lockpick|latch/i, 'key'],
|
| 1115 |
+
[/ring\b|necklace|brooch|jewel|diamond|pearl|locket|pendant|bracelet|gem/i, 'jewel'],
|
| 1116 |
+
[/phone|telephone|pager|wire\s*tap/i, 'phone'],
|
| 1117 |
+
[/ticket|stub|pawn|receipt|invoice|bill\b|cheque|check\b|voucher|claim/i, 'ticket'],
|
| 1118 |
+
[/cash|money|bills|banknote|currency|wad\b|payment|bribe|coin|wallet/i, 'cash'],
|
| 1119 |
+
[/rope|cord|wire\b|cable|twine|strap|belt/i, 'rope'],
|
| 1120 |
+
[/glove|scarf|fabric|fibre|fiber|cloth|thread|handkerchief|button|cufflink|coat|shawl|silk/i, 'fabric'],
|
| 1121 |
+
[/ledger|\bbook\b|journal|diary|register|contract|deed|will\b|manifest|statement|\brecords\b/i, 'ledger'],
|
| 1122 |
+
[/letter|note\b|envelope|telegram|correspondence|memo|page|paper/i, 'letter'],
|
| 1123 |
+
]
|
| 1124 |
+
|
| 1125 |
+
export function exhibitKindFor(name: string, summary = ''): ExhibitKind {
|
| 1126 |
+
const hay = `${name} ${summary}`
|
| 1127 |
+
for (const [re, kind] of _EX_RULES) if (re.test(hay)) return kind
|
| 1128 |
+
return 'bag'
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
function exBase(ctx: CanvasRenderingContext2D, w: number, h: number): void {
|
| 1132 |
+
ditherGrad(ctx, 0, 0, w, h, '#141a21', '#0a0e13')
|
| 1133 |
+
lampCone(ctx, Math.floor(w / 2), 0, Math.floor(w * 0.9), h)
|
| 1134 |
+
// forensic measuring strip
|
| 1135 |
+
ctx.fillStyle = C.bone; ctx.fillRect(6, h - 7, Math.floor(w * 0.4), 3)
|
| 1136 |
+
ctx.fillStyle = '#0a0e13'
|
| 1137 |
+
for (let x = 8; x < 6 + Math.floor(w * 0.4); x += 5) ctx.fillRect(x, h - 7, 1, 3)
|
| 1138 |
+
// evidence tag in the corner
|
| 1139 |
+
ctx.fillStyle = C.amberD; ctx.fillRect(w - 20, h - 12, 14, 8)
|
| 1140 |
+
ctx.fillStyle = '#3a2a10'; ctx.fillRect(w - 18, h - 9, 10, 1); ctx.fillRect(w - 18, h - 7, 7, 1)
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
type ExPainter = (ctx: CanvasRenderingContext2D, w: number, h: number, s: number) => void
|
| 1144 |
+
|
| 1145 |
+
const _EX_PAINT: Record<ExhibitKind, ExPainter> = {
|
| 1146 |
+
letter: (ctx, w, h, s) => {
|
| 1147 |
+
const cx = Math.floor(w / 2) - 22
|
| 1148 |
+
const cy = Math.floor(h / 2) - 14
|
| 1149 |
+
// open envelope behind
|
| 1150 |
+
ctx.fillStyle = '#b3ac96'; ctx.fillRect(cx - 6, cy + 12, 34, 18)
|
| 1151 |
+
ctx.fillStyle = '#9d977f'; ctx.fillRect(cx - 6, cy + 12, 34, 2)
|
| 1152 |
+
// folded letter, slightly rotated feel via offset slabs
|
| 1153 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + 8, cy - 6, 36, 26)
|
| 1154 |
+
ctx.fillStyle = '#fdf9ec'; ctx.fillRect(cx + 8, cy - 6, 36, 3)
|
| 1155 |
+
ctx.fillStyle = '#3a3428'
|
| 1156 |
+
for (let i = 0; i < 5; i++) ctx.fillRect(cx + 12, cy + (i * 4), 24 - ((s + i) % 3) * 5, 1)
|
| 1157 |
+
// wax seal or ink blot
|
| 1158 |
+
ctx.fillStyle = s % 2 ? C.ox : '#1c222a'; ctx.fillRect(cx + 34, cy + 12, 5, 5)
|
| 1159 |
+
},
|
| 1160 |
+
photo: (ctx, w, h, s) => {
|
| 1161 |
+
const cx = Math.floor(w / 2) - 20
|
| 1162 |
+
const cy = Math.floor(h / 2) - 17
|
| 1163 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 44, 38)
|
| 1164 |
+
ctx.fillStyle = '#10161d'; ctx.fillRect(cx + 4, cy + 4, 36, 24)
|
| 1165 |
+
// two silhouettes caught in frame
|
| 1166 |
+
ctx.fillStyle = '#222b35'
|
| 1167 |
+
ctx.fillRect(cx + 10 + (s % 4), cy + 12, 7, 16); ctx.fillRect(cx + 12 + (s % 4), cy + 8, 4, 5)
|
| 1168 |
+
ctx.fillRect(cx + 24, cy + 14, 7, 14); ctx.fillRect(cx + 26, cy + 10, 4, 5)
|
| 1169 |
+
ctx.fillStyle = C.winDim; ctx.fillRect(cx + 34, cy + 6, 3, 3)
|
| 1170 |
+
// bent corner
|
| 1171 |
+
ctx.fillStyle = '#9d977f'; ctx.fillRect(cx + 40, cy + 34, 4, 4)
|
| 1172 |
+
},
|
| 1173 |
+
ledger: (ctx, w, h, s) => {
|
| 1174 |
+
const cx = Math.floor(w / 2) - 26
|
| 1175 |
+
const cy = Math.floor(h / 2) - 14
|
| 1176 |
+
ctx.fillStyle = '#241a10'; ctx.fillRect(cx - 2, cy - 2, 56, 32)
|
| 1177 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 26, 28); ctx.fillRect(cx + 27, cy, 25, 28)
|
| 1178 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 26, cy, 1, 28)
|
| 1179 |
+
ctx.fillStyle = '#3a3428'
|
| 1180 |
+
for (let i = 0; i < 6; i++) { ctx.fillRect(cx + 3, cy + 4 + i * 4, 20, 1); ctx.fillRect(cx + 30, cy + 4 + i * 4, 19, 1) }
|
| 1181 |
+
// the flagged entry
|
| 1182 |
+
ctx.fillStyle = C.ox; ctx.fillRect(cx + 30, cy + 4 + ((s % 4) + 1) * 4 - 1, 19, 3)
|
| 1183 |
+
},
|
| 1184 |
+
key: (ctx, w, h, s) => {
|
| 1185 |
+
const cx = Math.floor(w / 2) - 18
|
| 1186 |
+
const cy = Math.floor(h / 2) - 2
|
| 1187 |
+
if (s % 2) {
|
| 1188 |
+
// hotel key with fob
|
| 1189 |
+
ctx.fillStyle = '#b8b7ad'; ctx.fillRect(cx, cy - 3, 26, 4)
|
| 1190 |
+
ctx.fillRect(cx + 24, cy + 1, 3, 6); ctx.fillRect(cx + 18, cy + 1, 3, 4)
|
| 1191 |
+
ctx.fillStyle = '#8d8c82'; ctx.fillRect(cx - 8, cy - 7, 10, 10)
|
| 1192 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - 5, cy - 4, 4, 4)
|
| 1193 |
+
ctx.fillStyle = C.ox; ctx.fillRect(cx + 32, cy - 8, 14, 9)
|
| 1194 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + 35, cy - 5, 8, 3)
|
| 1195 |
+
} else {
|
| 1196 |
+
// access card
|
| 1197 |
+
ctx.fillStyle = '#2d4a52'; ctx.fillRect(cx - 4, cy - 12, 42, 26)
|
| 1198 |
+
ctx.fillStyle = '#3a6b6b'; ctx.fillRect(cx - 4, cy - 12, 42, 5)
|
| 1199 |
+
ctx.fillStyle = C.amber; ctx.fillRect(cx, cy - 2, 10, 8)
|
| 1200 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + 14, cy, 18, 2); ctx.fillRect(cx + 14, cy + 4, 12, 2)
|
| 1201 |
+
}
|
| 1202 |
+
},
|
| 1203 |
+
blade: (ctx, w, h, s) => {
|
| 1204 |
+
const cx = Math.floor(w / 2) - 24
|
| 1205 |
+
const cy = Math.floor(h / 2)
|
| 1206 |
+
// blade with taper
|
| 1207 |
+
ctx.fillStyle = '#c2c8cc'
|
| 1208 |
+
for (let i = 0; i < 30; i++) ctx.fillRect(cx + i, cy - 3 + (i >> 4), 1, 5 - (i >> 3))
|
| 1209 |
+
ctx.fillStyle = '#8a9298'; ctx.fillRect(cx, cy + 1, 28, 1)
|
| 1210 |
+
// guard + grip
|
| 1211 |
+
ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 30, cy - 5, 3, 9)
|
| 1212 |
+
ctx.fillStyle = '#55402a'; ctx.fillRect(cx + 33, cy - 3, 14, 6)
|
| 1213 |
+
ctx.fillStyle = '#241a10'; for (let i = 0; i < 3; i++) ctx.fillRect(cx + 35 + i * 4, cy - 3, 1, 6)
|
| 1214 |
+
// a dark stain near the tip when the story calls for it
|
| 1215 |
+
if (s % 3 !== 1) { ctx.fillStyle = '#4a1414'; ctx.fillRect(cx + 4, cy - 1, 6, 3) }
|
| 1216 |
+
},
|
| 1217 |
+
bottle: (ctx, w, h, s) => {
|
| 1218 |
+
const cx = Math.floor(w / 2) - 6
|
| 1219 |
+
const cy = Math.floor(h / 2) - 16
|
| 1220 |
+
const tint = s % 2 ? '#1d4a2c' : '#2d3a5e'
|
| 1221 |
+
ctx.fillStyle = tint; ctx.fillRect(cx, cy + 10, 14, 22)
|
| 1222 |
+
ctx.fillRect(cx + 4, cy + 2, 6, 9)
|
| 1223 |
+
ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 4, cy - 2, 6, 4)
|
| 1224 |
+
// liquid line + skull label
|
| 1225 |
+
ctx.fillStyle = s % 2 ? '#2c5e38' : '#46506b'; ctx.fillRect(cx + 2, cy + 18, 10, 12)
|
| 1226 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + 3, cy + 14, 8, 8)
|
| 1227 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 5, cy + 16, 4, 3); ctx.fillRect(cx + 5, cy + 20, 1, 1); ctx.fillRect(cx + 8, cy + 20, 1, 1)
|
| 1228 |
+
// spilled drops
|
| 1229 |
+
ctx.fillStyle = tint; ctx.fillRect(cx + 18, cy + 30, 4, 2); ctx.fillRect(cx + 24, cy + 32, 2, 1)
|
| 1230 |
+
},
|
| 1231 |
+
watch: (ctx, w, h, s) => {
|
| 1232 |
+
const cx = Math.floor(w / 2) - 2
|
| 1233 |
+
const cy = Math.floor(h / 2) - 2
|
| 1234 |
+
// chain
|
| 1235 |
+
ctx.fillStyle = '#8d8c82'
|
| 1236 |
+
for (let i = 0; i < 7; i++) ctx.fillRect(cx + 12 + i * 3, cy - 14 + i * 2, 2, 2)
|
| 1237 |
+
// case rings
|
| 1238 |
+
for (let r = 14; r > 2; r -= 3) { ctx.fillStyle = r % 6 ? '#b8943e' : '#e3c06d'; ctx.fillRect(cx - r, cy - r, r * 2, r * 2) }
|
| 1239 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx - 8, cy - 8, 16, 16)
|
| 1240 |
+
// hands stopped at the hour of the crime
|
| 1241 |
+
ctx.fillStyle = '#0a0e13'
|
| 1242 |
+
ctx.fillRect(cx - 1, cy - 7 + (s % 3), 2, 8); ctx.fillRect(cx - 1, cy, 6 - (s % 3), 2)
|
| 1243 |
+
// cracked glass
|
| 1244 |
+
ctx.fillStyle = '#8a9298'; ctx.fillRect(cx + 2, cy - 6, 1, 5); ctx.fillRect(cx + 3, cy - 3, 3, 1)
|
| 1245 |
+
},
|
| 1246 |
+
fabric: (ctx, w, h, s) => {
|
| 1247 |
+
const cx = Math.floor(w / 2) - 20
|
| 1248 |
+
const cy = Math.floor(h / 2) - 10
|
| 1249 |
+
const tint = ['#5e1c1c', '#284149', '#46506b'][s % 3]
|
| 1250 |
+
// a torn swatch, ragged edges
|
| 1251 |
+
ctx.fillStyle = tint
|
| 1252 |
+
for (let y = 0; y < 22; y++) {
|
| 1253 |
+
const ragL = (y * 7 + s) % 4
|
| 1254 |
+
const ragR = (y * 5 + s) % 5
|
| 1255 |
+
ctx.fillRect(cx + ragL, cy + y, 38 - ragL - ragR, 1)
|
| 1256 |
+
}
|
| 1257 |
+
// weave lines + a loose thread
|
| 1258 |
+
ctx.fillStyle = '#00000033'
|
| 1259 |
+
for (let y = 2; y < 20; y += 3) ctx.fillRect(cx + 3, cy + y, 32, 1)
|
| 1260 |
+
ctx.fillStyle = tint
|
| 1261 |
+
ctx.fillRect(cx + 38, cy + 22, 2, 2); ctx.fillRect(cx + 41, cy + 25, 2, 1); ctx.fillRect(cx + 44, cy + 26, 3, 1)
|
| 1262 |
+
// monogram patch
|
| 1263 |
+
ctx.fillStyle = C.amber; ctx.fillRect(cx + 14, cy + 8, 8, 6)
|
| 1264 |
+
ctx.fillStyle = '#3a2a10'; ctx.fillRect(cx + 16, cy + 10, 4, 2)
|
| 1265 |
+
},
|
| 1266 |
+
flame: (ctx, w, h, s) => {
|
| 1267 |
+
const cx = Math.floor(w / 2) - 16
|
| 1268 |
+
const cy = Math.floor(h / 2) - 8
|
| 1269 |
+
if (s % 2) {
|
| 1270 |
+
// matchbook, one match torn out
|
| 1271 |
+
ctx.fillStyle = C.ox; ctx.fillRect(cx, cy, 26, 18)
|
| 1272 |
+
ctx.fillStyle = '#b8443f'; ctx.fillRect(cx, cy, 26, 4)
|
| 1273 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + 4, cy + 7, 18, 7)
|
| 1274 |
+
ctx.fillStyle = '#3a2c1c'; for (let i = 0; i < 5; i++) ctx.fillRect(cx + 5 + i * 4, cy + 8, 2, 5)
|
| 1275 |
+
ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 32, cy + 6, 2, 10); ctx.fillStyle = C.amber; ctx.fillRect(cx + 31, cy + 3, 4, 4)
|
| 1276 |
+
} else {
|
| 1277 |
+
// scorched tin of accelerant
|
| 1278 |
+
ctx.fillStyle = '#8a4a1e'; ctx.fillRect(cx + 2, cy - 4, 20, 26)
|
| 1279 |
+
ctx.fillStyle = '#a8602a'; ctx.fillRect(cx + 2, cy - 4, 20, 3)
|
| 1280 |
+
ctx.fillStyle = '#241208'; ctx.fillRect(cx + 6, cy + 2, 12, 12)
|
| 1281 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - 4, cy + 20, 34, 4)
|
| 1282 |
+
ctx.fillStyle = '#1c1209'; ctx.fillRect(cx + 24, cy + 16, 10, 6)
|
| 1283 |
+
}
|
| 1284 |
+
},
|
| 1285 |
+
ticket: (ctx, w, h, s) => {
|
| 1286 |
+
const cx = Math.floor(w / 2) - 24
|
| 1287 |
+
const cy = Math.floor(h / 2) - 9
|
| 1288 |
+
ctx.fillStyle = '#cfc0a0'; ctx.fillRect(cx, cy, 48, 20)
|
| 1289 |
+
ctx.fillStyle = '#b3a585'; ctx.fillRect(cx, cy, 48, 3)
|
| 1290 |
+
// perforation + torn half
|
| 1291 |
+
ctx.fillStyle = '#0a0e13'; for (let y = cy; y < cy + 20; y += 3) ctx.fillRect(cx + 33, y, 1, 2)
|
| 1292 |
+
ctx.fillStyle = '#3a3428'
|
| 1293 |
+
ctx.fillRect(cx + 4, cy + 7, 24, 2); ctx.fillRect(cx + 4, cy + 12, 16 + (s % 3) * 3, 2)
|
| 1294 |
+
ctx.fillStyle = C.ox; ctx.fillRect(cx + 37, cy + 5, 8, 8)
|
| 1295 |
+
},
|
| 1296 |
+
jewel: (ctx, w, h, s) => {
|
| 1297 |
+
const cx = Math.floor(w / 2)
|
| 1298 |
+
const cy = Math.floor(h / 2) - 4
|
| 1299 |
+
// necklace chain pooled on the table
|
| 1300 |
+
ctx.fillStyle = '#e3c06d'
|
| 1301 |
+
for (let i = 0; i < 14; i++) ctx.fillRect(cx - 20 + i * 3, cy + 10 + Math.floor(Math.sin(i * 0.9 + s) * 3), 2, 2)
|
| 1302 |
+
// the stone
|
| 1303 |
+
const gem = ['#7ec8d8', '#c86a7a', '#8ad89a'][s % 3]
|
| 1304 |
+
ctx.fillStyle = '#b8943e'; ctx.fillRect(cx - 6, cy - 8, 12, 12)
|
| 1305 |
+
ctx.fillStyle = gem; ctx.fillRect(cx - 4, cy - 6, 8, 8)
|
| 1306 |
+
ctx.fillStyle = '#ffffff'; ctx.fillRect(cx - 3, cy - 5, 2, 2)
|
| 1307 |
+
// glints
|
| 1308 |
+
ctx.fillStyle = C.lamp; ctx.fillRect(cx + 8, cy - 12, 1, 3); ctx.fillRect(cx + 7, cy - 11, 3, 1)
|
| 1309 |
+
},
|
| 1310 |
+
phone: (ctx, w, h) => {
|
| 1311 |
+
const cx = Math.floor(w / 2) - 14
|
| 1312 |
+
const cy = Math.floor(h / 2) - 12
|
| 1313 |
+
// candlestick telephone - bright enough to read against the dark table
|
| 1314 |
+
ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 6, cy + 22, 20, 5); ctx.fillRect(cx + 12, cy + 6, 8, 17)
|
| 1315 |
+
ctx.fillStyle = '#5a6470'; ctx.fillRect(cx + 13, cy + 6, 2, 17); ctx.fillRect(cx + 7, cy + 22, 18, 2)
|
| 1316 |
+
// mouthpiece horn
|
| 1317 |
+
ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 6, cy - 2, 20, 8)
|
| 1318 |
+
ctx.fillStyle = '#5a6470'; ctx.fillRect(cx + 8, cy - 1, 16, 2)
|
| 1319 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 12, cy + 1, 8, 4)
|
| 1320 |
+
// rotary dial face
|
| 1321 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + 12, cy + 14, 8, 6)
|
| 1322 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 15, cy + 16, 2, 2)
|
| 1323 |
+
// earpiece on its side hook + coiled cord
|
| 1324 |
+
ctx.fillStyle = '#3a444e'; ctx.fillRect(cx - 6, cy + 2, 8, 5); ctx.fillRect(cx - 4, cy + 7, 5, 10)
|
| 1325 |
+
ctx.fillStyle = '#5a6470'; ctx.fillRect(cx - 5, cy + 3, 6, 2)
|
| 1326 |
+
ctx.fillStyle = '#8a9298'
|
| 1327 |
+
for (let i = 0; i < 6; i++) ctx.fillRect(cx - 2 + ((i % 2) * 3), cy + 18 + i * 2, 2, 2)
|
| 1328 |
+
},
|
| 1329 |
+
recorder: (ctx, w, h, s) => {
|
| 1330 |
+
const cx = Math.floor(w / 2) - 22
|
| 1331 |
+
const cy = Math.floor(h / 2) - 12
|
| 1332 |
+
ctx.fillStyle = '#2a2f39'; ctx.fillRect(cx, cy, 44, 26)
|
| 1333 |
+
ctx.fillStyle = '#3a414e'; ctx.fillRect(cx, cy, 44, 4)
|
| 1334 |
+
// twin reels
|
| 1335 |
+
for (const rx of [cx + 11, cx + 31]) {
|
| 1336 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(rx - 7, cy + 8, 14, 14)
|
| 1337 |
+
ctx.fillStyle = '#43301f'; ctx.fillRect(rx - 5, cy + 10, 10, 10)
|
| 1338 |
+
ctx.fillStyle = C.bone; ctx.fillRect(rx - 1, cy + 14, 2, 2)
|
| 1339 |
+
}
|
| 1340 |
+
// tape strand + rec light
|
| 1341 |
+
ctx.fillStyle = '#43301f'; ctx.fillRect(cx + 13, cy + 9, 18, 1)
|
| 1342 |
+
ctx.fillStyle = s % 2 ? C.ox : '#4a1414'; ctx.fillRect(cx + 40, cy + 2, 2, 2)
|
| 1343 |
+
},
|
| 1344 |
+
print: (ctx, w, h, s) => {
|
| 1345 |
+
const cx = Math.floor(w / 2) - 18
|
| 1346 |
+
const cy = Math.floor(h / 2) - 14
|
| 1347 |
+
// print card
|
| 1348 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 28, 30)
|
| 1349 |
+
ctx.fillStyle = '#0a0e13'
|
| 1350 |
+
// whorl
|
| 1351 |
+
for (let r = 9; r > 1; r -= 2) ctx.fillRect(cx + 14 - r, cy + 14 - r + (s % 2), r * 2, 1)
|
| 1352 |
+
for (let r = 8; r > 1; r -= 2) ctx.fillRect(cx + 14 - r, cy + 14 + r, r * 2, 1)
|
| 1353 |
+
ctx.fillRect(cx + 6, cy + 10, 1, 9); ctx.fillRect(cx + 22, cy + 9, 1, 9)
|
| 1354 |
+
// magnifier over the corner
|
| 1355 |
+
ctx.fillStyle = '#8d8c82'; ctx.fillRect(cx + 22, cy + 18, 14, 2)
|
| 1356 |
+
for (let r = 8; r > 5; r--) { ctx.fillStyle = '#b8b7ad'; ctx.fillRect(cx + 30 - r, cy + 12 - r, r * 2, r * 2) }
|
| 1357 |
+
ctx.fillStyle = '#1d2832aa'; ctx.fillRect(cx + 25, cy + 7, 10, 10)
|
| 1358 |
+
},
|
| 1359 |
+
cash: (ctx, w, h, s) => {
|
| 1360 |
+
const cx = Math.floor(w / 2) - 22
|
| 1361 |
+
const cy = Math.floor(h / 2) - 8
|
| 1362 |
+
// stacked banded bills
|
| 1363 |
+
for (let i = 0; i < 3; i++) {
|
| 1364 |
+
const y = cy + 12 - i * 6
|
| 1365 |
+
ctx.fillStyle = i % 2 ? '#1d5a2c' : '#14401f'; ctx.fillRect(cx + i * 3, y, 36, 6)
|
| 1366 |
+
ctx.fillStyle = '#2c5e38'; ctx.fillRect(cx + i * 3, y, 36, 1)
|
| 1367 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + i * 3 + 14, y, 8, 6)
|
| 1368 |
+
}
|
| 1369 |
+
// loose bill + coins
|
| 1370 |
+
ctx.fillStyle = '#1d5a2c'; ctx.fillRect(cx + 40, cy + 14, 14, 7)
|
| 1371 |
+
ctx.fillStyle = C.bone; ctx.fillRect(cx + 45, cy + 16, 4, 3)
|
| 1372 |
+
ctx.fillStyle = '#e3c06d'; ctx.fillRect(cx - 8, cy + 18, 4, 4); ctx.fillRect(cx - 12, cy + 20 + (s % 2), 4, 4)
|
| 1373 |
+
},
|
| 1374 |
+
rope: (ctx, w, h, s) => {
|
| 1375 |
+
const cx = Math.floor(w / 2)
|
| 1376 |
+
const cy = Math.floor(h / 2) + 2
|
| 1377 |
+
// coiled loops
|
| 1378 |
+
for (let r = 16; r > 4; r -= 4) {
|
| 1379 |
+
ctx.fillStyle = r % 8 ? '#8a6a3a' : '#a8854a'
|
| 1380 |
+
ctx.fillRect(cx - r, cy - Math.floor(r / 2), r * 2, Math.floor(r / 1.2))
|
| 1381 |
+
ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - r + 3, cy - Math.floor(r / 2) + 3, (r - 3) * 2, Math.floor(r / 1.4) - 4)
|
| 1382 |
+
}
|
| 1383 |
+
ctx.fillStyle = '#8a6a3a'; ctx.fillRect(cx - 4, cy - 2, 8, 6)
|
| 1384 |
+
// frayed end trailing off
|
| 1385 |
+
ctx.fillStyle = '#a8854a'
|
| 1386 |
+
for (let i = 0; i < 10; i++) ctx.fillRect(cx + 14 + i * 2, cy + 8 + ((i + s) % 3), 2, 2)
|
| 1387 |
+
ctx.fillStyle = '#6b4f2a'; ctx.fillRect(cx + 33, cy + 8, 1, 4); ctx.fillRect(cx + 35, cy + 9, 1, 3)
|
| 1388 |
+
},
|
| 1389 |
+
boot: (ctx, w, h, s) => {
|
| 1390 |
+
const cx = Math.floor(w / 2) - 12
|
| 1391 |
+
const cy = Math.floor(h / 2) - 14
|
| 1392 |
+
// plaster cast slab
|
| 1393 |
+
ctx.fillStyle = '#3a3022'; ctx.fillRect(cx - 8, cy - 2, 42, 34)
|
| 1394 |
+
ctx.fillStyle = '#4a3e2c'; ctx.fillRect(cx - 8, cy - 2, 42, 3)
|
| 1395 |
+
// boot tread pressed in
|
| 1396 |
+
ctx.fillStyle = '#241c12'
|
| 1397 |
+
ctx.fillRect(cx + 4, cy + 2, 16, 18); ctx.fillRect(cx + 6, cy + 22, 12, 6)
|
| 1398 |
+
ctx.fillStyle = '#15100a'
|
| 1399 |
+
for (let i = 0; i < 4; i++) ctx.fillRect(cx + 6, cy + 4 + i * 4, 12, 2)
|
| 1400 |
+
ctx.fillRect(cx + 7, cy + 23, 10, 2)
|
| 1401 |
+
// the worn-heel notch that gives it away
|
| 1402 |
+
ctx.fillStyle = '#4a3e2c'; ctx.fillRect(cx + 6 + (s % 3) * 3, cy + 24, 4, 3)
|
| 1403 |
+
},
|
| 1404 |
+
bag: (ctx, w, h, s) => {
|
| 1405 |
+
const cx = Math.floor(w / 2) - 18
|
| 1406 |
+
const cy = Math.floor(h / 2) - 12
|
| 1407 |
+
// sealed evidence bag with a small dark object inside
|
| 1408 |
+
ctx.fillStyle = '#c8d0d4'; ctx.fillRect(cx, cy, 36, 28)
|
| 1409 |
+
ctx.fillStyle = '#e2e8ea'; ctx.fillRect(cx, cy, 36, 5)
|
| 1410 |
+
ctx.fillStyle = C.ox; ctx.fillRect(cx, cy + 4, 36, 2)
|
| 1411 |
+
ctx.fillStyle = '#2a2f39'; ctx.fillRect(cx + 10 + (s % 3) * 2, cy + 12, 14, 9)
|
| 1412 |
+
ctx.fillStyle = '#15181d'; ctx.fillRect(cx + 12 + (s % 3) * 2, cy + 14, 10, 5)
|
| 1413 |
+
ctx.fillStyle = '#8a9298'; ctx.fillRect(cx + 4, cy + 24, 28, 1)
|
| 1414 |
+
},
|
| 1415 |
+
}
|
| 1416 |
+
|
| 1417 |
+
/** Procedural painter for one exhibit, seeded by its id so each looks distinct. */
|
| 1418 |
+
export function exhibitPainter(name: string, summary: string, seedKey: string): ScenePainter {
|
| 1419 |
+
const kind = exhibitKindFor(name, summary)
|
| 1420 |
+
const s = _hash(seedKey || name)
|
| 1421 |
+
return (ctx, w, h) => {
|
| 1422 |
+
exBase(ctx, w, h)
|
| 1423 |
+
_EX_PAINT[kind](ctx, w, h, s)
|
| 1424 |
+
}
|
| 1425 |
+
}
|
| 1426 |
+
|
| 1427 |
export function sceneFor(name: string): ScenePainter {
|
| 1428 |
if (SCENES[name]) return SCENES[name]
|
| 1429 |
// "Building — Room" -> key the painter off the room (the part after the last dash).
|
web/src/screens/cold.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { useTypewriter } from '../engine/pixel'
|
|
| 6 |
import { useGame } from '../store'
|
| 7 |
import type { PublicCase } from '../types'
|
| 8 |
import { playSfx } from '../ui/audio'
|
| 9 |
-
import { BottomNav, Btn, Controls, HintButton, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components'
|
| 10 |
|
| 11 |
export function TitleScreen() {
|
| 12 |
const g = useGame()
|
|
@@ -24,7 +24,7 @@ export function TitleScreen() {
|
|
| 24 |
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 75% at 50% 42%, transparent 45%, rgba(8,11,16,.45) 95%)' }} />
|
| 25 |
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(34% 56% at 50% 46%, rgba(8,11,16,.82) 30%, rgba(8,11,16,.5) 60%, transparent 80%)' }} />
|
| 26 |
<div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: 24, textAlign: 'center' }}>
|
| 27 |
-
<div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>A PROCEDURAL HOMICIDE</div>
|
| 28 |
<h1 class="t-display" style={{ fontSize: 'clamp(34px,9vw,82px)', color: 'var(--bone-3)', lineHeight: 0.95, textShadow: '4px 4px 0 var(--ink-0)' }}>CASE<br />ZERO</h1>
|
| 29 |
<p class="t-body" style={{ maxWidth: 440, color: 'var(--bone-2)', fontSize: 16 }}>
|
| 30 |
Every case is generated. The city, the body, the lies. Solve one no one has ever seen.
|
|
@@ -183,7 +183,7 @@ function PageHead({ tab, idx, c }: { tab: string; idx: string; c: PublicCase })
|
|
| 183 |
return (
|
| 184 |
<div class="between" style={{ borderBottom: '2px solid #211d1533', paddingBottom: 10, marginBottom: 16 }}>
|
| 185 |
<div class="col" style={{ gap: 4 }}>
|
| 186 |
-
<span class="dlabel">{c.city} CO. · HOMICIDE DIVISION</span>
|
| 187 |
<span class="dh" style={{ fontSize: 13 }}>{tab}</span>
|
| 188 |
</div>
|
| 189 |
<span class="dtype" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: '#6a6150' }}>FILE {c.id} · pg.{idx}</span>
|
|
@@ -199,7 +199,7 @@ function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChil
|
|
| 199 |
<div class="center" style={{ flexDirection: 'column', height: '100%', gap: 18, textAlign: 'center', position: 'relative' }}>
|
| 200 |
<span class="paperclip" />
|
| 201 |
<span class="dlabel" style={{ fontSize: 9, letterSpacing: '.3em' }}>{c.district}</span>
|
| 202 |
-
<div class="dlabel" style={{ color: '#6a6150', letterSpacing: '.2em' }}>HOMICIDE — CASE FILE</div>
|
| 203 |
<h1 class="dh" style={{ fontSize: 'clamp(26px,5vw,44px)', lineHeight: 1.05 }}>{c.title}</h1>
|
| 204 |
<div style={{ margin: '6px 0' }}><span class="dstamp" style={{ fontSize: 'clamp(14px,3vw,20px)' }}>{c.id}</span></div>
|
| 205 |
<div class="dtype" style={{ color: '#4a4234' }}>{c.district}</div>
|
|
@@ -222,8 +222,8 @@ function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChil
|
|
| 222 |
<h2 class="dh" style={{ fontSize: 20 }}>{c.victim.name}</h2>
|
| 223 |
<div class="dtype" style={{ color: '#4a4234' }}>{c.victim.role}</div>
|
| 224 |
<div class="row" style={{ gap: 10, flexWrap: 'wrap' }}>
|
| 225 |
-
<span class="dstamp" style={{ fontSize: 11 }}>DECEASED</span>
|
| 226 |
-
<span class="dtype" style={{ alignSelf: 'center' }}>T.O.D. {c.tod} · AGE {c.victim.age}</span>
|
| 227 |
</div>
|
| 228 |
</div>
|
| 229 |
</div>
|
|
@@ -248,12 +248,34 @@ function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChil
|
|
| 248 |
</div>
|
| 249 |
),
|
| 250 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
{
|
| 252 |
tab: 'KEY FACTS',
|
| 253 |
render: () => (
|
| 254 |
<div style={{ position: 'relative' }}>
|
| 255 |
-
<PageHead tab="KEY FACTS" idx="
|
| 256 |
-
<div style={{ position: 'absolute', top: 60, right: 0 }}><span class="dstamp" style={{ fontSize: 13 }}>HOMICIDE</span></div>
|
| 257 |
<div class="col" style={{ gap: 0 }}>
|
| 258 |
{c.facts.map(([k, v], i) => (
|
| 259 |
<div key={i} class="row" style={{ gap: 14, padding: '11px 0', borderBottom: '1px dotted #211d1540', alignItems: 'baseline' }}>
|
|
@@ -269,7 +291,7 @@ function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChil
|
|
| 269 |
tab: 'PERSONS OF INTEREST',
|
| 270 |
render: () => (
|
| 271 |
<div>
|
| 272 |
-
<PageHead tab="PERSONS OF INTEREST" idx="
|
| 273 |
<p class="dtype" style={{ marginBottom: 14, color: '#4a4234' }}>Those who stayed when the others fled the sirens. Each had reason to be near.</p>
|
| 274 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
| 275 |
{c.suspects.map((s) => (
|
|
|
|
| 6 |
import { useGame } from '../store'
|
| 7 |
import type { PublicCase } from '../types'
|
| 8 |
import { playSfx } from '../ui/audio'
|
| 9 |
+
import { BottomNav, Btn, Controls, ExhibitArt, HintButton, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components'
|
| 10 |
|
| 11 |
export function TitleScreen() {
|
| 12 |
const g = useGame()
|
|
|
|
| 24 |
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 75% at 50% 42%, transparent 45%, rgba(8,11,16,.45) 95%)' }} />
|
| 25 |
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(34% 56% at 50% 46%, rgba(8,11,16,.82) 30%, rgba(8,11,16,.5) 60%, transparent 80%)' }} />
|
| 26 |
<div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: 24, textAlign: 'center' }}>
|
| 27 |
+
<div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>A PROCEDURAL {c.kindLabel || 'HOMICIDE'}</div>
|
| 28 |
<h1 class="t-display" style={{ fontSize: 'clamp(34px,9vw,82px)', color: 'var(--bone-3)', lineHeight: 0.95, textShadow: '4px 4px 0 var(--ink-0)' }}>CASE<br />ZERO</h1>
|
| 29 |
<p class="t-body" style={{ maxWidth: 440, color: 'var(--bone-2)', fontSize: 16 }}>
|
| 30 |
Every case is generated. The city, the body, the lies. Solve one no one has ever seen.
|
|
|
|
| 183 |
return (
|
| 184 |
<div class="between" style={{ borderBottom: '2px solid #211d1533', paddingBottom: 10, marginBottom: 16 }}>
|
| 185 |
<div class="col" style={{ gap: 4 }}>
|
| 186 |
+
<span class="dlabel">{c.city} CO. · {c.division || 'HOMICIDE DIVISION'}</span>
|
| 187 |
<span class="dh" style={{ fontSize: 13 }}>{tab}</span>
|
| 188 |
</div>
|
| 189 |
<span class="dtype" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: '#6a6150' }}>FILE {c.id} · pg.{idx}</span>
|
|
|
|
| 199 |
<div class="center" style={{ flexDirection: 'column', height: '100%', gap: 18, textAlign: 'center', position: 'relative' }}>
|
| 200 |
<span class="paperclip" />
|
| 201 |
<span class="dlabel" style={{ fontSize: 9, letterSpacing: '.3em' }}>{c.district}</span>
|
| 202 |
+
<div class="dlabel" style={{ color: '#6a6150', letterSpacing: '.2em' }}>{c.kindLabel || 'HOMICIDE'} — CASE FILE</div>
|
| 203 |
<h1 class="dh" style={{ fontSize: 'clamp(26px,5vw,44px)', lineHeight: 1.05 }}>{c.title}</h1>
|
| 204 |
<div style={{ margin: '6px 0' }}><span class="dstamp" style={{ fontSize: 'clamp(14px,3vw,20px)' }}>{c.id}</span></div>
|
| 205 |
<div class="dtype" style={{ color: '#4a4234' }}>{c.district}</div>
|
|
|
|
| 222 |
<h2 class="dh" style={{ fontSize: 20 }}>{c.victim.name}</h2>
|
| 223 |
<div class="dtype" style={{ color: '#4a4234' }}>{c.victim.role}</div>
|
| 224 |
<div class="row" style={{ gap: 10, flexWrap: 'wrap' }}>
|
| 225 |
+
<span class="dstamp" style={{ fontSize: 11 }}>{c.victimStatus || 'DECEASED'}</span>
|
| 226 |
+
<span class="dtype" style={{ alignSelf: 'center' }}>{c.todLabel || 'T.O.D.'} {c.tod} · AGE {c.victim.age}</span>
|
| 227 |
</div>
|
| 228 |
</div>
|
| 229 |
</div>
|
|
|
|
| 248 |
</div>
|
| 249 |
),
|
| 250 |
},
|
| 251 |
+
{
|
| 252 |
+
tab: 'THE EXHIBITS',
|
| 253 |
+
render: () => (
|
| 254 |
+
<div>
|
| 255 |
+
<PageHead tab="THE EXHIBITS" idx="03" c={c} />
|
| 256 |
+
<p class="dtype" style={{ marginBottom: 12, color: '#4a4234' }}>Photographed and logged at the scene. Examine each on the wall.</p>
|
| 257 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
| 258 |
+
{c.evidence.slice(0, 6).map((e, i) => (
|
| 259 |
+
<div key={e.id} class="photo-paper" style={{ transform: `rotate(${(i % 3) - 1}deg)` }}>
|
| 260 |
+
<div style={{ background: '#0d1117' }}>
|
| 261 |
+
<ExhibitArt e={e} style={{ width: '100%', height: 'auto' }} />
|
| 262 |
+
</div>
|
| 263 |
+
<div class="between" style={{ marginTop: 4, gap: 6 }}>
|
| 264 |
+
<span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span>
|
| 265 |
+
<span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#8a3a2c', flexShrink: 0 }}>{e.time}</span>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
))}
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
),
|
| 272 |
+
},
|
| 273 |
{
|
| 274 |
tab: 'KEY FACTS',
|
| 275 |
render: () => (
|
| 276 |
<div style={{ position: 'relative' }}>
|
| 277 |
+
<PageHead tab="KEY FACTS" idx="04" c={c} />
|
| 278 |
+
<div style={{ position: 'absolute', top: 60, right: 0 }}><span class="dstamp" style={{ fontSize: 13 }}>{c.kindLabel || 'HOMICIDE'}</span></div>
|
| 279 |
<div class="col" style={{ gap: 0 }}>
|
| 280 |
{c.facts.map(([k, v], i) => (
|
| 281 |
<div key={i} class="row" style={{ gap: 14, padding: '11px 0', borderBottom: '1px dotted #211d1540', alignItems: 'baseline' }}>
|
|
|
|
| 291 |
tab: 'PERSONS OF INTEREST',
|
| 292 |
render: () => (
|
| 293 |
<div>
|
| 294 |
+
<PageHead tab="PERSONS OF INTEREST" idx="05" c={c} />
|
| 295 |
<p class="dtype" style={{ marginBottom: 14, color: '#4a4234' }}>Those who stayed when the others fled the sirens. Each had reason to be near.</p>
|
| 296 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
| 297 |
{c.suspects.map((s) => (
|
web/src/screens/deduce.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useState } from 'preact/hooks'
|
|
| 3 |
|
| 4 |
import { useGame } from '../store'
|
| 5 |
import type { Evidence, FlashbackAccount, TimelineBeat } from '../types'
|
| 6 |
-
import { BottomNav, Btn, Chip, EvIcon, Hud, Panel, Scene } from '../ui/components'
|
| 7 |
|
| 8 |
function Side({ data, hot }: { data: FlashbackAccount; hot?: boolean }) {
|
| 9 |
return (
|
|
@@ -54,7 +54,7 @@ export function FlashbackScreen() {
|
|
| 54 |
<div class="flash-grid"><Side data={F.a} /><Side data={F.b} hot /></div>
|
| 55 |
)}
|
| 56 |
<div class="center" style={{ marginTop: 18 }}>
|
| 57 |
-
<Btn variant="ox" onClick={() => g.nav('accuse')}>This is enough. Name the
|
| 58 |
</div>
|
| 59 |
</div>
|
| 60 |
</div>
|
|
@@ -154,7 +154,7 @@ export function NotesScreen() {
|
|
| 154 |
))}
|
| 155 |
</div>
|
| 156 |
<div class="center" style={{ marginTop: 14 }}>
|
| 157 |
-
<Btn variant="ox" onClick={() => g.nav('accuse')}>Name the
|
| 158 |
</div>
|
| 159 |
</Panel>
|
| 160 |
</div>
|
|
|
|
| 3 |
|
| 4 |
import { useGame } from '../store'
|
| 5 |
import type { Evidence, FlashbackAccount, TimelineBeat } from '../types'
|
| 6 |
+
import { BottomNav, Btn, Chip, EvIcon, Hud, Panel, Scene, perpNoun } from '../ui/components'
|
| 7 |
|
| 8 |
function Side({ data, hot }: { data: FlashbackAccount; hot?: boolean }) {
|
| 9 |
return (
|
|
|
|
| 54 |
<div class="flash-grid"><Side data={F.a} /><Side data={F.b} hot /></div>
|
| 55 |
)}
|
| 56 |
<div class="center" style={{ marginTop: 18 }}>
|
| 57 |
+
<Btn variant="ox" onClick={() => g.nav('accuse')}>This is enough. Name the {perpNoun(g.case.kind)} ▸</Btn>
|
| 58 |
</div>
|
| 59 |
</div>
|
| 60 |
</div>
|
|
|
|
| 154 |
))}
|
| 155 |
</div>
|
| 156 |
<div class="center" style={{ marginTop: 14 }}>
|
| 157 |
+
<Btn variant="ox" onClick={() => g.nav('accuse')}>Name the {perpNoun(g.case.kind)} ▸</Btn>
|
| 158 |
</div>
|
| 159 |
</Panel>
|
| 160 |
</div>
|
web/src/screens/endgame.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { accuse } from '../api'
|
|
| 6 |
import { useGame } from '../store'
|
| 7 |
import type { VerdictResult } from '../types'
|
| 8 |
import { playSfx } from '../ui/audio'
|
| 9 |
-
import { Btn, DialoguePanel, EvIcon, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components'
|
| 10 |
|
| 11 |
|
| 12 |
export function AccusationScreen() {
|
|
@@ -90,9 +90,9 @@ export function AccusationScreen() {
|
|
| 90 |
<Panel variant="ox" className="between wrap" style={{ gap: 12, padding: 16 }}>
|
| 91 |
<div class="t-body" style={{ fontSize: 14, maxWidth: 460 }}>
|
| 92 |
{err ? <span class="ox">{err}</span> : ready ? (
|
| 93 |
-
<>You are about to charge <span class="ox t-display" style={{ fontSize: 12 }}>{c.suspects.find((s) => s.id === a.suspect)!.name}</span>
|
| 94 |
) : (
|
| 95 |
-
|
| 96 |
)}
|
| 97 |
</div>
|
| 98 |
<Btn variant="ox" disabled={!ready || submitting} style={{ fontSize: 13, padding: '15px 26px' }} onClick={submit}>
|
|
@@ -141,7 +141,7 @@ export function VerdictScreen() {
|
|
| 141 |
const stats = result.stats?.length ? result.stats : g.runStats()
|
| 142 |
return (
|
| 143 |
<div class="app__view" style={{ background: 'var(--ink-0)', position: 'relative' }}>
|
| 144 |
-
<Scene name=
|
| 145 |
<div class="fx-scanlines" style={{ position: 'absolute', inset: 0, opacity: 0.4 }} />
|
| 146 |
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(80% 70% at 50% 40%, transparent 20%, rgba(8,11,16,.85) 80%)' }} />
|
| 147 |
<div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
|
|
@@ -155,7 +155,7 @@ export function VerdictScreen() {
|
|
| 155 |
<div class="col center" style={{ gap: 6 }}>
|
| 156 |
<div style={{ background: 'var(--ink-1)', boxShadow: `inset 0 0 0 3px ${correct ? 'var(--slate-2)' : 'var(--ox-2)'}`, padding: 6 }}><Portrait id={killerSprite} px={5} gender={killerGender} /></div>
|
| 157 |
<span class="t-display amber" style={{ fontSize: 11 }}>{result.verdict.killerName}</span>
|
| 158 |
-
<span class="t-label">THE
|
| 159 |
</div>
|
| 160 |
)}
|
| 161 |
{phase >= 2 && (
|
|
|
|
| 6 |
import { useGame } from '../store'
|
| 7 |
import type { VerdictResult } from '../types'
|
| 8 |
import { playSfx } from '../ui/audio'
|
| 9 |
+
import { Btn, DialoguePanel, EvIcon, Hud, Panel, Portrait, Scene, Stamp, perpNoun } from '../ui/components'
|
| 10 |
|
| 11 |
|
| 12 |
export function AccusationScreen() {
|
|
|
|
| 90 |
<Panel variant="ox" className="between wrap" style={{ gap: 12, padding: 16 }}>
|
| 91 |
<div class="t-body" style={{ fontSize: 14, maxWidth: 460 }}>
|
| 92 |
{err ? <span class="ox">{err}</span> : ready ? (
|
| 93 |
+
<>You are about to charge <span class="ox t-display" style={{ fontSize: 12 }}>{c.suspects.find((s) => s.id === a.suspect)!.name}</span> as the {perpNoun(c.kind)} in the case of {c.victim.name}.</>
|
| 94 |
) : (
|
| 95 |
+
`Select the ${perpNoun(c.kind)}, a motive, and at least one exhibit to proceed.`
|
| 96 |
)}
|
| 97 |
</div>
|
| 98 |
<Btn variant="ox" disabled={!ready || submitting} style={{ fontSize: 13, padding: '15px 26px' }} onClick={submit}>
|
|
|
|
| 141 |
const stats = result.stats?.length ? result.stats : g.runStats()
|
| 142 |
return (
|
| 143 |
<div class="app__view" style={{ background: 'var(--ink-0)', position: 'relative' }}>
|
| 144 |
+
<Scene name={c.scene} w={320} h={200} anim cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.5 }} />
|
| 145 |
<div class="fx-scanlines" style={{ position: 'absolute', inset: 0, opacity: 0.4 }} />
|
| 146 |
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(80% 70% at 50% 40%, transparent 20%, rgba(8,11,16,.85) 80%)' }} />
|
| 147 |
<div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
|
|
|
|
| 155 |
<div class="col center" style={{ gap: 6 }}>
|
| 156 |
<div style={{ background: 'var(--ink-1)', boxShadow: `inset 0 0 0 3px ${correct ? 'var(--slate-2)' : 'var(--ox-2)'}`, padding: 6 }}><Portrait id={killerSprite} px={5} gender={killerGender} /></div>
|
| 157 |
<span class="t-display amber" style={{ fontSize: 11 }}>{result.verdict.killerName}</span>
|
| 158 |
+
<span class="t-label">THE {perpNoun(c.kind).toUpperCase()}</span>
|
| 159 |
</div>
|
| 160 |
)}
|
| 161 |
{phase >= 2 && (
|
web/src/screens/evidence.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'preact/hooks'
|
|
| 4 |
|
| 5 |
import { useGame } from '../store'
|
| 6 |
import type { Evidence } from '../types'
|
| 7 |
-
import { BottomNav, Btn, Chip, EvIcon, EvidenceCard, Hud, Panel, Scene } from '../ui/components'
|
| 8 |
|
| 9 |
function PhoneThread({ e }: { e: Evidence }) {
|
| 10 |
return (
|
|
@@ -88,7 +88,10 @@ function ImageExhibit({ e, scene, tab, rec }: { e: Evidence; scene: string; tab:
|
|
| 88 |
|
| 89 |
function PaperItem({ e }: { e: Evidence }) {
|
| 90 |
return (
|
| 91 |
-
<Panel tab="
|
|
|
|
|
|
|
|
|
|
| 92 |
<div style={{ background: 'var(--bone-2)', color: 'var(--ink-1)', padding: 16, fontFamily: 'var(--f-mono)', fontSize: 'calc(16px*var(--mono-scale))', lineHeight: 1.5, boxShadow: '3px 4px 0 rgba(0,0,0,.4)', whiteSpace: 'pre-wrap', transform: 'rotate(-1deg)' }}>{e.detail}</div>
|
| 93 |
</Panel>
|
| 94 |
)
|
|
|
|
| 4 |
|
| 5 |
import { useGame } from '../store'
|
| 6 |
import type { Evidence } from '../types'
|
| 7 |
+
import { BottomNav, Btn, Chip, EvIcon, EvidenceCard, ExhibitArt, Hud, Panel, Scene } from '../ui/components'
|
| 8 |
|
| 9 |
function PhoneThread({ e }: { e: Evidence }) {
|
| 10 |
return (
|
|
|
|
| 88 |
|
| 89 |
function PaperItem({ e }: { e: Evidence }) {
|
| 90 |
return (
|
| 91 |
+
<Panel tab="PHYSICAL EXHIBIT" style={{ maxWidth: 440 }}>
|
| 92 |
+
<div style={{ border: '6px solid var(--bone-2)', boxShadow: '3px 4px 0 rgba(0,0,0,.4)', marginBottom: 14, background: 'var(--ink-0)' }}>
|
| 93 |
+
<ExhibitArt e={e} style={{ width: '100%', height: 'auto' }} />
|
| 94 |
+
</div>
|
| 95 |
<div style={{ background: 'var(--bone-2)', color: 'var(--ink-1)', padding: 16, fontFamily: 'var(--f-mono)', fontSize: 'calc(16px*var(--mono-scale))', lineHeight: 1.5, boxShadow: '3px 4px 0 rgba(0,0,0,.4)', whiteSpace: 'pre-wrap', transform: 'rotate(-1deg)' }}>{e.detail}</div>
|
| 96 |
</Panel>
|
| 97 |
)
|
web/src/types.ts
CHANGED
|
@@ -96,6 +96,13 @@ export interface PublicCase {
|
|
| 96 |
tod: string
|
| 97 |
found: string
|
| 98 |
cause: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
facts: [string, string][]
|
| 100 |
bootLines: string[]
|
| 101 |
storyBeats: StoryBeat[]
|
|
|
|
| 96 |
tod: string
|
| 97 |
found: string
|
| 98 |
cause: string
|
| 99 |
+
// Case-kind display labels (optional on old stored cases; default to homicide wording).
|
| 100 |
+
kind?: string
|
| 101 |
+
kindLabel?: string
|
| 102 |
+
division?: string
|
| 103 |
+
victimStatus?: string
|
| 104 |
+
todLabel?: string
|
| 105 |
+
verdict?: string
|
| 106 |
facts: [string, string][]
|
| 107 |
bootLines: string[]
|
| 108 |
storyBeats: StoryBeat[]
|
web/src/ui/components.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import type { ComponentChildren, JSX } from 'preact'
|
| 4 |
import { useEffect, useState } from 'preact/hooks'
|
| 5 |
|
| 6 |
-
import { EV_ICONS, IPAL, bodyFor, portraitFor, sceneFor } from '../engine/art'
|
| 7 |
import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
|
| 8 |
import { useGame } from '../store'
|
| 9 |
import type { Evidence, Suspect } from '../types'
|
|
@@ -13,6 +13,13 @@ import { TweaksSheet } from './tweaks'
|
|
| 13 |
const pxScaled = (px: number, floor = 2) =>
|
| 14 |
Math.max(floor, Math.round(px * (window.__pxScale || 1)))
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
// ---- sprite wrappers ----
|
| 17 |
export function useBlink(): boolean {
|
| 18 |
const [b, setB] = useState(false)
|
|
@@ -106,6 +113,28 @@ export function Scene({ name, w = 240, h = 135, anim = false, full = false, cove
|
|
| 106 |
)
|
| 107 |
}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
// ---- primitives ----
|
| 110 |
interface PanelProps {
|
| 111 |
tab?: string
|
|
|
|
| 3 |
import type { ComponentChildren, JSX } from 'preact'
|
| 4 |
import { useEffect, useState } from 'preact/hooks'
|
| 5 |
|
| 6 |
+
import { EV_ICONS, IPAL, bodyFor, exhibitPainter, portraitFor, sceneFor } from '../engine/art'
|
| 7 |
import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
|
| 8 |
import { useGame } from '../store'
|
| 9 |
import type { Evidence, Suspect } from '../types'
|
|
|
|
| 13 |
const pxScaled = (px: number, floor = 2) =>
|
| 14 |
Math.max(floor, Math.round(px * (window.__pxScale || 1)))
|
| 15 |
|
| 16 |
+
// What the culprit is called in this kind of case ("name the killer / thief / arsonist").
|
| 17 |
+
const PERP: Record<string, string> = {
|
| 18 |
+
homicide: 'killer', theft: 'thief', fraud: 'fraudster',
|
| 19 |
+
blackmail: 'blackmailer', arson: 'arsonist', missing_person: 'abductor',
|
| 20 |
+
}
|
| 21 |
+
export const perpNoun = (kind?: string): string => PERP[kind || 'homicide'] || 'culprit'
|
| 22 |
+
|
| 23 |
// ---- sprite wrappers ----
|
| 24 |
export function useBlink(): boolean {
|
| 25 |
const [b, setB] = useState(false)
|
|
|
|
| 113 |
)
|
| 114 |
}
|
| 115 |
|
| 116 |
+
interface ExhibitArtProps {
|
| 117 |
+
e: Pick<Evidence, 'id' | 'name' | 'summary'>
|
| 118 |
+
w?: number
|
| 119 |
+
h?: number
|
| 120 |
+
style?: JSX.CSSProperties
|
| 121 |
+
className?: string
|
| 122 |
+
}
|
| 123 |
+
/** Procedural "evidence photo" of one exhibit - the object itself, on the forensic
|
| 124 |
+
* table, classified from the exhibit's own words and seeded by its id. */
|
| 125 |
+
export function ExhibitArt({ e, w = 96, h = 72, style, className }: ExhibitArtProps) {
|
| 126 |
+
return (
|
| 127 |
+
<SceneCanvas
|
| 128 |
+
paint={exhibitPainter(e.name, e.summary || '', e.id)}
|
| 129 |
+
w={w}
|
| 130 |
+
h={h}
|
| 131 |
+
style={style}
|
| 132 |
+
className={className}
|
| 133 |
+
deps={[e.id, e.name]}
|
| 134 |
+
/>
|
| 135 |
+
)
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
// ---- primitives ----
|
| 139 |
interface PanelProps {
|
| 140 |
tab?: string
|