Spaces:
Running
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>
- COMPLIANCE.md +108 -105
- src/case_zero/api/case_adapter.py +297 -264
- web/src/engine/art.ts +16 -0
|
@@ -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)** -
|
| 52 |
-
Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
- [x]
|
| 103 |
-
- [x]
|
| 104 |
-
- [
|
| 105 |
-
- [
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
| 56 |
-
# never a phone icon on a
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
(re.compile(r"
|
| 61 |
-
(re.compile(r"
|
| 62 |
-
(re.compile(r"
|
| 63 |
-
(re.compile(r"
|
| 64 |
-
|
| 65 |
-
(re.compile(r"
|
| 66 |
-
)
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
)
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
return tuple(
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 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 |
+
)
|
|
@@ -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 ----
|