HusseinEid commited on
Commit
80cd1f2
·
verified ·
1 Parent(s): 1478c2e

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 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."* Draft in
61
- [`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md) - to be published on the Hub.
62
  - **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
63
- from."* A captured interrogation/generation trace to be uploaded to the Hub.
 
 
64
  - **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
65
- Not yet - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and publishing a
66
- model; out of scope for this submission unless pursued separately.
 
 
 
 
 
 
 
 
67
 
68
  ## Zero cloud AI APIs
69
 
 
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
- - llama-cpp
 
 
 
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 murder mystery, written and acted by a 1.5B model, every single time.**
24
 
25
  No scripted cases. No content library. A single small local model invents the whole
26
- thing — the victim, the suspects, their secrets and motives, the timeline, the murder
27
- weapon, the evidence, and the one who did it then **role-plays every suspect live**.
28
- They remember what you asked. They lie to your face. And when you slap down the right
29
- piece of evidence, you watch the lie **crack in real time**.
 
30
 
31
  > Interrogate. Investigate. Accuse. One of them is guilty. Prove it.
32
 
@@ -54,15 +61,30 @@ reliability layer — it never writes story, character, or dialogue.
54
 
55
  ## 🏆 Built for the Build Small Hackathon
56
 
57
- - **Tiny Titan (≤4B):** the entire game runs on **Qwen2.5-1.5B** ~1.6B total runtime
58
- params (LLM + Supertonic), far under the 32B cap.
 
 
 
59
  - **Llama Champion:** the model runs through the **llama.cpp** runtime, in-process — no
60
  server, no remote endpoint.
61
- - **Off-Brand:** a fully custom pixel-art frontend, served through `gradio.Server`.
62
- - All models are **open-weights and self-run**. No third-party AI APIs are ever called.
 
 
 
 
 
 
63
 
64
  See [COMPLIANCE.md](COMPLIANCE.md) for the full parameter budget and badge details.
65
 
 
 
 
 
 
 
66
  ## ▶️ Run it locally
67
 
68
  ```bash
 
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 murder-mystery game with no scripted cases and no content library. A single
8
  **1.5B-parameter model** (Qwen2.5-1.5B-Instruct, Q4_K_M GGUF) invents the entire mystery
9
- every time you play - the victim, the suspects, their secrets and motives, the timeline, the
10
- weapon, the evidence, and the one who did it - and then **role-plays every suspect live**.
 
11
  They remember what you asked. They lie. And when you present the piece of evidence that
12
  contradicts an alibi, the lie cracks on screen.
13
 
14
  The whole thing runs on the CPU in front of you. No cloud, no GPU, no remote endpoint.
15
 
 
 
 
 
 
 
16
  ## The hard part: a tiny model that's still *fair*
17
 
18
  The interesting tension in this project is that a 1.5B model is a wonderful improviser and a
@@ -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. The same
58
- number gates background case-generation so it never steals vCPUs from an active
59
- interrogation.
 
 
 
 
60
 
61
  There is also no image work on the request path: all pixel art is rendered **client-side on
62
  canvas**, so the server spends ~0 CPU on visuals and devotes both vCPUs to the model. Voices
 
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". Reject anything with a comma, digits, or a gender word.
44
- return bool("," in n or any(c.isdigit() for c in n) or re.search(r"\b(male|female)\b", n, re.I))
 
45
 
46
 
47
  def _name_prefix(n: str) -> str:
@@ -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 10
87
- start_seed = int(sys.argv[2]) if len(sys.argv) > 2 else 42000
88
  max_attempts = target * 4 + 8
89
 
90
  backend = make_backend(get_settings())
91
  out_dir = prebaked_cases_dir()
92
  out_dir.mkdir(parents=True, exist_ok=True)
 
 
93
 
94
  kept: list[CaseFile] = []
95
  seed = start_seed
96
  attempts = 0
97
  while len(kept) < target and attempts < max_attempts:
98
  attempts += 1
 
99
  try:
100
- result = generate_case(backend, seed=seed)
 
101
  except Exception as exc: # generation hiccup - skip this seed
102
  print(f"[seed {seed}] generation error: {exc}")
103
  seed += 1
@@ -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
- _ICONS = ("photoEv", "receipt", "keycard", "compass", "phone", "cctv")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  # Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
54
  _CITIES = (
55
  "Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
@@ -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=_ICONS[idx % len(_ICONS)],
115
  time=time,
116
  found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
117
  summary=clue.reveal_text,
@@ -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
- beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=f"{case.victim.name} is killed. Time of death.", locked=True))
 
 
 
 
133
  beats.sort(key=lambda b: b.time)
134
  return tuple(beats)
135
 
@@ -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="atrium", kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
175
- StoryBeat(scene="interro", kicker="THOSE WHO STAYED", title="Persons of interest",
176
  text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
177
- StoryBeat(scene="seawall", kicker="YOUR CASE NOW", title="Detective",
178
  text="One of them is lying to your face. Find the crack in the account and follow it down."),
179
  )
180
 
181
 
182
  def casefile_to_public(case: CaseFile) -> PublicCase:
183
  seed = case.seed
 
184
  tod = case.victim.time_of_death.start_min
185
  building = case.setting.name
186
  room = _loc_name(case, case.victim.found_at_loc_id)
@@ -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"Found in {scene} at {_clock(case.victim.found_at_min)}.",
200
  cause=case.victim.cause_of_death,
 
 
 
 
 
 
201
  facts=(
202
  ("CITY", city),
203
  ("VICTIM", f"{case.victim.name}"),
204
  ("SCENE", scene),
205
- ("TIME OF DEATH", _clock(tod)),
206
- ("CAUSE", case.victim.cause_of_death),
207
- ("VERDICT", "Homicide"),
208
  ),
209
  boot_lines=(
210
  "The phone drags you up out of half a sleep.",
211
- f"{case.setting.name}. A death no one saw coming.",
212
  f"{case.victim.name} - {case.victim.role}.",
213
  "They're holding the scene. It's yours now, detective.",
214
  ),
215
- story_beats=_story_beats(case),
216
  suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
217
  evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
218
- timeline=_timeline(case),
219
  flashback=_flashback(case),
220
  motives=_motives(case, seed),
221
  )
 
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 death - everything "
10
- "turns on who could be where, and when.",
11
- "story": "Don't look for the killer yet. The story tells you who was in the building and "
12
  "why; the lies come when you start asking questions.",
13
  "board": "Pick a suspect on the right and interrogate. Drag the exhibits as you think, "
14
  "and examine anything that doesn't sit right.",
 
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
- To keep that lock from ever blocking an interrogation, case generation only happens when
7
- no one is playing yet: one case is prebuilt at startup, and each later New Case generates
8
- synchronously while the player waits on the loading screen (no background generation runs
9
- during play). On a 2-vCPU Space this also means the LLM is never oversubscribed.
 
 
 
 
10
  """
11
 
12
  from __future__ import annotations
13
 
 
14
  import threading
15
  import time
16
  import uuid
 
17
  from dataclasses import dataclass
18
 
19
  from ..config import effective_cpus, get_settings
20
  from ..engine.session import Session
21
  from ..generator.pipeline import generate_case
22
- from ..llm.backend import LLMBackend, LLMError, make_backend
23
  from ..persistence.case_store import load_case, save_runtime_case
24
  from ..persistence.paths import prebaked_cases_dir, runtime_cases_dir
25
  from ..schemas.accusation import Accusation
@@ -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
- # Only generate cases ahead-of-time in the background on a box with cores to spare;
56
- # on the 2-vCPU Space that would steal the single model lock from an interrogation,
57
- # so there we rely on the pre-baked pool and generate only on demand.
58
- self._gen_ahead = effective_cpus() > 4
 
 
 
59
 
60
  # ---- backend ----
61
  def _get_backend(self) -> LLMBackend | None:
@@ -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
- with self._lock:
82
- result = generate_case(backend, seed=seed)
 
83
  save_runtime_case(result.case)
84
  return result.case
85
 
86
  def _prebuild(self) -> None:
 
 
 
 
87
  try:
88
- case = self._generate(self._next_seed())
89
- with self._buffer_lock:
90
- self._buffer = case
91
- except Exception:
92
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  def _load_prebaked(self) -> None:
95
  if self._prebaked_loaded:
@@ -103,19 +173,15 @@ class GameRuntime:
103
  self._prebaked.append(load_case(path))
104
  except Exception:
105
  continue
106
- # Start the rotation at a varied offset so a Space restart doesn't always serve the
107
- # first case again (the seed is time-based), keeping New Case fresh across restarts.
108
- if self._prebaked:
109
- self._prebaked_idx = self._seed % len(self._prebaked)
110
 
111
  def start_buffer(self) -> None:
112
- """Make the first New Case instant: load the shipped pool now and (only on a box with
113
- cores to spare) prebuild one fresh live case in the background. On the 2-vCPU Space we
114
- skip the background prebuild so the model lock stays free for the first interrogation -
115
- the pre-baked pool already gives an instant case."""
116
  self._load_prebaked()
117
- if self._gen_ahead and self.available():
118
- threading.Thread(target=self._prebuild, daemon=True).start()
119
 
120
  def _take_buffered(self) -> CaseFile | None:
121
  with self._buffer_lock:
@@ -127,14 +193,18 @@ class GameRuntime:
127
  self._load_prebaked()
128
  if not self._prebaked:
129
  return None
130
- case = self._prebaked[self._prebaked_idx % len(self._prebaked)]
 
 
 
 
131
  self._prebaked_idx += 1
132
  return case
133
 
134
  def _maybe_refill(self) -> None:
135
- """Generate one fresh case in the background - capable hardware only (see _gen_ahead)."""
136
- if self._gen_ahead and self._buffer is None and self.available():
137
- threading.Thread(target=self._prebuild, daemon=True).start()
138
 
139
  def new_generated_run(self) -> tuple[PublicCase, str] | None:
140
  if not self.available():
@@ -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"planned|revenge|confess|culprit|victim|the weapon|the murder)\b",
 
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 left where the body fell, its rim marked with a recent lip-print.",
61
  "A torn thread of dark cloth snagged on the {room} doorframe.",
62
- "Half-dried footprints crossing the {room}, left around the time of death.",
63
  "A monogrammed cufflink wedged under the rug, dropped in haste.",
64
- "A freshly scuffed floorboard where someone braced against a struggle.",
65
- "A cigarette stubbed out mid-smoke - and not the victim's brand.",
66
- "A mantel clock knocked still at the very minute of death.",
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) -> 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
- f"A Death in the {room}",
149
- f"The {room} Affair",
150
- f"Murder at {setting}",
151
- f"The {last} File",
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 murder.", true_value=True, loc_id=crime_loc, at_min=tod.start_min),
 
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="alone with the victim"),
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", topic="where you were during the murder",
 
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 with the murder."),)
 
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 murder window keeps alibi reasoning simple; the model still invents the rest.
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 = 4,
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.9, max_tokens=4096, seed=attempt_seed,
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, world.weapon_name,
76
- world.locations[crime_idx], world.locations[claimed_idx],
 
77
  MURDER_WINDOW.start_min, MURDER_WINDOW.end_min),
78
- MysteryOut, temperature=0.6, max_tokens=2048, seed=attempt_seed,
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, weapon, 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.
 
 
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, n_suspects: int,
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 murder mystery. Invent something specific and "
79
- f"atmospheric (theme: {hint}). The murder happened between {_clock(window_start)} and "
80
- f"{_clock(window_end)} one evening. Keep every field short - a phrase or one sentence; "
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
- "- the victim (name, role, cause of death) and which room index (0-based) they were "
89
- "found in;\n"
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 necessarily murder), the COVER STORY they tell "
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(culprit_name: str, culprit_role: str, victim_name: str, weapon_name: str,
115
- crime_room: str, claimed_room: str, window_start: int, window_end: int) -> str:
 
 
 
 
 
 
 
116
  return (
117
- f"In this mystery the killer is {culprit_name} ({culprit_role}). They murdered {victim_name} "
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, quietly place {culprit_name} there during the "
126
- "murder. Each must be a real forensic trace a detective would find - a fingerprint, a "
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 killer in the "
129
- "reveal, do NOT mention the murder or the motive, and NEVER write a confession or any "
130
- "first-person words. (a short name + a one-line physical description for each);\n"
 
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, max_tokens=max_tokens, seed=seed)
 
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
- i_did.append(case.culprit.method_narrative)
87
- i_must_conceal.append(
88
- f"You killed {case.victim.name}. You must never admit this; deflect and deny."
 
 
 
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 murder-mystery 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,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 killer. "
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 interior.
 
584
  const _ROOM_MAP: [RegExp, string][] = [
585
- [/kitchen|pantry|galley/i, 'kitchen'],
586
- [/librar|study|office|den|archive/i, 'study'],
587
- [/cellar|basement|wine|vault/i, 'study'],
588
- [/bed|chamber|boudoir|suite|nursery/i, 'bedroom'],
589
- [/mezzanine|\brail\b|balcon/i, 'mezzanine'],
590
- [/dock|harbou?r|pier|seawall|wharf|seaside|waterfront/i, 'seawall'],
591
- [/parlou?r|lounge|living|sitting|drawing|salon|conservatory|garden|terrace|greenhouse/i, 'parlor'],
592
- [/hall|foyer|ballroom|atrium|gallery|lobby|dining|entrance|stair|landing|court/i, 'atrium'],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="03" c={c} />
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="04" c={c} />
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 killer ▸</Btn>
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 killer ▸</Btn>
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> with the murder of {c.victim.name}.</>
94
  ) : (
95
- 'Select a killer, 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,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="mezzanine" 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,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 KILLER</span>
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="PAPER EXHIBIT" style={{ maxWidth: 360 }}>
 
 
 
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