aliabdelwahab Claude Fable 5 commited on
Commit
571ccaa
·
verified ·
1 Parent(s): 57464e6

Evidence icons: 16 new pixel icons, name-first relevance matching

Browse files

- art.ts: EV_ICONS grows from 7 to 23 procedural 12x16 pixel icons
(fingerprint, tumbler, thread spool, envelope, bottle, knife, pocket
watch, ring, cigarette, bootprint, book, skeleton key, lit match,
pen, banknotes, magnifier) in the existing IPAL palette
- case_adapter.py: icon keyword rules rewritten and reordered (knife
before "letter", footprints before "prints", keycard before "key");
the clue NAME decides first and the reveal text only breaks ties, so
a fingerprint whose description mentions a blade stays a fingerprint
- Unmatched clues now fall back by discovery method (forensic ->
fingerprint, document -> receipt, search -> photo, interrogation ->
voicemail) instead of a blind 3-icon rotation
- Applies to prebaked AND AI-generated cases (both flow through
casefile_to_public at serve time); golden seed cases keep their
hand-authored icons
- Verified: every prebaked clue maps to a relevant icon (15 distinct
icons across the pool, was effectively 3-4); backend icon names
cross-checked 1:1 against EV_ICONS keys
- COMPLIANCE.md: Off-Brand blurb updated for the icon set, the
Connect the Dots mode, and the mobile corkboard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. COMPLIANCE.md +108 -105
  2. src/case_zero/api/case_adapter.py +297 -264
  3. web/src/engine/art.ts +16 -0
COMPLIANCE.md CHANGED
@@ -1,105 +1,108 @@
1
- # Case Zero - Hackathon Compliance
2
-
3
- Built for the **Build Small Hackathon** ("Small models, big adventure").
4
-
5
- Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
6
- "Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
7
- registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
8
- GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
9
- app itself is Gradio, served end to end by `gradio.Server`.
10
-
11
- ## Core requirements
12
-
13
- | Requirement | Status |
14
- |---|---|
15
- | Total model params <= 32B | ✓ ~1.6B (see budget below) |
16
- | Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
17
- | Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
18
- | Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
19
- | Social-media post | ☐ to post |
20
-
21
- ## Parameter budget (<= 32B total)
22
-
23
- Every model is open-weights and self-run. **No third-party AI service is ever called.**
24
-
25
- | Component | Model | Open? | Params | Runs |
26
- |---|---|---|---|---|
27
- | Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
28
- | Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
29
- | Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
30
- | Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
31
- | Embeddings / vector RAG | none | n/a | 0B | - |
32
-
33
- **Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
34
- **Tiny Titan** special award).
35
-
36
- ## Merit badges
37
-
38
- ### Earned by the build (verifiable on the Space)
39
-
40
- - **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
41
- The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
42
- rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
43
- baked into the Docker image at build time, so the running container makes **no AI network
44
- calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
45
- socket guard and asserts **zero non-loopback connections**. ✓
46
- - **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
47
- through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
48
- endpoint. ✓
49
- - **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
50
- end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
51
- TypeScript)** - 12 screens, a custom pixel design system (self-hosted Silkscreen /
52
- Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards, a ruled-paper
53
- dossier with page-flips), a draggable corkboard, a live interrogation stage with a
54
- voiced suspect, procedural canvas art and rain FX, and a full client audio layer. The
55
- built bundle is served as static files by the same `gradio.Server` that exposes the
56
- `/api` routes - one process, no separate frontend host.
57
-
58
- ### Targeted / in progress
59
-
60
- - **Field Notes** - *"Write a blog post or report about your project."*
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
-
80
- - **No OpenAI, Anthropic, Google, ElevenLabs, Higgsfield, Midjourney, or any other hosted
81
- AI API is ever called** - not for text, not for voice, not for images.
82
- - The LLM is the in-process llama.cpp runtime. The voices are a local ONNX model. The pixel
83
- art is procedural canvas. The music is a bundled CC-BY track.
84
- - The open Qwen GGUF and Supertonic ONNX are **baked into the Docker image at build time**,
85
- so the running container makes no AI network calls. `scripts/net_audit.py` proves zero
86
- non-loopback connections during a full playthrough.
87
-
88
- ## Anti-cheat / fairness (why the game is solvable and the win is earned)
89
-
90
- - The sealed solution (killer, true motive, key evidence) is **never sent to the client**
91
- pre-verdict; it is read only inside `/api/run/{runId}/accuse`. Verified by anti-leak tests.
92
- - Suspicion, evidence reactions, and the verdict are **server-authoritative** - the client
93
- only displays them.
94
- - Suspects **never confess**: the win is registered only when the player accuses correctly,
95
- so the outcome is immune to prose (a jailbroken "just tell me who did it" earns nothing).
96
-
97
- ## Submission checklist
98
-
99
- - [x] Gradio app on a Hugging Face Space (CPU)
100
- - [x] <= 32B total params (~1.6B)
101
- - [x] Open-weights, self-run models only - zero cloud AI APIs
102
- - [x] Custom (non-default) UI - pixel-art Preact SPA via `gradio.Server`
103
- - [x] Off the Grid proof (`scripts/net_audit.py`)
104
- - [ ] Short demo video
105
- - [ ] Social-media post
 
 
 
 
1
+ # Case Zero - Hackathon Compliance
2
+
3
+ Built for the **Build Small Hackathon** ("Small models, big adventure").
4
+
5
+ Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
6
+ "Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
7
+ registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
8
+ GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
9
+ app itself is Gradio, served end to end by `gradio.Server`.
10
+
11
+ ## Core requirements
12
+
13
+ | Requirement | Status |
14
+ |---|---|
15
+ | Total model params <= 32B | ✓ ~1.6B (see budget below) |
16
+ | Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
17
+ | Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
18
+ | Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
19
+ | Social-media post | ☐ to post |
20
+
21
+ ## Parameter budget (<= 32B total)
22
+
23
+ Every model is open-weights and self-run. **No third-party AI service is ever called.**
24
+
25
+ | Component | Model | Open? | Params | Runs |
26
+ |---|---|---|---|---|
27
+ | Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
28
+ | Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
29
+ | Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
30
+ | Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
31
+ | Embeddings / vector RAG | none | n/a | 0B | - |
32
+
33
+ **Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
34
+ **Tiny Titan** special award).
35
+
36
+ ## Merit badges
37
+
38
+ ### Earned by the build (verifiable on the Space)
39
+
40
+ - **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
41
+ The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
42
+ rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
43
+ baked into the Docker image at build time, so the running container makes **no AI network
44
+ calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
45
+ socket guard and asserts **zero non-loopback connections**. ✓
46
+ - **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
47
+ through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
48
+ endpoint. ✓
49
+ - **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
50
+ end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
51
+ TypeScript)** - 13 screens, a custom pixel design system (self-hosted Silkscreen /
52
+ Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards with 23
53
+ procedural pixel icons keyword-matched to each exhibit, a ruled-paper dossier with
54
+ page-flips), a draggable corkboard with a red-yarn **"Connect the Dots"** mode (tie
55
+ evidence together with sagging SVG threads, on desktop and on the scrollable mobile
56
+ wall), a live interrogation stage with a voiced suspect, procedural canvas art and rain
57
+ FX, and a full client audio layer. The built bundle is served as static files by the
58
+ same `gradio.Server` that exposes the `/api` routes - one process, no separate frontend
59
+ host. ✓
60
+
61
+ ### Targeted / in progress
62
+
63
+ - **Field Notes** - *"Write a blog post or report about your project."*
64
+ [`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md), shipped with the Space.
65
+ - **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
66
+ from."* Real traces (the exact prompts + raw completions of a full case generation, and
67
+ a live interrogation playthrough with server-authoritative suspicion) are produced by
68
+ `scripts/export_traces.py` and published as a Hub dataset (linked from the README).
69
+ - **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
70
+ Not claimed - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and
71
+ publishing a model; out of scope for this submission.
72
+
73
+ ## Content scope
74
+
75
+ Cases span **homicide, theft, fraud, blackmail, arson, and missing-person** mysteries.
76
+ Generation is structurally constrained (case-file language, physical evidence, no graphic
77
+ description) and a deterministic scrubber sanitizes model output. Sexual violence is
78
+ deliberately **not** a case type, keeping the Space comfortably inside the
79
+ [HF Content Guidelines](https://huggingface.co/content-guidelines) with no NFAA gating.
80
+
81
+ ## Zero cloud AI APIs
82
+
83
+ - **No OpenAI, Anthropic, Google, ElevenLabs, Higgsfield, Midjourney, or any other hosted
84
+ AI API is ever called** - not for text, not for voice, not for images.
85
+ - The LLM is the in-process llama.cpp runtime. The voices are a local ONNX model. The pixel
86
+ art is procedural canvas. The music is a bundled CC-BY track.
87
+ - The open Qwen GGUF and Supertonic ONNX are **baked into the Docker image at build time**,
88
+ so the running container makes no AI network calls. `scripts/net_audit.py` proves zero
89
+ non-loopback connections during a full playthrough.
90
+
91
+ ## Anti-cheat / fairness (why the game is solvable and the win is earned)
92
+
93
+ - The sealed solution (killer, true motive, key evidence) is **never sent to the client**
94
+ pre-verdict; it is read only inside `/api/run/{runId}/accuse`. Verified by anti-leak tests.
95
+ - Suspicion, evidence reactions, and the verdict are **server-authoritative** - the client
96
+ only displays them.
97
+ - Suspects **never confess**: the win is registered only when the player accuses correctly,
98
+ so the outcome is immune to prose (a jailbroken "just tell me who did it" earns nothing).
99
+
100
+ ## Submission checklist
101
+
102
+ - [x] Gradio app on a Hugging Face Space (CPU)
103
+ - [x] <= 32B total params (~1.6B)
104
+ - [x] Open-weights, self-run models only - zero cloud AI APIs
105
+ - [x] Custom (non-default) UI - pixel-art Preact SPA via `gradio.Server`
106
+ - [x] Off the Grid proof (`scripts/net_audit.py`)
107
+ - [ ] Short demo video
108
+ - [ ] Social-media post
src/case_zero/api/case_adapter.py CHANGED
@@ -1,264 +1,297 @@
1
- """Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
2
- golden case uses). Deterministic: it synthesizes the display surface from the case's
3
- own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
4
- is exposed; the true motive's *text* appears only as one option among the decoys.
5
-
6
- Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
7
- (threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- 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 (
18
- FlashbackAccount,
19
- PublicCase,
20
- PublicEvidence,
21
- PublicFlashback,
22
- PublicMotive,
23
- PublicSuspect,
24
- PublicVictim,
25
- StoryBeat,
26
- SuggestedQuestion,
27
- TimelineBeat,
28
- )
29
- from .questions import FIXED_QUESTIONS
30
-
31
- _SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
32
- _WEATHER = (
33
- "Rain, and a wind off the water.", "Fog thick enough to lean on.",
34
- "A dry cold that gets into the joints.", "Sleet against every window.",
35
- )
36
- _GREETS = (
37
- "Detective. Let's get this over with.",
38
- "I already told the others everything I know.",
39
- "Ask what you came to ask.",
40
- "I have nothing to hide, if that's what you think.",
41
- )
42
- _APPARENT = (
43
- "Was close enough to the victim to have a reason - and a chance.",
44
- "Had more to lose tonight than they're letting on.",
45
- "Their story has a seam in it, if you pull the right thread.",
46
- "Stood to gain from a death no one saw coming.",
47
- )
48
- _DECOY_MOTIVES = (
49
- "To bury a debt that was about to surface.",
50
- "Jealousy that had festered for years.",
51
- "To keep a buried secret buried.",
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",
79
- "Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
80
- "Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
81
- )
82
-
83
-
84
- def _hash(s: str) -> int:
85
- h = 0
86
- for ch in s:
87
- h = (h * 31 + ord(ch)) & 0x7FFFFFFF
88
- return h
89
-
90
-
91
- def _clock(minute: int) -> str:
92
- h = (minute // 60) % 24
93
- m = minute % 60
94
- suffix = "AM" if h < 12 else "PM"
95
- h12 = h % 12 or 12
96
- return f"{h12}:{m:02d} {suffix}"
97
-
98
-
99
- def _loc_name(case: CaseFile, loc_id: str) -> str:
100
- for loc in case.setting.locations:
101
- if loc.loc_id == loc_id:
102
- return loc.name
103
- return loc_id
104
-
105
-
106
- def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
107
- s = case.suspects[idx]
108
- seed = _hash(s.sus_id)
109
- role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
110
- tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
111
- quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
112
- gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
113
- return PublicSuspect(
114
- id=s.sus_id,
115
- name=s.name,
116
- role=s.role,
117
- age=30 + (seed % 35),
118
- sprite=s.sus_id,
119
- gender=gender,
120
- tag=tag[:22],
121
- baseline_suspicion=25 + (seed % 16),
122
- motive=_APPARENT[idx % len(_APPARENT)],
123
- alibi=s.stated_alibi.claim_text,
124
- quote=quote,
125
- greet=_GREETS[idx % len(_GREETS)],
126
- suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
127
- )
128
-
129
-
130
- def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
131
- at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
132
- time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
133
- return PublicEvidence(
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,
141
- detail=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))
149
- culprit = case.culprit.sus_id
150
- for i, clue in enumerate(case.clues):
151
- at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
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
-
163
-
164
- def _flashback(case: CaseFile) -> PublicFlashback:
165
- culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
166
- claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
167
- crime = _loc_name(case, case.victim.found_at_loc_id)
168
- breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
169
- return PublicFlashback(
170
- title="TWO ACCOUNTS",
171
- a=FlashbackAccount(
172
- who=f"{culprit.name} SAYS",
173
- scene=claimed,
174
- lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
175
- flags=(),
176
- ),
177
- b=FlashbackAccount(
178
- who="THE EVIDENCE SAYS",
179
- scene=crime,
180
- lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
181
- flags=tuple(range(len(breakers))),
182
- ),
183
- )
184
-
185
-
186
- def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
187
- true_text = case.culprit.true_motive.summary
188
- decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
189
- chosen = [PublicMotive(id="M1", text=true_text)]
190
- for i in range(3):
191
- chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
192
- # stable shuffle by seed so the true option isn't always first
193
- rot = seed % len(chosen)
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)
224
- scene = f"{building} {room}" # building + room; the painter keys off the room
225
- city = _CITIES[seed % len(_CITIES)]
226
- return PublicCase(
227
- id=case.case_id,
228
- city=city,
229
- district=building,
230
- title=case.title,
231
- tagline="Detective, your presence is required.",
232
- weather=_WEATHER[seed % len(_WEATHER)],
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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
2
+ golden case uses). Deterministic: it synthesizes the display surface from the case's
3
+ own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
4
+ is exposed; the true motive's *text* appears only as one option among the decoys.
5
+
6
+ Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
7
+ (threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ 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 (
18
+ FlashbackAccount,
19
+ PublicCase,
20
+ PublicEvidence,
21
+ PublicFlashback,
22
+ PublicMotive,
23
+ PublicSuspect,
24
+ PublicVictim,
25
+ StoryBeat,
26
+ SuggestedQuestion,
27
+ TimelineBeat,
28
+ )
29
+ from .questions import FIXED_QUESTIONS
30
+
31
+ _SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
32
+ _WEATHER = (
33
+ "Rain, and a wind off the water.", "Fog thick enough to lean on.",
34
+ "A dry cold that gets into the joints.", "Sleet against every window.",
35
+ )
36
+ _GREETS = (
37
+ "Detective. Let's get this over with.",
38
+ "I already told the others everything I know.",
39
+ "Ask what you came to ask.",
40
+ "I have nothing to hide, if that's what you think.",
41
+ )
42
+ _APPARENT = (
43
+ "Was close enough to the victim to have a reason - and a chance.",
44
+ "Had more to lose tonight than they're letting on.",
45
+ "Their story has a seam in it, if you pull the right thread.",
46
+ "Stood to gain from a death no one saw coming.",
47
+ )
48
+ _DECOY_MOTIVES = (
49
+ "To bury a debt that was about to surface.",
50
+ "Jealousy that had festered for years.",
51
+ "To keep a buried secret buried.",
52
+ "To settle an old score, finally.",
53
+ "Fear of being exposed and ruined.",
54
+ )
55
+ # Keyword-matched icons so a letter looks like an envelope, a tumbler like a glass and a
56
+ # bloodied letter opener like a blade - never a phone icon on a fingerprint. First match
57
+ # wins, so the order is deliberate: specific objects (knife before "letter", footprints
58
+ # before "prints", keycard before "key") come before the generic paper/photo buckets.
59
+ _ICON_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
60
+ (re.compile(r"cctv|camera|footage|surveillance|still\b", re.I), "cctv"),
61
+ (re.compile(r"photo|polaroid|snapshot|portrait|negative|film\b", re.I), "photoEv"),
62
+ (re.compile(r"knife|blade|dagger|razor|scissors?|shears|opener|stilett?o", re.I), "knife"),
63
+ (re.compile(r"voicemail|recording|tape\b|audio|cylinder|dictaphone|phonograph", re.I), "voicemail"),
64
+ (re.compile(r"phone|telephone|message|\bwire\b|\bcall\b|\btext\b", re.I), "phone"),
65
+ (re.compile(r"keycard|key card|access|badge|\bpass\b|swipe", re.I), "keycard"),
66
+ (re.compile(r"\bkey\b|\bkeys\b|keychain|keyring|latchkey|\block\b|padlock", re.I), "key"),
67
+ (re.compile(r"footprint|bootprint|\bboot\b|shoe|\bsole\b|tread|scuff|heel", re.I), "bootprint"),
68
+ (re.compile(r"fingerprint|thumbprint|handprint|prints?\b", re.I), "fingerprint"),
69
+ (re.compile(r"tumbler|glass\b|goblet|wine|whisky|whiskey|brandy|champagne|cocktail", re.I), "tumbler"),
70
+ (re.compile(r"bottle|vial|flask|perfume|poison|decanter|tonic|arsenic|cyanide|sedative", re.I), "bottle"),
71
+ (re.compile(r"match(es|box|book)?\b|lighter|accelerant|gasoline|petrol|kerosene|"
72
+ r"\bfuel\b|scorch|charred|\bburn(t|ed)?\b|ember|\bash\b|flame", re.I), "flame"),
73
+ (re.compile(r"cigar|cigarette|tobacco|ashtray", re.I), "cigarette"),
74
+ (re.compile(r"clock|\bwatch\b|timepiece|hourglass|pendulum", re.I), "clock"),
75
+ (re.compile(r"cufflink|jewell?|diamond|brooch|necklace|pendant|earring|locket|"
76
+ r"bracelet|\bgem\b|\bring\b|tiara|pearl|sapphire|ruby|emerald", re.I), "jewel"),
77
+ (re.compile(r"thread|fib(er|re)|cloth\b|fabric|handkerchief|scarf|glove|wool|silk|"
78
+ r"snag|button\b|lace\b", re.I), "thread"),
79
+ (re.compile(r"\bpen\b|\bnib\b|quill|fountain pen|inkwell|\bink\b", re.I), "pen"),
80
+ (re.compile(r"letter\b|envelope|telegram|postcard|\bnote\b|correspondence|\bmemo\b", re.I), "envelope"),
81
+ (re.compile(r"\bbook\b|journal|diary|ledger|notebook|manuscript|novel", re.I), "book"),
82
+ (re.compile(r"\bcash\b|banknote|money|wallet|billfold|currency|\bcoins?\b|bribe|payout", re.I), "cash"),
83
+ (re.compile(r"magnif|\blens\b|loupe", re.I), "magnifier"),
84
+ (re.compile(r"receipt|ticket|\bstub\b|paper|document|contract|deed|cheque|\bcheck\b|"
85
+ r"\bbill\b|pawn|invoice|agreement|statement|certificate|affidavit|\bwill\b|"
86
+ r"\bfile\b|\bform\b|register", re.I), "receipt"),
87
+ (re.compile(r"\bmap\b|compass|route|itinerary|timetable|schedule", re.I), "compass"),
88
+ )
89
+ # No keyword hit: fall back to how the clue is FOUND, which is always known - forensics
90
+ # read as a print, documents as paper, searches as a scene photo, testimony as a voice.
91
+ _METHOD_ICONS = {
92
+ "forensic": "fingerprint",
93
+ "document": "receipt",
94
+ "search": "photoEv",
95
+ "interrogation": "voicemail",
96
+ }
97
+ _ICONS = ("photoEv", "receipt", "compass", "envelope", "fingerprint")
98
+
99
+
100
+ def _icon_for(name: str, reveal: str, idx: int, method: str = "") -> str:
101
+ # The NAME names the object itself, so it decides first; the reveal text only breaks
102
+ # a tie when the name says nothing (it describes context and loves false positives -
103
+ # a fingerprint whose reveal mentions "the blade" is still a fingerprint).
104
+ for hay in (name, reveal):
105
+ for rule, icon in _ICON_RULES:
106
+ if rule.search(hay):
107
+ return icon
108
+ return _METHOD_ICONS.get(method, _ICONS[idx % len(_ICONS)])
109
+ # Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
110
+ _CITIES = (
111
+ "Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
112
+ "Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
113
+ "Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
114
+ )
115
+
116
+
117
+ def _hash(s: str) -> int:
118
+ h = 0
119
+ for ch in s:
120
+ h = (h * 31 + ord(ch)) & 0x7FFFFFFF
121
+ return h
122
+
123
+
124
+ def _clock(minute: int) -> str:
125
+ h = (minute // 60) % 24
126
+ m = minute % 60
127
+ suffix = "AM" if h < 12 else "PM"
128
+ h12 = h % 12 or 12
129
+ return f"{h12}:{m:02d} {suffix}"
130
+
131
+
132
+ def _loc_name(case: CaseFile, loc_id: str) -> str:
133
+ for loc in case.setting.locations:
134
+ if loc.loc_id == loc_id:
135
+ return loc.name
136
+ return loc_id
137
+
138
+
139
+ def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
140
+ s = case.suspects[idx]
141
+ seed = _hash(s.sus_id)
142
+ role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
143
+ tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
144
+ quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
145
+ gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
146
+ return PublicSuspect(
147
+ id=s.sus_id,
148
+ name=s.name,
149
+ role=s.role,
150
+ age=30 + (seed % 35),
151
+ sprite=s.sus_id,
152
+ gender=gender,
153
+ tag=tag[:22],
154
+ baseline_suspicion=25 + (seed % 16),
155
+ motive=_APPARENT[idx % len(_APPARENT)],
156
+ alibi=s.stated_alibi.claim_text,
157
+ quote=quote,
158
+ greet=_GREETS[idx % len(_GREETS)],
159
+ suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
160
+ )
161
+
162
+
163
+ def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
164
+ at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
165
+ time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
166
+ return PublicEvidence(
167
+ id=clue.clue_id,
168
+ name=clue.name.upper(),
169
+ type=clue.discovery_method.value.upper(),
170
+ icon=_icon_for(clue.name, clue.reveal_text, idx, clue.discovery_method.value),
171
+ time=time,
172
+ found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
173
+ summary=clue.reveal_text,
174
+ detail=clue.reveal_text,
175
+ )
176
+
177
+
178
+ def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
179
+ beats: list[TimelineBeat] = []
180
+ w = case.setting.murder_window
181
+ beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
182
+ culprit = case.culprit.sus_id
183
+ for i, clue in enumerate(case.clues):
184
+ at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
185
+ t = at if at is not None else w.start_min + 7 * (i + 1)
186
+ conflict = clue.contradicts_alibi_of == culprit
187
+ beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
188
+ incident = profile.timeline_line.format(
189
+ name=case.victim.name, instrument=case.weapon.name,
190
+ room=_loc_name(case, case.victim.found_at_loc_id),
191
+ )
192
+ beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=incident, locked=True))
193
+ beats.sort(key=lambda b: b.time)
194
+ return tuple(beats)
195
+
196
+
197
+ def _flashback(case: CaseFile) -> PublicFlashback:
198
+ culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
199
+ claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
200
+ crime = _loc_name(case, case.victim.found_at_loc_id)
201
+ breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
202
+ return PublicFlashback(
203
+ title="TWO ACCOUNTS",
204
+ a=FlashbackAccount(
205
+ who=f"{culprit.name} SAYS",
206
+ scene=claimed,
207
+ lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
208
+ flags=(),
209
+ ),
210
+ b=FlashbackAccount(
211
+ who="THE EVIDENCE SAYS",
212
+ scene=crime,
213
+ lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
214
+ flags=tuple(range(len(breakers))),
215
+ ),
216
+ )
217
+
218
+
219
+ def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
220
+ true_text = case.culprit.true_motive.summary
221
+ decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
222
+ chosen = [PublicMotive(id="M1", text=true_text)]
223
+ for i in range(3):
224
+ chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
225
+ # stable shuffle by seed so the true option isn't always first
226
+ rot = seed % len(chosen)
227
+ return tuple(chosen[rot:] + chosen[:rot])
228
+
229
+
230
+ def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[StoryBeat, ...]:
231
+ """Each beat's backdrop is the place its text describes: the city for the call, the
232
+ REAL crime scene for the incident, another room of the same building for the cast,
233
+ and the detective's desk for the hand-off."""
234
+ v = case.victim
235
+ crime_loc = case.victim.found_at_loc_id
236
+ building = case.setting.name
237
+ other_room = next(
238
+ (loc.name for loc in case.setting.locations if loc.loc_id != crime_loc),
239
+ _loc_name(case, crime_loc),
240
+ )
241
+ return (
242
+ StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
243
+ StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
244
+ StoryBeat(scene=f"{building} — {other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
245
+ text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
246
+ StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
247
+ text="One of them is lying to your face. Find the crack in the account and follow it down."),
248
+ )
249
+
250
+
251
+ def casefile_to_public(case: CaseFile) -> PublicCase:
252
+ seed = case.seed
253
+ profile = profile_for(case.crime_kind)
254
+ tod = case.victim.time_of_death.start_min
255
+ building = case.setting.name
256
+ room = _loc_name(case, case.victim.found_at_loc_id)
257
+ scene = f"{building} — {room}" # building + room; the painter keys off the room
258
+ city = _CITIES[seed % len(_CITIES)]
259
+ return PublicCase(
260
+ id=case.case_id,
261
+ city=city,
262
+ district=building,
263
+ title=case.title,
264
+ tagline="Detective, your presence is required.",
265
+ weather=_WEATHER[seed % len(_WEATHER)],
266
+ victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
267
+ scene=scene,
268
+ tod=_clock(tod),
269
+ found=f"{profile.found_verb} {scene} at {_clock(case.victim.found_at_min)}.",
270
+ cause=case.victim.cause_of_death,
271
+ kind=profile.kind.value,
272
+ kind_label=profile.kind_label,
273
+ division=profile.division,
274
+ victim_status=profile.victim_status,
275
+ tod_label=profile.tod_label,
276
+ verdict=profile.verdict,
277
+ facts=(
278
+ ("CITY", city),
279
+ ("VICTIM", f"{case.victim.name}"),
280
+ ("SCENE", scene),
281
+ (profile.tod_label, _clock(tod)),
282
+ ("WHAT HAPPENED", case.victim.cause_of_death),
283
+ ("VERDICT", profile.verdict),
284
+ ),
285
+ boot_lines=(
286
+ "The phone drags you up out of half a sleep.",
287
+ f"{case.setting.name}. {profile.boot_line}",
288
+ f"{case.victim.name} - {case.victim.role}.",
289
+ "They're holding the scene. It's yours now, detective.",
290
+ ),
291
+ story_beats=_story_beats(case, profile, scene),
292
+ suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
293
+ evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
294
+ timeline=_timeline(case, profile),
295
+ flashback=_flashback(case),
296
+ motives=_motives(case, seed),
297
+ )
web/src/engine/art.ts CHANGED
@@ -290,6 +290,22 @@ export const EV_ICONS: Record<string, string[]> = {
290
  keycard: ['............', '.kkkkkkkkkk.', '.kssssssssk.', '.ksbbbbbbsk.', '.ksbaaaabsk.', '.ksbaWWabsk.', '.ksbaaaabsk.', '.ksbbbbbbsk.', '.ksssssssk..', '.kswwwwsssk.', '.kswwwwsssk.', '.kssssssssk.', '.kkkkkkkkkk.', '......kk....', '.....kgggk..', '............'],
291
  photoEv: ['.wwwwwwwwww.', '.wkkkkkkkkw.', '.wkdddddd kw', '.wkdaa..d.kw', '.wkd.aa..dkw', '.wkd..aa.dkw', '.wkd...aadkw', '.wkdGG..adkw', '.wkdddddddkw', '.wkdGGGGGdkw', '.wkddddddd kw', '.wkkkkkkkkw.', '.wwwwwwwwww.', '...ka.ak....', '..k.aa.k....', '............'],
292
  compass: ['......a.....', '.....aAa....', '....a.a.a...', '...a..a..a..', '..a...a...a.', '.a....a....a', 'a....aAa... a', '.aaaaaAaaaaa', 'a....aAa....a', '.a...a.a...a.', '..a..a.a..a..', '...a.a.a.a...', '....aa.aa....', '.....aAa.....', '......a......', '............'],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  }
294
 
295
  // ---- scene painters ----
 
290
  keycard: ['............', '.kkkkkkkkkk.', '.kssssssssk.', '.ksbbbbbbsk.', '.ksbaaaabsk.', '.ksbaWWabsk.', '.ksbaaaabsk.', '.ksbbbbbbsk.', '.ksssssssk..', '.kswwwwsssk.', '.kswwwwsssk.', '.kssssssssk.', '.kkkkkkkkkk.', '......kk....', '.....kgggk..', '............'],
291
  photoEv: ['.wwwwwwwwww.', '.wkkkkkkkkw.', '.wkdddddd kw', '.wkdaa..d.kw', '.wkd.aa..dkw', '.wkd..aa.dkw', '.wkd...aadkw', '.wkdGG..adkw', '.wkdddddddkw', '.wkdGGGGGdkw', '.wkddddddd kw', '.wkkkkkkkkw.', '.wwwwwwwwww.', '...ka.ak....', '..k.aa.k....', '............'],
292
  compass: ['......a.....', '.....aAa....', '....a.a.a...', '...a..a..a..', '..a...a...a.', '.a....a....a', 'a....aAa... a', '.aaaaaAaaaaa', 'a....aAa....a', '.a...a.a...a.', '..a..a.a..a..', '...a.a.a.a...', '....aa.aa....', '.....aAa.....', '......a......', '............'],
293
+ fingerprint: ['............', '...kkkkkk...', '..kGGGGGGk..', '.kGkkkkkkGk.', '.kGkGGGGkGk.', '.kGkGkkGkGk.', '.kGkGkGkkGk.', '.kGkGkGkGGk.', '.kGkGkkGkGk.', '.kGkGGGGkGk.', '.kGkkkkkkGk.', '..kGGGGGGk..', '...kkkkkk...', '............', '............', '............'],
294
+ tumbler: ['............', '.kkkkkkkkkk.', '.kwWWWWWWwk.', '.kw......wk.', '.kw....xxwk.', '.kw...x..wk.', '.kw......wk.', '.kwAAAAAAwk.', '.kwaaaaaawk.', '.kwaaaaaawk.', '.kwaaaaaawk.', '.kwwwwwwwwk.', '.kkkkkkkkkk.', '............', '............', '............'],
295
+ thread: ['............', '..kkkkkkkk..', '.kmmmmmmmmk.', '..kkkkkkkk..', '..kGgGgGgk..', '..kgGgGgGk..', '..kGgGgGgk..', '..kgGgGgGk..', '..kGgGgGgk..', '..kkkkkkkk..', '.kmmmmmmmmk.', '..kkkkkkkk..', '.......G....', '......G.....', '.......G....', '............'],
296
+ envelope: ['............', '............', '.kkkkkkkkkk.', '.kWkWWWWkWk.', '.kWWkWWkWWk.', '.kWWWkkWWWk.', '.kWWWWWWWWk.', '.kWWWxxWWWk.', '.kWWWxxWWWk.', '.kWWWWWWWWk.', '.kkkkkkkkkk.', '............', '............', '............', '............', '............'],
297
+ bottle: ['....kkkk....', '....kmmk....', '....kwwk....', '....kwwk....', '...kkwwkk...', '..kwggggwk..', '.kwggggggwk.', '.kwgWWWWgwk.', '.kwgWxxWgwk.', '.kwgWWWWgwk.', '.kwggggggwk.', '.kwggggggwk.', '..kwggggwk..', '...kkkkkk...', '............', '............'],
298
+ knife: ['.....kk.....', '....kWWk....', '....kWwk....', '....kWwk....', '....kWwk....', '....kWwk....', '....kWwk....', '....kWwk....', '...kkkkkk...', '..kmmmmmmk..', '...kkddkk...', '....kddk....', '....kddk....', '....kddk....', '....kkkk....', '............'],
299
+ clock: ['.....kk.....', '....kaak....', '..kkkkkkkk..', '.kaWWWWWWak.', 'kaWWWkWWWWak', 'kaWWWkWWWWak', 'kaWWWkkkWWak', 'kaWWWWWWWWak', 'kaWWWWWWWWak', '.kaWWWWWWak.', '..kkkkkkkk..', '............', '............', '............', '............', '............'],
300
+ jewel: ['............', '.....kk.....', '....kGGk....', '...kGWWGk...', '...kGGGGk...', '....kkkk....', '...kaaaak...', '..kak..kak..', '..ka....ak..', '..kak..kak..', '...kaaaak...', '....kkkk....', '............', '............', '............', '............'],
301
+ cigarette: ['.......g....', '......g.....', '.......g....', '......g.....', '.......g....', '............', '.kkkkkkkkkk.', '.kmmWWWWaAk.', '.kmmWWWWaxk.', '.kkkkkkkkkk.', '............', '............', '............', '............', '............', '............'],
302
+ bootprint: ['............', '...kkkkk....', '..kdddddk...', '..kdkdkdk...', '..kdddddk...', '..kdkdkdk...', '..kdddddk...', '...kkkkk....', '............', '...kkkk.....', '..kddddk....', '..kdkkdk....', '..kddddk....', '...kkkk.....', '............', '............'],
303
+ book: ['............', '..kkkkkkkk..', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexaaaaxWk.', '.kexaaaaxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '..kkkkkkkk..', '............', '............', '............', '............'],
304
+ key: ['............', '...kkkk.....', '..ka..ak....', '..ka..ak....', '...kaak.....', '....kak.....', '....kak.....', '....kak.....', '....kak.....', '....kaak....', '....kak.....', '....kaak....', '....kkk.....', '............', '............', '............'],
305
+ flame: ['....kaak....', '...kaAAak...', '...kaAAak...', '...kaAAak...', '....kaak....', '.....xx.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....kk.....', '............', '............', '............'],
306
+ pen: ['....kkkk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kaak....', '....kWWk....', '....kWWk....', '.....kWk....', '.....kk.....', '............', '............', '............', '............'],
307
+ cash: ['............', '............', '.kkkkkkkkkk.', '.kgaggggggk.', '.kgagGGgggk.', '.kgagGGgggk.', '.kgaggggggk.', '.kkkkkkkkkk.', '..kggggggk..', '..kkkkkkkk..', '............', '............', '............', '............', '............', '............'],
308
+ magnifier: ['...kkkk.....', '..kGGGGk....', '.kGWGGGGk...', '.kGGGGGGk...', '.kGGGGGGk...', '..kGGGGk....', '...kkkkk....', '......kkk...', '.......kmk..', '........kmk.', '.........kk.', '............', '............', '............', '............', '............'],
309
  }
310
 
311
  // ---- scene painters ----