Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds

#4
by aliabdelwahab - opened
COMPLIANCE.md CHANGED
@@ -72,7 +72,8 @@ Every model is open-weights and self-run. **No third-party AI service is ever ca
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
 
72
 
73
  ## Content scope
74
 
75
+ Cases span **homicide, theft, fraud, blackmail, arson, missing-person, confidence-game,
76
+ poisoning, ransom, and sabotage** mysteries.
77
  Generation is structurally constrained (case-file language, physical evidence, no graphic
78
  description) and a deterministic scrubber sanitizes model output. Sexual violence is
79
  deliberately **not** a case type, keeping the Space comfortably inside the
scripts/check_exhibits.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read-only check: project every prebaked case to its public view and report which
2
+ rich payload each clue gets. Fails (exit 1) if any case ends up all-paper."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from collections import Counter
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ from case_zero.api.case_adapter import casefile_to_public # noqa: E402
13
+ from case_zero.persistence.case_store import load_case # noqa: E402
14
+ from case_zero.persistence.paths import prebaked_cases_dir # noqa: E402
15
+
16
+
17
+ def payload_kind(ev) -> str:
18
+ if ev.thread:
19
+ return "thread"
20
+ if ev.transcript:
21
+ return "voicemail"
22
+ if ev.rows:
23
+ return "keycard"
24
+ if ev.type == "IMAGE":
25
+ return "cctv" if ev.icon == "cctv" else "photo"
26
+ return "paper"
27
+
28
+
29
+ def main() -> int:
30
+ hist: Counter[str] = Counter()
31
+ all_paper: list[str] = []
32
+ for path in sorted(prebaked_cases_dir().glob("*.json")):
33
+ case = load_case(path)
34
+ public = casefile_to_public(case)
35
+ kinds = []
36
+ for ev in public.evidence:
37
+ kind = payload_kind(ev)
38
+ kinds.append(kind)
39
+ hist[kind] += 1
40
+ print(f"{case.case_id} | {ev.id:10s} | {ev.icon:12s} | {kind:9s} | {ev.name[:52]}")
41
+ if all(k == "paper" for k in kinds):
42
+ all_paper.append(case.case_id)
43
+ print("\n--- payload histogram:")
44
+ for kind, n in hist.most_common():
45
+ print(f"{kind:9s} {n}")
46
+ if all_paper:
47
+ print(f"\nFAIL: all-paper cases: {all_paper}")
48
+ return 1
49
+ print("\nOK: every case has at least one rich exhibit")
50
+ return 0
51
+
52
+
53
+ if __name__ == "__main__":
54
+ raise SystemExit(main())
scripts/prebake_cases.py CHANGED
@@ -1,161 +1,163 @@
1
- """Pre-bake a pool of full, model-authored cases for instant New Case serving.
2
-
3
- Generation on a 2-vCPU Space takes ~1-2 minutes, so the player would otherwise stare at a
4
- loading screen. This script runs the SAME in-process llama.cpp generator offline, keeps only
5
- solvable, well-formed, "exciting" cases (distinct human suspects, a real motive, no
6
- detective/officer suspects, a gender mix), assigns each a stable Case ID, and writes the full
7
- sealed CaseFile JSON to ``cases/prebaked/``. The Space ships these and serves one instantly on
8
- New Case while still running every interrogation live (and generating fresh cases when the
9
- hardware allows). The pre-baked cases are authored by the local model - no cloud, still
10
- Off-the-Grid.
11
-
12
- 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
-
18
- from __future__ import annotations
19
-
20
- import re
21
- import sys
22
- from pathlib import Path
23
-
24
- ROOT = Path(__file__).resolve().parent.parent
25
- sys.path.insert(0, str(ROOT / "src"))
26
-
27
- from case_zero.config import get_settings # noqa: E402
28
- from case_zero.generator.pipeline import generate_case # noqa: E402
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",
55
- re.IGNORECASE,
56
- )
57
- # Filler names a small model reaches for - they read as obviously fake and kill the mood.
58
- _PLACEHOLDER_NAMES = {
59
- "john doe", "jane doe", "john smith", "jane smith", "joe bloggs", "richard roe",
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:
79
- return " ".join(n.lower().replace(",", " ").split()[:2])
80
-
81
-
82
- def _is_exciting(case: CaseFile) -> tuple[bool, str]:
83
- """Reject bland or malformed cases; keep ones that will read well to a judge."""
84
- title = (case.title or "").strip()
85
- if len(title) < 5:
86
- return False, "weak title"
87
- vname = case.victim.name.strip()
88
- if not vname or " " not in vname or _name_malformed(vname):
89
- return False, f"victim needs a clean full name: '{vname}'"
90
- names = [s.name.strip() for s in case.suspects]
91
- low = [n.lower() for n in names]
92
- if len(set(low)) != len(names):
93
- return False, "duplicate suspect names"
94
- if any(len(n) < 3 or " " not in n for n in names):
95
- return False, "suspect needs a full name"
96
- if any(_name_malformed(n) for n in names):
97
- return False, f"malformed name (comma/digit/gender): {names}"
98
- if any(_name_prefix(n) in _PLACEHOLDER_NAMES for n in names):
99
- return False, f"placeholder name: {names}"
100
- roles = [s.role.strip().lower() for s in case.suspects]
101
- if len(set(roles)) < len(roles):
102
- return False, "duplicate suspect roles"
103
- for s in case.suspects:
104
- if _BAD_ROLE.search(s.role) or _BAD_ROLE.search(s.name):
105
- return False, f"detective-like suspect: {s.name} ({s.role})"
106
- if not any((s.visual.gender or "").lower().startswith("f") for s in case.suspects):
107
- return False, "no female suspect"
108
- if not any((s.visual.gender or "").lower().startswith("m") for s in case.suspects):
109
- return False, "no male suspect"
110
- # A real culprit with a written motive and method makes the mystery land.
111
- if not (case.culprit.method_narrative or "").strip():
112
- return False, "no method narrative"
113
- return True, "ok"
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
139
- continue
140
- seed += 1
141
- if not result.report.ok:
142
- print(f"[seed {seed - 1}] unsolvable, skipped")
143
- continue
144
- ok, why = _is_exciting(result.case)
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
158
-
159
-
160
- if __name__ == "__main__":
161
- raise SystemExit(main())
 
 
 
1
+ """Pre-bake a pool of full, model-authored cases for instant New Case serving.
2
+
3
+ Generation on a 2-vCPU Space takes ~1-2 minutes, so the player would otherwise stare at a
4
+ loading screen. This script runs the SAME in-process llama.cpp generator offline, keeps only
5
+ solvable, well-formed, "exciting" cases (distinct human suspects, a real motive, no
6
+ detective/officer suspects, a gender mix), assigns each a stable Case ID, and writes the full
7
+ sealed CaseFile JSON to ``cases/prebaked/``. The Space ships these and serves one instantly on
8
+ New Case while still running every interrogation live (and generating fresh cases when the
9
+ hardware allows). The pre-baked cases are authored by the local model - no cloud, still
10
+ Off-the-Grid.
11
+
12
+ 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
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ sys.path.insert(0, str(ROOT / "src"))
26
+
27
+ from case_zero.config import get_settings # noqa: E402
28
+ from case_zero.generator.pipeline import generate_case # noqa: E402
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
+ # (the four newest kinds open the plan so the next bake adds them immediately).
37
+ _KIND_PLAN: tuple[CrimeKind, ...] = (
38
+ CrimeKind.CON, CrimeKind.POISONING, CrimeKind.RANSOM, CrimeKind.SABOTAGE,
39
+ CrimeKind.THEFT, CrimeKind.BLACKMAIL, CrimeKind.ARSON, CrimeKind.MISSING,
40
+ CrimeKind.FRAUD, CrimeKind.CON, CrimeKind.POISONING, CrimeKind.SABOTAGE,
41
+ CrimeKind.RANSOM, CrimeKind.HOMICIDE, CrimeKind.THEFT, CrimeKind.FRAUD,
42
+ )
43
+
44
+ _SMALL = {"a", "an", "and", "at", "but", "by", "for", "in", "of", "on", "or", "the", "to"}
45
+
46
+
47
+ def _titlecase(raw: str) -> str:
48
+ words = (raw or "").strip().split()
49
+ out = []
50
+ for i, w in enumerate(words):
51
+ lw = w.lower()
52
+ out.append(lw if (i not in (0, len(words) - 1) and lw in _SMALL) else lw.capitalize())
53
+ return " ".join(out)
54
+
55
+ _BAD_ROLE = re.compile(
56
+ r"\b(detective|officer|investigator|police|inspector|sergeant|constable|cop|agent)\b",
57
+ re.IGNORECASE,
58
+ )
59
+ # Filler names a small model reaches for - they read as obviously fake and kill the mood.
60
+ _PLACEHOLDER_NAMES = {
61
+ "john doe", "jane doe", "john smith", "jane smith", "joe bloggs", "richard roe",
62
+ "mary major", "john q public", "tom johnson", "tom smith", "jack smith", "jane roe",
63
+ "john brown", "bob smith", "foo bar", "first last", "name surname",
64
+ }
65
+ # A "name" that is really a role description ("Rival Curator", "Business Partner").
66
+ _ROLE_AS_NAME = re.compile(
67
+ r"\b(rival|partner|business|curator|servant|butler|maid|cousin|nephew|niece|heir|"
68
+ r"the\s|guest|stranger|visitor|neighbou?r|colleague|assistant|clerk|owner|manager)\b",
69
+ re.IGNORECASE,
70
+ )
71
+
72
+
73
+ def _name_malformed(n: str) -> bool:
74
+ # The model sometimes bakes a gender/age/label into the name: "John Smith, Male",
75
+ # "Lara White, 45" - or hands back a role instead of a name ("Rival Curator").
76
+ return bool("," in n or any(c.isdigit() for c in n)
77
+ or re.search(r"\b(male|female)\b", n, re.I) or _ROLE_AS_NAME.search(n))
78
+
79
+
80
+ def _name_prefix(n: str) -> str:
81
+ return " ".join(n.lower().replace(",", " ").split()[:2])
82
+
83
+
84
+ def _is_exciting(case: CaseFile) -> tuple[bool, str]:
85
+ """Reject bland or malformed cases; keep ones that will read well to a judge."""
86
+ title = (case.title or "").strip()
87
+ if len(title) < 5:
88
+ return False, "weak title"
89
+ vname = case.victim.name.strip()
90
+ if not vname or " " not in vname or _name_malformed(vname):
91
+ return False, f"victim needs a clean full name: '{vname}'"
92
+ names = [s.name.strip() for s in case.suspects]
93
+ low = [n.lower() for n in names]
94
+ if len(set(low)) != len(names):
95
+ return False, "duplicate suspect names"
96
+ if any(len(n) < 3 or " " not in n for n in names):
97
+ return False, "suspect needs a full name"
98
+ if any(_name_malformed(n) for n in names):
99
+ return False, f"malformed name (comma/digit/gender): {names}"
100
+ if any(_name_prefix(n) in _PLACEHOLDER_NAMES for n in names):
101
+ return False, f"placeholder name: {names}"
102
+ roles = [s.role.strip().lower() for s in case.suspects]
103
+ if len(set(roles)) < len(roles):
104
+ return False, "duplicate suspect roles"
105
+ for s in case.suspects:
106
+ if _BAD_ROLE.search(s.role) or _BAD_ROLE.search(s.name):
107
+ return False, f"detective-like suspect: {s.name} ({s.role})"
108
+ if not any((s.visual.gender or "").lower().startswith("f") for s in case.suspects):
109
+ return False, "no female suspect"
110
+ if not any((s.visual.gender or "").lower().startswith("m") for s in case.suspects):
111
+ return False, "no male suspect"
112
+ # A real culprit with a written motive and method makes the mystery land.
113
+ if not (case.culprit.method_narrative or "").strip():
114
+ return False, "no method narrative"
115
+ return True, "ok"
116
+
117
+
118
+ def main() -> int:
119
+ target = int(sys.argv[1]) if len(sys.argv) > 1 else 8
120
+ start_seed = int(sys.argv[2]) if len(sys.argv) > 2 else 51000
121
+ max_attempts = target * 4 + 8
122
+
123
+ backend = make_backend(get_settings())
124
+ out_dir = prebaked_cases_dir()
125
+ out_dir.mkdir(parents=True, exist_ok=True)
126
+ existing = len(list(out_dir.glob("CASE-*.json")))
127
+ print(f"pool has {existing} cases; appending {target} new ones across crime kinds")
128
+
129
+ kept: list[CaseFile] = []
130
+ seed = start_seed
131
+ attempts = 0
132
+ while len(kept) < target and attempts < max_attempts:
133
+ attempts += 1
134
+ kind = _KIND_PLAN[len(kept) % len(_KIND_PLAN)]
135
+ try:
136
+ result = generate_case(backend, seed=seed,
137
+ knobs=GenerationKnobs(crime_kind=kind))
138
+ except Exception as exc: # generation hiccup - skip this seed
139
+ print(f"[seed {seed}] generation error: {exc}")
140
+ seed += 1
141
+ continue
142
+ seed += 1
143
+ if not result.report.ok:
144
+ print(f"[seed {seed - 1}] unsolvable, skipped")
145
+ continue
146
+ ok, why = _is_exciting(result.case)
147
+ if not ok:
148
+ print(f"[seed {seed - 1}] rejected: {why} -- '{result.case.title}'")
149
+ continue
150
+ case_id = f"CASE-{existing + len(kept) + 1:04d}"
151
+ case = result.case.model_copy(update={"case_id": case_id,
152
+ "title": _titlecase(result.case.title)})
153
+ save_case(case, out_dir / f"{case_id}.json")
154
+ kept.append(case)
155
+ cast = ", ".join(f"{s.name} ({s.visual.gender[:1].upper()})" for s in case.suspects)
156
+ print(f"[KEEP {case_id}] ({kind.value}) '{case.title}' - victim {case.victim.name} | {cast}")
157
+
158
+ print(f"\nDONE: kept {len(kept)}/{target} in {attempts} attempts -> {out_dir}")
159
+ return 0 if kept else 1
160
+
161
+
162
+ if __name__ == "__main__":
163
+ raise SystemExit(main())
src/case_zero/api/case_adapter.py CHANGED
@@ -14,6 +14,7 @@ import re
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,
@@ -148,8 +149,11 @@ def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
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)],
@@ -162,17 +166,23 @@ def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
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, ...]:
@@ -239,8 +249,10 @@ def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[Sto
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",
 
14
  from ..generator.crime_profiles import CrimeProfile, profile_for
15
  from ..schemas.case import CaseFile
16
  from ..schemas.clue import Clue
17
+ from .exhibits import synthesize_payload
18
  from .public_view import (
19
  FlashbackAccount,
20
  PublicCase,
 
149
  name=s.name,
150
  role=s.role,
151
  age=30 + (seed % 35),
152
+ # Salted with the case id so S1 in one case is a different face than S1 in
153
+ # another (the client hashes the whole sprite string into its portrait pools).
154
+ sprite=f"{case.case_id}:{s.sus_id}",
155
  gender=gender,
156
+ accent_color=(s.visual.accent_color if s.visual else "") or "",
157
  tag=tag[:22],
158
  baseline_suspicion=25 + (seed % 16),
159
  motive=_APPARENT[idx % len(_APPARENT)],
 
166
 
167
  def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
168
  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)
169
+ base_min = at if at is not None else case.setting.murder_window.start_min + 7 * (idx + 1)
170
+ time = _clock(base_min)
171
+ icon = _icon_for(clue.name, clue.reveal_text, idx, clue.discovery_method.value)
172
+ kwargs: dict = dict(
173
  id=clue.clue_id,
174
  name=clue.name.upper(),
175
  type=clue.discovery_method.value.upper(),
176
+ icon=icon,
177
  time=time,
178
  found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
179
  summary=clue.reveal_text,
180
  detail=clue.reveal_text,
181
  )
182
+ # Rich device payload (phone thread / voicemail / keycard table / CCTV / scene
183
+ # photo) synthesized deterministically from the clue's own public material.
184
+ kwargs.update(synthesize_payload(case, clue, icon, idx, base_min, time))
185
+ return PublicEvidence(**kwargs)
186
 
187
 
188
  def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
 
249
  _loc_name(case, crime_loc),
250
  )
251
  return (
252
+ # Beat 1 backdrop: an establishing shot of the actual crime setting (the client
253
+ # resolves the place name to a painter; bare "skyline" stays the city default).
254
+ StoryBeat(scene=case.setting.name or "skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
255
+ StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}", incident=True),
256
  StoryBeat(scene=f"{building} — {other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
257
  text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
258
  StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
src/case_zero/api/dto.py CHANGED
@@ -1,31 +1,34 @@
1
- """Wire DTOs for the public game API.
2
-
3
- These are the exact shapes the Preact client exchanges with the server. The client
4
- speaks camelCase; pydantic aliases map that to snake_case internally. The sealed
5
- ``CaseFile``/``Solution`` never appears here - only the public ``PlayerCaseView``
6
- projection is ever returned before the verdict.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from pydantic import BaseModel, ConfigDict, Field
12
-
13
- from .public_view import PublicCase
14
-
15
-
16
- class _CamelModel(BaseModel):
17
- """Base: accept snake_case or camelCase on input, emit camelCase on output."""
18
-
19
- model_config = ConfigDict(populate_by_name=True)
20
-
21
-
22
- class NewCaseRequest(_CamelModel):
23
- seed: int | None = None
24
- case_id: str | None = Field(default=None, alias="caseId")
25
- difficulty: str | None = None
26
-
27
-
28
- class CaseResponse(_CamelModel):
29
- case_id: str = Field(alias="caseId")
30
- run_id: str = Field(alias="runId")
31
- case: PublicCase
 
 
 
 
1
+ """Wire DTOs for the public game API.
2
+
3
+ These are the exact shapes the Preact client exchanges with the server. The client
4
+ speaks camelCase; pydantic aliases map that to snake_case internally. The sealed
5
+ ``CaseFile``/``Solution`` never appears here - only the public ``PlayerCaseView``
6
+ projection is ever returned before the verdict.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from .public_view import PublicCase
14
+
15
+
16
+ class _CamelModel(BaseModel):
17
+ """Base: accept snake_case or camelCase on input, emit camelCase on output."""
18
+
19
+ model_config = ConfigDict(populate_by_name=True)
20
+
21
+
22
+ class NewCaseRequest(_CamelModel):
23
+ seed: int | None = None
24
+ case_id: str | None = Field(default=None, alias="caseId")
25
+ difficulty: str | None = None
26
+ # Case ids this player already started, oldest first. New Case never re-deals
27
+ # these (explicit ?case=/caseId loads bypass the filter so share links work).
28
+ exclude_case_ids: list[str] = Field(default_factory=list, alias="excludeCaseIds")
29
+
30
+
31
+ class CaseResponse(_CamelModel):
32
+ case_id: str = Field(alias="caseId")
33
+ run_id: str = Field(alias="runId")
34
+ case: PublicCase
src/case_zero/api/exhibits.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic rich-exhibit payloads for generated and prebaked cases.
2
+
3
+ The golden case hand-authors device mockups (phone threads, voicemail players, keycard
4
+ tables, CCTV stills). Generated cases get the same treatment here, synthesized at serve
5
+ time from each clue's OWN public material - no model call, instant, reproducible.
6
+
7
+ NO-LEAK RULE (enforced by tests): synthesizers may read ONLY
8
+ - the clue's name / reveal_text / location / supporting fact's ``at_min``,
9
+ - suspects' names, roles, and STATED alibis (their public dossier claims),
10
+ - the victim's name and the murder window.
11
+ Never the culprit, solution, secrets, true whereabouts, anchored lies, or any sealed
12
+ ``Fact.statement``. A suspect's name may appear in synthesized content only if it is
13
+ verbatim in the reveal_text (already shown to players) or restating that suspect's own
14
+ stated alibi. Filler lines come from fixed banks that assert no plot facts.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ..schemas.case import CaseFile
20
+ from ..schemas.clue import Clue
21
+ from ..schemas.enums import DiscoveryMethod
22
+ from .public_view import ThreadMessage
23
+
24
+
25
+ def _hash(s: str) -> int:
26
+ h = 0
27
+ for ch in s:
28
+ h = (h * 31 + ord(ch)) & 0x7FFFFFFF
29
+ return h
30
+
31
+
32
+ def _clock12(minute: int) -> str:
33
+ h = (minute // 60) % 24
34
+ m = minute % 60
35
+ return f"{h % 12 or 12}:{m:02d}"
36
+
37
+
38
+ def _clock24(minute: int) -> str:
39
+ return f"{(minute // 60) % 24:02d}:{minute % 60:02d}"
40
+
41
+
42
+ def _loc_name(case: CaseFile, loc_id: str) -> str:
43
+ for loc in case.setting.locations:
44
+ if loc.loc_id == loc_id:
45
+ return loc.name
46
+ return loc_id
47
+
48
+
49
+ def _surname(full: str) -> str:
50
+ parts = (full or "").split()
51
+ return parts[-1] if parts else full
52
+
53
+
54
+ def _named_in(text: str, case: CaseFile) -> str | None:
55
+ """A suspect's name, only if the clue's own text already names them."""
56
+ hay = text or ""
57
+ for s in case.suspects:
58
+ if s.name and s.name in hay:
59
+ return s.name
60
+ sur = _surname(s.name)
61
+ if len(sur) > 2 and sur in hay:
62
+ return s.name
63
+ return None
64
+
65
+
66
+ def _split_sentences(text: str, limit: int = 2) -> list[str]:
67
+ parts = [p.strip() for p in (text or "").replace("? ", "?|").replace("! ", "!|").replace(". ", ".|").split("|") if p.strip()]
68
+ if not parts:
69
+ return [text or ""]
70
+ if len(parts) <= limit:
71
+ return parts
72
+ head = " ".join(parts[: len(parts) - limit + 1])
73
+ return [head, *parts[len(parts) - limit + 1:]]
74
+
75
+
76
+ # Plot-neutral openers: timestamps and pleasantries only, never case facts.
77
+ _OPENERS = (
78
+ "We need to talk.",
79
+ "Not over the phone.",
80
+ "Are you there?",
81
+ "Don't make me ask twice.",
82
+ "You know why I'm writing.",
83
+ )
84
+
85
+
86
+ def _thread(case: CaseFile, clue: Clue, base_min: int) -> dict:
87
+ seed = _hash(case.case_id + clue.clue_id)
88
+ other = _named_in(f"{clue.name} {clue.reveal_text}", case)
89
+ who_other = _surname(other).upper() if other else "UNKNOWN"
90
+ who_me = _surname(case.victim.name).upper()
91
+ climax = _split_sentences(clue.reveal_text, 2)
92
+ n_open = 2 + seed % 2
93
+ msgs: list[ThreadMessage] = []
94
+ minute = base_min - 3 * (n_open + len(climax))
95
+ for i in range(n_open):
96
+ frm = "them" if i % 2 == 0 else "me"
97
+ msgs.append(ThreadMessage(**{
98
+ "from": frm,
99
+ "who": who_other if frm == "them" else who_me,
100
+ "t": _clock12(minute),
101
+ "m": _OPENERS[(seed + i) % len(_OPENERS)],
102
+ }))
103
+ minute += 2 + (seed >> i) % 3
104
+ for i, line in enumerate(climax):
105
+ frm = "them" if (n_open + i) % 2 == 0 else "me"
106
+ msgs.append(ThreadMessage(**{
107
+ "from": frm,
108
+ "who": who_other if frm == "them" else who_me,
109
+ "t": _clock12(min(minute, base_min)),
110
+ "m": line,
111
+ }))
112
+ minute += 2
113
+ return {"type": "PHONE", "thread": tuple(msgs)}
114
+
115
+
116
+ def _voicemail(clue: Clue) -> dict:
117
+ text = (clue.reveal_text or "").strip()
118
+ quoted = text if text.startswith(("“", '"')) else f"“{text}”"
119
+ secs = max(8, min(55, round(len(text.split()) * 0.4)))
120
+ return {"type": "AUDIO", "transcript": quoted, "dur": f"0:{secs:02d}"}
121
+
122
+
123
+ def _keycard(case: CaseFile, clue: Clue, base_min: int) -> dict:
124
+ rows: list[tuple[str, str, str, str]] = []
125
+ for s in case.suspects[:4]:
126
+ segs = s.stated_alibi.claimed_segments
127
+ if not segs:
128
+ continue
129
+ seg = segs[0]
130
+ rows.append((
131
+ _clock24(seg.window.start_min),
132
+ _loc_name(case, seg.loc_id),
133
+ _surname(s.name).upper(),
134
+ "ok",
135
+ ))
136
+ holder = _named_in(f"{clue.name} {clue.reveal_text}", case)
137
+ rows.append((
138
+ _clock24(base_min),
139
+ _loc_name(case, clue.discoverable_at_loc_id),
140
+ _surname(holder).upper() if holder else "UNREGISTERED",
141
+ "flag",
142
+ ))
143
+ rows.sort(key=lambda r: r[0])
144
+ return {"type": "DATA", "rows": tuple(rows[:6])}
145
+
146
+
147
+ def _cctv(case: CaseFile, clue: Clue, time_str: str) -> dict:
148
+ loc = _loc_name(case, clue.discoverable_at_loc_id)
149
+ return {
150
+ "type": "IMAGE",
151
+ "detail": f"CAM — {loc.upper()}\n{time_str}\n{clue.reveal_text}",
152
+ }
153
+
154
+
155
+ def _photo(case: CaseFile, clue: Clue, idx: int, keep_icon: bool = False) -> dict:
156
+ loc = _loc_name(case, clue.discoverable_at_loc_id)
157
+ out: dict = {
158
+ "type": "IMAGE",
159
+ "detail": f"{loc.upper()} — EVIDENCE MARKER {idx + 1}\n{clue.reveal_text}",
160
+ }
161
+ if not keep_icon:
162
+ out["icon"] = "photoEv"
163
+ return out
164
+
165
+
166
+ # Forensic finds that read best as an in-situ scene photograph (evidence marker shot):
167
+ # the locker shows the photo frame, while the board/dossier keep the object art.
168
+ _IN_SITU_ICONS = frozenset({"fingerprint", "bootprint", "tumbler", "jewel", "clock"})
169
+
170
+
171
+ def synthesize_payload(case: CaseFile, clue: Clue, icon: str, idx: int, base_min: int, time_str: str) -> dict:
172
+ """Extra ``PublicEvidence`` kwargs for one clue: a display type plus at most one
173
+ rich payload. Anything unmatched stays a paper exhibit (the existing look)."""
174
+ if icon == "phone":
175
+ return _thread(case, clue, base_min)
176
+ if icon == "voicemail" or (clue.discovery_method is DiscoveryMethod.INTERROGATION):
177
+ return _voicemail(clue)
178
+ if icon == "keycard":
179
+ return _keycard(case, clue, base_min)
180
+ if icon == "cctv":
181
+ return _cctv(case, clue, time_str)
182
+ if icon == "photoEv":
183
+ return _photo(case, clue, idx)
184
+ if clue.discovery_method is DiscoveryMethod.FORENSIC and icon in _IN_SITU_ICONS:
185
+ return _photo(case, clue, idx, keep_icon=True)
186
+ return {}
src/case_zero/api/public_view.py CHANGED
@@ -1,187 +1,192 @@
1
- """The PUBLIC wire contract: the exact case shape the Preact client receives.
2
-
3
- This mirrors the prototype's ``prototype/js/case.jsx`` (the data-model-by-example) in
4
- camelCase. It is the ONLY case representation that ever reaches the browser before the
5
- verdict. The sealed solution - killer, true motive, key-evidence chain - and every
6
- per-question/per-evidence suspicion *delta* and scripted *answer* are deliberately absent:
7
- those are produced live, server-side (the deltas alone would reveal the killer).
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from pydantic import BaseModel, ConfigDict, Field
13
- from pydantic.alias_generators import to_camel
14
-
15
-
16
- class _Wire(BaseModel):
17
- """camelCase on the wire; snake_case in Python. Frozen - a view is never mutated."""
18
-
19
- model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, frozen=True)
20
-
21
-
22
- class PublicVictim(_Wire):
23
- name: str
24
- role: str
25
- age: int
26
- sprite: str
27
- bio: str
28
-
29
-
30
- class SuggestedQuestion(_Wire):
31
- id: str
32
- q: str
33
-
34
-
35
- class PublicSuspect(_Wire):
36
- id: str
37
- name: str
38
- role: str
39
- age: int
40
- sprite: str
41
- gender: str = "male"
42
- tag: str
43
- baseline_suspicion: int
44
- motive: str # apparent motive shown in the dossier - NOT proof
45
- alibi: str
46
- quote: str
47
- greet: str
48
- suggested_questions: tuple[SuggestedQuestion, ...]
49
-
50
-
51
- class ThreadMessage(_Wire):
52
- from_: str = Field(alias="from")
53
- who: str
54
- t: str
55
- m: str
56
-
57
-
58
- class PublicEvidence(_Wire):
59
- id: str
60
- name: str
61
- type: str # PHONE | PAPER | IMAGE | AUDIO | DATA
62
- icon: str
63
- time: str
64
- found: str
65
- summary: str
66
- # Exactly one of these display payloads is populated, per type.
67
- thread: tuple[ThreadMessage, ...] | None = None
68
- detail: str | None = None
69
- transcript: str | None = None
70
- dur: str | None = None
71
- rows: tuple[tuple[str, ...], ...] | None = None
72
-
73
-
74
- class TimelineBeat(_Wire):
75
- time: str
76
- label: str
77
- locked: bool = False
78
- ev: str | None = None
79
- conflict: bool = False
80
-
81
-
82
- class FlashbackAccount(_Wire):
83
- who: str
84
- scene: str
85
- lines: tuple[str, ...]
86
- flags: tuple[int, ...] = ()
87
-
88
-
89
- class PublicFlashback(_Wire):
90
- title: str
91
- a: FlashbackAccount
92
- b: FlashbackAccount
93
-
94
-
95
- class PublicMotive(_Wire):
96
- id: str
97
- text: str
98
-
99
-
100
- class StoryBeat(_Wire):
101
- scene: str
102
- kicker: str
103
- title: str
104
- text: str
105
-
106
-
107
- class PublicCase(_Wire):
108
- id: str
109
- city: str
110
- district: str
111
- title: str
112
- tagline: str
113
- weather: str
114
- victim: PublicVictim
115
- scene: str
116
- tod: str
117
- found: str
118
- cause: str
119
- # 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, ...]
130
- suspects: tuple[PublicSuspect, ...]
131
- evidence: tuple[PublicEvidence, ...]
132
- timeline: tuple[TimelineBeat, ...]
133
- flashback: PublicFlashback
134
- motives: tuple[PublicMotive, ...]
135
-
136
-
137
- def golden_to_public(data: dict) -> PublicCase:
138
- """Project a sealed golden/stored case dict into the PUBLIC view.
139
-
140
- Strips the ``sealed`` block and, per suspect, the scripted ``answer``/``delta`` and
141
- the ``present``/``default`` reply tables - leaving only question text the player sees.
142
- """
143
- suspects = tuple(
144
- PublicSuspect(
145
- id=s["id"],
146
- name=s["name"],
147
- role=s["role"],
148
- age=s["age"],
149
- sprite=s["sprite"],
150
- gender=s.get("gender", "male"),
151
- tag=s["tag"],
152
- baseline_suspicion=s["suspicion"],
153
- motive=s["motive"],
154
- alibi=s["alibi"],
155
- quote=s["quote"],
156
- greet=s["greet"],
157
- suggested_questions=tuple(
158
- SuggestedQuestion(id=q["id"], q=q["q"]) for q in s["questions"]
159
- ),
160
- )
161
- for s in data["suspects"]
162
- )
163
- return PublicCase(
164
- id=data["id"],
165
- city=data["city"],
166
- district=data["district"],
167
- title=data["title"],
168
- tagline=data["tagline"],
169
- weather=data["weather"],
170
- victim=PublicVictim(**data["victim"]),
171
- scene=data["scene"],
172
- tod=data["tod"],
173
- found=data["found"],
174
- cause=data["cause"],
175
- facts=tuple(tuple(pair) for pair in data["facts"]),
176
- boot_lines=tuple(data["bootLines"]),
177
- story_beats=tuple(StoryBeat(**b) for b in data.get("storyBeats", [])),
178
- suspects=suspects,
179
- evidence=tuple(PublicEvidence(**e) for e in data["evidence"]),
180
- timeline=tuple(TimelineBeat(**b) for b in data["timeline"]),
181
- flashback=PublicFlashback(
182
- title=data["flashback"]["title"],
183
- a=FlashbackAccount(**data["flashback"]["a"]),
184
- b=FlashbackAccount(**data["flashback"]["b"]),
185
- ),
186
- motives=tuple(PublicMotive(**m) for m in data["motives"]),
187
- )
 
 
 
 
 
 
1
+ """The PUBLIC wire contract: the exact case shape the Preact client receives.
2
+
3
+ This mirrors the prototype's ``prototype/js/case.jsx`` (the data-model-by-example) in
4
+ camelCase. It is the ONLY case representation that ever reaches the browser before the
5
+ verdict. The sealed solution - killer, true motive, key-evidence chain - and every
6
+ per-question/per-evidence suspicion *delta* and scripted *answer* are deliberately absent:
7
+ those are produced live, server-side (the deltas alone would reveal the killer).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+ from pydantic.alias_generators import to_camel
14
+
15
+
16
+ class _Wire(BaseModel):
17
+ """camelCase on the wire; snake_case in Python. Frozen - a view is never mutated."""
18
+
19
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, frozen=True)
20
+
21
+
22
+ class PublicVictim(_Wire):
23
+ name: str
24
+ role: str
25
+ age: int
26
+ sprite: str
27
+ bio: str
28
+
29
+
30
+ class SuggestedQuestion(_Wire):
31
+ id: str
32
+ q: str
33
+
34
+
35
+ class PublicSuspect(_Wire):
36
+ id: str
37
+ name: str
38
+ role: str
39
+ age: int
40
+ sprite: str
41
+ gender: str = "male"
42
+ accent_color: str = "" # outfit accent hex; the client recolors the sprite with it
43
+ tag: str
44
+ baseline_suspicion: int
45
+ motive: str # apparent motive shown in the dossier - NOT proof
46
+ alibi: str
47
+ quote: str
48
+ greet: str
49
+ suggested_questions: tuple[SuggestedQuestion, ...]
50
+
51
+
52
+ class ThreadMessage(_Wire):
53
+ from_: str = Field(alias="from")
54
+ who: str
55
+ t: str
56
+ m: str
57
+
58
+
59
+ class PublicEvidence(_Wire):
60
+ id: str
61
+ name: str
62
+ type: str # PHONE | PAPER | IMAGE | AUDIO | DATA
63
+ icon: str
64
+ time: str
65
+ found: str
66
+ summary: str
67
+ # Exactly one of these display payloads is populated, per type.
68
+ thread: tuple[ThreadMessage, ...] | None = None
69
+ detail: str | None = None
70
+ transcript: str | None = None
71
+ dur: str | None = None
72
+ rows: tuple[tuple[str, ...], ...] | None = None
73
+
74
+
75
+ class TimelineBeat(_Wire):
76
+ time: str
77
+ label: str
78
+ locked: bool = False
79
+ ev: str | None = None
80
+ conflict: bool = False
81
+
82
+
83
+ class FlashbackAccount(_Wire):
84
+ who: str
85
+ scene: str
86
+ lines: tuple[str, ...]
87
+ flags: tuple[int, ...] = ()
88
+
89
+
90
+ class PublicFlashback(_Wire):
91
+ title: str
92
+ a: FlashbackAccount
93
+ b: FlashbackAccount
94
+
95
+
96
+ class PublicMotive(_Wire):
97
+ id: str
98
+ text: str
99
+
100
+
101
+ class StoryBeat(_Wire):
102
+ scene: str
103
+ kicker: str
104
+ title: str
105
+ text: str
106
+ # True on the beat that narrates the MAIN ACTION (the body, the empty pedestal...):
107
+ # the client draws the crime-kind vignette over the scene so the act is SEEN.
108
+ incident: bool = False
109
+
110
+
111
+ class PublicCase(_Wire):
112
+ id: str
113
+ city: str
114
+ district: str
115
+ title: str
116
+ tagline: str
117
+ weather: str
118
+ victim: PublicVictim
119
+ scene: str
120
+ tod: str
121
+ found: str
122
+ cause: str
123
+ # Case-kind display labels. Defaulted to homicide so the golden case and every
124
+ # pre-kind stored case keep their exact current wording.
125
+ kind: str = "homicide"
126
+ kind_label: str = "HOMICIDE" # title screen: "A PROCEDURAL {kindLabel}"
127
+ division: str = "HOMICIDE DIVISION"
128
+ victim_status: str = "DECEASED" # dossier stamp next to the victim
129
+ tod_label: str = "T.O.D."
130
+ verdict: str = "Homicide" # KEY FACTS verdict line
131
+ facts: tuple[tuple[str, str], ...]
132
+ boot_lines: tuple[str, ...]
133
+ story_beats: tuple[StoryBeat, ...]
134
+ suspects: tuple[PublicSuspect, ...]
135
+ evidence: tuple[PublicEvidence, ...]
136
+ timeline: tuple[TimelineBeat, ...]
137
+ flashback: PublicFlashback
138
+ motives: tuple[PublicMotive, ...]
139
+
140
+
141
+ def golden_to_public(data: dict) -> PublicCase:
142
+ """Project a sealed golden/stored case dict into the PUBLIC view.
143
+
144
+ Strips the ``sealed`` block and, per suspect, the scripted ``answer``/``delta`` and
145
+ the ``present``/``default`` reply tables - leaving only question text the player sees.
146
+ """
147
+ suspects = tuple(
148
+ PublicSuspect(
149
+ id=s["id"],
150
+ name=s["name"],
151
+ role=s["role"],
152
+ age=s["age"],
153
+ sprite=s["sprite"],
154
+ gender=s.get("gender", "male"),
155
+ accent_color=s.get("accentColor", ""),
156
+ tag=s["tag"],
157
+ baseline_suspicion=s["suspicion"],
158
+ motive=s["motive"],
159
+ alibi=s["alibi"],
160
+ quote=s["quote"],
161
+ greet=s["greet"],
162
+ suggested_questions=tuple(
163
+ SuggestedQuestion(id=q["id"], q=q["q"]) for q in s["questions"]
164
+ ),
165
+ )
166
+ for s in data["suspects"]
167
+ )
168
+ return PublicCase(
169
+ id=data["id"],
170
+ city=data["city"],
171
+ district=data["district"],
172
+ title=data["title"],
173
+ tagline=data["tagline"],
174
+ weather=data["weather"],
175
+ victim=PublicVictim(**data["victim"]),
176
+ scene=data["scene"],
177
+ tod=data["tod"],
178
+ found=data["found"],
179
+ cause=data["cause"],
180
+ facts=tuple(tuple(pair) for pair in data["facts"]),
181
+ boot_lines=tuple(data["bootLines"]),
182
+ story_beats=tuple(StoryBeat(**b) for b in data.get("storyBeats", [])),
183
+ suspects=suspects,
184
+ evidence=tuple(PublicEvidence(**e) for e in data["evidence"]),
185
+ timeline=tuple(TimelineBeat(**b) for b in data["timeline"]),
186
+ flashback=PublicFlashback(
187
+ title=data["flashback"]["title"],
188
+ a=FlashbackAccount(**data["flashback"]["a"]),
189
+ b=FlashbackAccount(**data["flashback"]["b"]),
190
+ ),
191
+ motives=tuple(PublicMotive(**m) for m in data["motives"]),
192
+ )
src/case_zero/api/routes_case.py CHANGED
@@ -1,55 +1,57 @@
1
- """Case lifecycle: create a new case (generate, or load by ID) and fetch a public view.
2
-
3
- New Case generates a fresh, solvable mystery via the in-process LLM (buffered one-ahead);
4
- if generation is unavailable (no weights / load failure) it falls back to the GRAYMOOR-3107
5
- golden case so the loop is always playable. Generated cases are persisted by Case ID, so a
6
- shared ID reloads the identical mystery. The sealed solution is never in any response here.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from fastapi import APIRouter, HTTPException
12
-
13
- from ..persistence.golden import golden_exists, load_golden
14
- from ..persistence.run_store import create_run
15
- from .dto import CaseResponse, NewCaseRequest
16
- from .public_view import golden_to_public
17
- from .runtime import RUNTIME
18
-
19
- router = APIRouter(prefix="/api", tags=["case"])
20
-
21
- DEFAULT_CASE_ID = "GRAYMOOR-3107"
22
-
23
-
24
- def _golden_response(case_id: str) -> CaseResponse:
25
- run = create_run(case_id)
26
- return CaseResponse(case_id=case_id, run_id=run.run_id, case=golden_to_public(load_golden(case_id)))
27
-
28
-
29
- @router.post("/case", response_model=CaseResponse)
30
- def new_case(req: NewCaseRequest) -> CaseResponse:
31
- if req.case_id:
32
- if golden_exists(req.case_id):
33
- return _golden_response(req.case_id)
34
- loaded = RUNTIME.load_generated_run(req.case_id)
35
- if loaded is not None:
36
- public, run_id = loaded
37
- return CaseResponse(case_id=public.id, run_id=run_id, case=public)
38
- return _golden_response(DEFAULT_CASE_ID)
39
-
40
- generated = RUNTIME.new_generated_run()
41
- if generated is not None:
42
- public, run_id = generated
43
- return CaseResponse(case_id=public.id, run_id=run_id, case=public)
44
- return _golden_response(DEFAULT_CASE_ID)
45
-
46
-
47
- @router.get("/case/{case_id}", response_model=CaseResponse)
48
- def get_case(case_id: str) -> CaseResponse:
49
- if golden_exists(case_id):
50
- return _golden_response(case_id)
51
- loaded = RUNTIME.load_generated_run(case_id)
52
- if loaded is not None:
53
- public, run_id = loaded
54
- return CaseResponse(case_id=public.id, run_id=run_id, case=public)
55
- raise HTTPException(status_code=404, detail=f"case not found: {case_id}")
 
 
 
1
+ """Case lifecycle: create a new case (generate, or load by ID) and fetch a public view.
2
+
3
+ New Case generates a fresh, solvable mystery via the in-process LLM (buffered one-ahead);
4
+ if generation is unavailable (no weights / load failure) it falls back to the GRAYMOOR-3107
5
+ golden case so the loop is always playable. Generated cases are persisted by Case ID, so a
6
+ shared ID reloads the identical mystery. The sealed solution is never in any response here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+
13
+ from ..persistence.golden import golden_exists, load_golden
14
+ from ..persistence.run_store import create_run
15
+ from .dto import CaseResponse, NewCaseRequest
16
+ from .public_view import golden_to_public
17
+ from .runtime import RUNTIME
18
+
19
+ router = APIRouter(prefix="/api", tags=["case"])
20
+
21
+ DEFAULT_CASE_ID = "GRAYMOOR-3107"
22
+
23
+
24
+ def _golden_response(case_id: str) -> CaseResponse:
25
+ run = create_run(case_id)
26
+ return CaseResponse(case_id=case_id, run_id=run.run_id, case=golden_to_public(load_golden(case_id)))
27
+
28
+
29
+ @router.post("/case", response_model=CaseResponse)
30
+ def new_case(req: NewCaseRequest) -> CaseResponse:
31
+ if req.case_id:
32
+ if golden_exists(req.case_id):
33
+ return _golden_response(req.case_id)
34
+ loaded = RUNTIME.load_generated_run(req.case_id)
35
+ if loaded is not None:
36
+ public, run_id = loaded
37
+ return CaseResponse(case_id=public.id, run_id=run_id, case=public)
38
+ return _golden_response(DEFAULT_CASE_ID)
39
+
40
+ # Exclusions are best-effort: the golden fallback below ignores them (it only
41
+ # triggers when the LLM runtime is unavailable, i.e. local dev, or total failure).
42
+ generated = RUNTIME.new_generated_run(exclude=req.exclude_case_ids[:300])
43
+ if generated is not None:
44
+ public, run_id = generated
45
+ return CaseResponse(case_id=public.id, run_id=run_id, case=public)
46
+ return _golden_response(DEFAULT_CASE_ID)
47
+
48
+
49
+ @router.get("/case/{case_id}", response_model=CaseResponse)
50
+ def get_case(case_id: str) -> CaseResponse:
51
+ if golden_exists(case_id):
52
+ return _golden_response(case_id)
53
+ loaded = RUNTIME.load_generated_run(case_id)
54
+ if loaded is not None:
55
+ public, run_id = loaded
56
+ return CaseResponse(case_id=public.id, run_id=run_id, case=public)
57
+ raise HTTPException(status_code=404, detail=f"case not found: {case_id}")
src/case_zero/api/runtime.py CHANGED
@@ -1,319 +1,353 @@
1
- """Live game runtime: lazily builds the in-process llama.cpp backend, generates cases,
2
- and holds live ``Session`` objects per run.
3
-
4
- Single-flight is MANDATORY: ``llama_cpp.Llama`` is not thread-safe, so every model call
5
- (generation + interrogation) runs under one lock - never concurrently, on any machine.
6
-
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
32
- from ..schemas.case import CaseFile
33
- from ..schemas.enums import Relevance
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:
74
- run_id: str
75
- case: CaseFile
76
- session: Session
77
- public: PublicCase
78
- baselines: dict[str, int]
79
-
80
-
81
- class GameRuntime:
82
- def __init__(self) -> None:
83
- self._lock = threading.Lock() # MANDATORY single-flight over all model calls
84
- self._backend: LLMBackend | None = None
85
- self._backend_failed = False
86
- self._runs: dict[str, 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:
106
- if self._backend is None and not self._backend_failed:
107
- try:
108
- self._backend = make_backend(get_settings())
109
- except LLMError:
110
- self._backend_failed = True
111
- return self._backend
112
-
113
- def available(self) -> bool:
114
- return self._get_backend() is not None
115
-
116
- def _next_seed(self) -> int:
117
- self._seed += 1
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:
166
- return
167
- self._prebaked_loaded = True
168
- pool_dir = prebaked_cases_dir()
169
- if not pool_dir.is_dir():
170
- return
171
- for path in sorted(pool_dir.glob("*.json")):
172
- try:
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:
188
- case = self._buffer
189
- self._buffer = None
190
- return case
191
-
192
- def _take_prebaked(self) -> CaseFile | None:
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():
211
- return None
212
- # Prefer a freshly generated case if one is ready; else serve the pre-baked pool
213
- # instantly; only with neither do we generate synchronously (first run, no pool).
214
- case = self._take_buffered() or self._take_prebaked()
215
- if case is None:
216
- try:
217
- case = self._generate(self._next_seed())
218
- except Exception:
219
- return None
220
- self._maybe_refill()
221
- return self._register(case)
222
-
223
- def load_generated_run(self, case_id: str) -> tuple[PublicCase, str] | None:
224
- if not self.available():
225
- return None
226
- self._load_prebaked()
227
- case = next((c for c in self._prebaked if c.case_id == case_id), None)
228
- if case is None:
229
- for directory in (prebaked_cases_dir(), runtime_cases_dir()):
230
- path = directory / f"{case_id}.json"
231
- if path.exists():
232
- try:
233
- case = load_case(path)
234
- except Exception:
235
- case = None
236
- break
237
- if case is None:
238
- return None
239
- return self._register(case)
240
-
241
- def _register(self, case: CaseFile) -> tuple[PublicCase, str]:
242
- public = casefile_to_public(case)
243
- session = Session(case, self._get_backend()) # type: ignore[arg-type]
244
- run_id = uuid.uuid4().hex
245
- baselines = {s.id: s.baseline_suspicion for s in public.suspects}
246
- self._runs[run_id] = LiveRun(run_id, case, session, public, baselines)
247
- return public, run_id
248
-
249
- def get(self, run_id: str) -> LiveRun | None:
250
- return self._runs.get(run_id)
251
-
252
- # ---- live turn / verdict ----
253
- def _suspicion(self, run: LiveRun, sus_id: str) -> int:
254
- st = run.session.state.state_for(sus_id)
255
- base = run.baselines.get(sus_id, 25)
256
- val = base + round(st.stress * 55) + (20 if st.broken_lie_ids else 0)
257
- return max(0, min(100, val))
258
-
259
- def interrogate_live(
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
277
- rattled = bool(adj and adj.relevance in (Relevance.DIRECT, Relevance.BREAKING))
278
- cornered = bool(adj and adj.is_contradiction)
279
- return {
280
- "reply": reply,
281
- "suspicionDelta": after - prev,
282
- "suspicion": after,
283
- "flags": {"rattled": rattled, "contradictionExposed": cornered, "cornered": cornered},
284
- }
285
-
286
- def accuse_live(self, run: LiveRun, suspect_id: str, motive_id: str, evidence_ids: list[str]) -> dict:
287
- verdict = run.session.accuse(
288
- Accusation(accused_sus_id=suspect_id, motive_id=motive_id, cited_clue_ids=tuple(evidence_ids))
289
- )
290
- culprit_id = run.case.culprit.sus_id
291
- killer = run.case.suspect(culprit_id)
292
- if verdict.culprit_correct:
293
- truth = verdict.rationale or run.case.culprit.method_narrative
294
- else:
295
- accused = run.case.suspect(suspect_id).name if any(s.sus_id == suspect_id for s in run.case.suspects) else "the accused"
296
- truth = (
297
- f"You charged {accused}. The case held for a night - but the evidence led past "
298
- f"them to {killer.name}, who walked out into the rain."
299
- )
300
- return {
301
- "correct": verdict.culprit_correct,
302
- "verdict": {
303
- "stamp": "CASE CLOSED" if verdict.culprit_correct else "MISTRIAL",
304
- "killerId": culprit_id,
305
- "killerName": killer.name,
306
- "truth": truth,
307
- },
308
- "score": {
309
- "points": verdict.score,
310
- "max": 100,
311
- "killerCorrect": verdict.culprit_correct,
312
- "motiveCorrect": verdict.motive_correct,
313
- "evidenceHits": len(evidence_ids),
314
- },
315
- "stats": [],
316
- }
317
-
318
-
319
- RUNTIME = GameRuntime()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live game runtime: lazily builds the in-process llama.cpp backend, generates cases,
2
+ and holds live ``Session`` objects per run.
3
+
4
+ Single-flight is MANDATORY: ``llama_cpp.Llama`` is not thread-safe, so every model call
5
+ (generation + interrogation) runs under one lock - never concurrently, on any machine.
6
+
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, Sequence
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
32
+ from ..schemas.case import CaseFile
33
+ from ..schemas.enums import Relevance
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:
74
+ run_id: str
75
+ case: CaseFile
76
+ session: Session
77
+ public: PublicCase
78
+ baselines: dict[str, int]
79
+
80
+
81
+ class GameRuntime:
82
+ def __init__(self) -> None:
83
+ self._lock = threading.Lock() # MANDATORY single-flight over all model calls
84
+ self._backend: LLMBackend | None = None
85
+ self._backend_failed = False
86
+ self._runs: dict[str, 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:
106
+ if self._backend is None and not self._backend_failed:
107
+ try:
108
+ self._backend = make_backend(get_settings())
109
+ except LLMError:
110
+ self._backend_failed = True
111
+ return self._backend
112
+
113
+ def available(self) -> bool:
114
+ return self._get_backend() is not None
115
+
116
+ def _next_seed(self) -> int:
117
+ self._seed += 1
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:
166
+ return
167
+ self._prebaked_loaded = True
168
+ seen: set[str] = set()
169
+ # Shipped pool first, then cases generated during previous runs of this Space
170
+ # (solver-validated at save time) - the pool keeps growing across restarts
171
+ # instead of resetting to the shipped set.
172
+ for pool_dir in (prebaked_cases_dir(), runtime_cases_dir()):
173
+ if not pool_dir.is_dir():
174
+ continue
175
+ for path in sorted(pool_dir.glob("*.json")):
176
+ try:
177
+ case = load_case(path)
178
+ except Exception:
179
+ continue
180
+ if case.case_id in seen:
181
+ continue
182
+ seen.add(case.case_id)
183
+ self._prebaked.append(case)
184
+ # Shuffle per process (the seed is time-based) so the FIRST case of every fresh
185
+ # visit/restart is randomized - never the same opening mystery twice in a row.
186
+ self._rng.shuffle(self._prebaked)
187
+
188
+ def start_buffer(self) -> None:
189
+ """Make the first New Case instant (shipped pool, shuffled) and start growing the
190
+ pool with a fresh AI-generated case in the background."""
191
+ self._load_prebaked()
192
+ self._spawn_gen()
193
+
194
+ def _take_buffered(self, exclude: set[str] | None = None) -> CaseFile | None:
195
+ with self._buffer_lock:
196
+ case = self._buffer
197
+ if case is not None and exclude and case.case_id in exclude:
198
+ # This player has seen it - leave it buffered for someone who hasn't.
199
+ return None
200
+ self._buffer = None
201
+ return case
202
+
203
+ def _take_prebaked(self, exclude: set[str] | None = None) -> CaseFile | None:
204
+ self._load_prebaked()
205
+ if not self._prebaked:
206
+ return None
207
+ if exclude:
208
+ # Per-player filter: pick randomly among unplayed cases WITHOUT touching
209
+ # the shared rotation index (other players keep their rotation order).
210
+ candidates = [c for c in list(self._prebaked) if c.case_id not in exclude]
211
+ return self._rng.choice(candidates) if candidates else None
212
+ if self._prebaked_idx >= len(self._prebaked):
213
+ # Bag exhausted: reshuffle for a fresh order on the next lap.
214
+ self._rng.shuffle(self._prebaked)
215
+ self._prebaked_idx = 0
216
+ case = self._prebaked[self._prebaked_idx]
217
+ self._prebaked_idx += 1
218
+ return case
219
+
220
+ def _maybe_refill(self) -> None:
221
+ """Keep one fresh AI case cooking in the background whenever the buffer is empty."""
222
+ if self._buffer is None:
223
+ self._spawn_gen()
224
+
225
+ def new_generated_run(self, exclude: Sequence[str] = ()) -> tuple[PublicCase, str] | None:
226
+ """Deal a case this player hasn't started yet.
227
+
228
+ Ladder: buffered fresh case -> random unplayed pool case -> synchronous
229
+ generation (the Space has the model; the client shows its forming-a-case
230
+ screen) -> least-recently-played repeat (a repeat beats an error) -> None
231
+ (golden fallback upstream; LLM-less local dev only).
232
+ """
233
+ if not self.available():
234
+ return None
235
+ exclude_set = set(exclude)
236
+ # Heavy players drain their personal pool: keep generation ahead of them.
237
+ self._load_prebaked()
238
+ if exclude_set:
239
+ unplayed = sum(1 for c in list(self._prebaked) if c.case_id not in exclude_set)
240
+ if unplayed < 3:
241
+ self._spawn_gen()
242
+ case = self._take_buffered(exclude_set) or self._take_prebaked(exclude_set)
243
+ if case is None:
244
+ try:
245
+ case = self._generate(self._next_seed())
246
+ except Exception:
247
+ # Last resort: re-deal the LEAST recently played (client sends oldest
248
+ # first), rather than erroring out on a player who finished the pool.
249
+ for case_id in exclude:
250
+ loaded = self.load_generated_run(case_id)
251
+ if loaded is not None:
252
+ return loaded
253
+ return None
254
+ self._maybe_refill()
255
+ return self._register(case)
256
+
257
+ def load_generated_run(self, case_id: str) -> tuple[PublicCase, str] | None:
258
+ if not self.available():
259
+ return None
260
+ self._load_prebaked()
261
+ case = next((c for c in self._prebaked if c.case_id == case_id), None)
262
+ if case is None:
263
+ for directory in (prebaked_cases_dir(), runtime_cases_dir()):
264
+ path = directory / f"{case_id}.json"
265
+ if path.exists():
266
+ try:
267
+ case = load_case(path)
268
+ except Exception:
269
+ case = None
270
+ break
271
+ if case is None:
272
+ return None
273
+ return self._register(case)
274
+
275
+ def _register(self, case: CaseFile) -> tuple[PublicCase, str]:
276
+ public = casefile_to_public(case)
277
+ session = Session(case, self._get_backend()) # type: ignore[arg-type]
278
+ run_id = uuid.uuid4().hex
279
+ baselines = {s.id: s.baseline_suspicion for s in public.suspects}
280
+ self._runs[run_id] = LiveRun(run_id, case, session, public, baselines)
281
+ return public, run_id
282
+
283
+ def get(self, run_id: str) -> LiveRun | None:
284
+ return self._runs.get(run_id)
285
+
286
+ # ---- live turn / verdict ----
287
+ def _suspicion(self, run: LiveRun, sus_id: str) -> int:
288
+ st = run.session.state.state_for(sus_id)
289
+ base = run.baselines.get(sus_id, 25)
290
+ val = base + round(st.stress * 55) + (20 if st.broken_lie_ids else 0)
291
+ return max(0, min(100, val))
292
+
293
+ def interrogate_live(
294
+ self, run: LiveRun, sus_id: str, question: str, clue_id: str | None
295
+ ) -> dict:
296
+ prev = self._suspicion(run, sus_id)
297
+ # Tell any in-flight background generation to yield the lock NOW (it aborts
298
+ # between tokens), then take the table.
299
+ self._gen_interrupt.set()
300
+ self._last_player_ts = time.time()
301
+ with self._lock:
302
+ self._gen_interrupt.clear()
303
+ final = None
304
+ for ev in run.session.interrogate(sus_id, question, presented_clue_id=clue_id):
305
+ if ev.final is not None:
306
+ final = ev.final
307
+ self._last_player_ts = time.time()
308
+ reply = final.turn.spoken if final else "…I have nothing to say to that."
309
+ after = self._suspicion(run, sus_id)
310
+ adj = final.adjudication if final else None
311
+ rattled = bool(adj and adj.relevance in (Relevance.DIRECT, Relevance.BREAKING))
312
+ cornered = bool(adj and adj.is_contradiction)
313
+ return {
314
+ "reply": reply,
315
+ "suspicionDelta": after - prev,
316
+ "suspicion": after,
317
+ "flags": {"rattled": rattled, "contradictionExposed": cornered, "cornered": cornered},
318
+ }
319
+
320
+ def accuse_live(self, run: LiveRun, suspect_id: str, motive_id: str, evidence_ids: list[str]) -> dict:
321
+ verdict = run.session.accuse(
322
+ Accusation(accused_sus_id=suspect_id, motive_id=motive_id, cited_clue_ids=tuple(evidence_ids))
323
+ )
324
+ culprit_id = run.case.culprit.sus_id
325
+ killer = run.case.suspect(culprit_id)
326
+ if verdict.culprit_correct:
327
+ truth = verdict.rationale or run.case.culprit.method_narrative
328
+ else:
329
+ accused = run.case.suspect(suspect_id).name if any(s.sus_id == suspect_id for s in run.case.suspects) else "the accused"
330
+ truth = (
331
+ f"You charged {accused}. The case held for a night - but the evidence led past "
332
+ f"them to {killer.name}, who walked out into the rain."
333
+ )
334
+ return {
335
+ "correct": verdict.culprit_correct,
336
+ "verdict": {
337
+ "stamp": "CASE CLOSED" if verdict.culprit_correct else "MISTRIAL",
338
+ "killerId": culprit_id,
339
+ "killerName": killer.name,
340
+ "truth": truth,
341
+ },
342
+ "score": {
343
+ "points": verdict.score,
344
+ "max": 100,
345
+ "killerCorrect": verdict.culprit_correct,
346
+ "motiveCorrect": verdict.motive_correct,
347
+ "evidenceHits": len(evidence_ids),
348
+ },
349
+ "stats": [],
350
+ }
351
+
352
+
353
+ RUNTIME = GameRuntime()
src/case_zero/generator/crime_profiles.py CHANGED
@@ -1,247 +1,372 @@
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)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ CrimeKind.CON: _P(
233
+ kind=CrimeKind.CON,
234
+ author_noun="confidence-game mystery - a charming swindler emptied someone's "
235
+ "savings through a venture that never existed",
236
+ incident_noun="swindle",
237
+ incident_line="The decisive payment of the swindle changed hands between {start} "
238
+ "and {end} one evening.",
239
+ victim_ask="the victim - the person CONNED (name, role, and in cause_of_death "
240
+ "one line on what they handed over and the moment they realized the "
241
+ "venture was smoke) and which room index (0-based) the deal was closed in",
242
+ instrument_ask="the BAIT of the con (name, kind) - a false prospectus, fake share "
243
+ "certificates, a salted mine sample, a counterfeit letter of credit",
244
+ deed_line="They conned {victim} out of a fortune using the {instrument}, closing "
245
+ "the deal in the {room} between {start} and {end}, then lied that they "
246
+ "never left the {claimed}.",
247
+ trace_ask="quietly place {culprit} in the {room} when the deal was closed",
248
+ brief_deed="You conned {victim} out of their savings using the {instrument}.",
249
+ confession_verbs=r"conned|swindled|fleeced|duped|took\s+(him|her|them)\s+for",
250
+ perp_noun="con artist",
251
+ division="BUNCO SQUAD",
252
+ victim_status="FLEECED",
253
+ verdict="Confidence Fraud",
254
+ kind_label="CON GAME",
255
+ tod_label="CLOSED AT",
256
+ found_verb="Closed in",
257
+ timeline_line="The deal is closed in the {room}.",
258
+ boot_line="A fortune handed over with a smile.",
259
+ fallback_titles=(
260
+ "The {room} Prospectus", "The {setting} Promise", "The {last} Venture",
261
+ "Paper Gold at {setting}", "The Long Game in the {room}", "The {setting} Pitch",
262
+ ),
263
+ ),
264
+ CrimeKind.POISONING: _P(
265
+ kind=CrimeKind.POISONING,
266
+ author_noun="poisoning mystery - a tainted glass passed at a familiar table",
267
+ incident_noun="poisoning",
268
+ incident_line="The poison was administered between {start} and {end} one evening.",
269
+ victim_ask="the victim (name, role, cause of death - what they drank or ate and "
270
+ "how it took them) and which room index (0-based) they were stricken in",
271
+ instrument_ask="the VECTOR of the poison (name, kind) - a decanter, a teacup, a "
272
+ "tonic bottle, a dessert plate",
273
+ deed_line="They poisoned {victim} via the {instrument} in the {room} between "
274
+ "{start} and {end}, then lied that they never left the {claimed}.",
275
+ trace_ask="quietly place {culprit} near the {instrument} before {victim} was stricken",
276
+ brief_deed="You poisoned {victim}.",
277
+ confession_verbs=r"poisoned|dosed|laced|tainted|spiked",
278
+ perp_noun="poisoner",
279
+ division="TOXICOLOGY UNIT",
280
+ victim_status="POISONED",
281
+ verdict="Poisoning",
282
+ kind_label="POISONING",
283
+ tod_label="T.O.D.",
284
+ found_verb="Stricken in",
285
+ timeline_line="{name} is stricken. The glass is still warm.",
286
+ boot_line="A toast no one should have drunk.",
287
+ fallback_titles=(
288
+ "The Bitter Glass in the {room}", "The {setting} Toast", "The {last} Dose",
289
+ "What the Decanter Held", "Last Pour at {setting}", "The {room} Vintage",
290
+ ),
291
+ ),
292
+ CrimeKind.RANSOM: _P(
293
+ kind=CrimeKind.RANSOM,
294
+ author_noun="ransom mystery - someone was taken, and a demand was left where it "
295
+ "would be found",
296
+ incident_noun="abduction",
297
+ incident_line="The abduction happened between {start} and {end} one evening.",
298
+ victim_ask="the victim - the person TAKEN (name, role, and in cause_of_death one "
299
+ "line on how the ransom demand was discovered) and which room index "
300
+ "(0-based) they were taken from",
301
+ instrument_ask="the RANSOM NOTE (name, kind) - cut letters, a typed card, a wax-"
302
+ "sealed demand",
303
+ deed_line="They are behind {victim}'s abduction - they took them from the {room} "
304
+ "and left the {instrument} between {start} and {end}, then lied that "
305
+ "they never left the {claimed}.",
306
+ trace_ask="quietly place {culprit} in the {room} when {victim} was taken",
307
+ brief_deed="You took {victim} and left the {instrument}. {victim} is alive and "
308
+ "unharmed, but you must hide what you did.",
309
+ confession_verbs=r"abducted|kidnapped|took\s+(him|her|them)|snatched|holding\s+(him|her|them)",
310
+ perp_noun="kidnapper",
311
+ division="RANSOM DETAIL",
312
+ victim_status="TAKEN",
313
+ verdict="Kidnapping",
314
+ kind_label="RANSOM",
315
+ tod_label="TAKEN AT",
316
+ found_verb="Taken from",
317
+ timeline_line="{name} is taken. The note is found.",
318
+ boot_line="An empty chair and a demand on the mantel.",
319
+ fallback_titles=(
320
+ "The {room} Demand", "The Price of {last}", "Taken from {setting}",
321
+ "The {setting} Note", "What the {room} Lost", "The {last} Exchange",
322
+ ),
323
+ ),
324
+ CrimeKind.SABOTAGE: _P(
325
+ kind=CrimeKind.SABOTAGE,
326
+ author_noun="sabotage mystery - machinery, a performance, or a life's work wrecked "
327
+ "by a familiar hand",
328
+ incident_noun="sabotage",
329
+ incident_line="The sabotage was carried out between {start} and {end} one evening.",
330
+ victim_ask="the victim - the person whose WORK WAS WRECKED (name, role, and in "
331
+ "cause_of_death one line on what failed and what it cost them) and which "
332
+ "room index (0-based) the tampering happened in",
333
+ instrument_ask="the TAMPERED MECHANISM (name, kind) - a severed cable, a loosened "
334
+ "valve, a doctored mixture, a cut harness",
335
+ deed_line="They sabotaged {victim}'s work - tampering with the {instrument} in the "
336
+ "{room} between {start} and {end} - then lied that they never left the "
337
+ "{claimed}.",
338
+ trace_ask="quietly place {culprit} in the {room} when the tampering was done",
339
+ brief_deed="You sabotaged {victim}'s work by tampering with the {instrument}. "
340
+ "{victim} survived, but you must hide what you did.",
341
+ confession_verbs=r"sabotaged|tampered|rigged\s+it|cut\s+the|loosened\s+the|wrecked\s+it",
342
+ perp_noun="saboteur",
343
+ division="INCIDENT UNIT",
344
+ victim_status="RUINED",
345
+ verdict="Sabotage",
346
+ kind_label="SABOTAGE",
347
+ tod_label="FAILED AT",
348
+ found_verb="Tampered with in",
349
+ timeline_line="The {instrument} gives way in the {room}.",
350
+ boot_line="It did not break. It was broken.",
351
+ fallback_titles=(
352
+ "The {room} Failure", "What Gave Way at {setting}", "The {last} Collapse",
353
+ "A Loose Thread in the {room}", "The {setting} Incident", "Made to Fail",
354
+ ),
355
+ ),
356
+ }
357
+
358
+ # Weighted draw: homicide stays the backbone of a noir game; the rest add variety.
359
+ _KIND_BAG: tuple[CrimeKind, ...] = (
360
+ CrimeKind.HOMICIDE, CrimeKind.HOMICIDE, CrimeKind.HOMICIDE,
361
+ CrimeKind.THEFT, CrimeKind.THEFT,
362
+ CrimeKind.FRAUD, CrimeKind.BLACKMAIL, CrimeKind.ARSON, CrimeKind.MISSING,
363
+ CrimeKind.CON, CrimeKind.POISONING, CrimeKind.RANSOM, CrimeKind.SABOTAGE,
364
+ )
365
+
366
+
367
+ def profile_for(kind: CrimeKind | str) -> CrimeProfile:
368
+ return PROFILES[CrimeKind(kind)]
369
+
370
+
371
+ def kind_for_seed(seed: int) -> CrimeKind:
372
+ return _KIND_BAG[seed % len(_KIND_BAG)]
src/case_zero/schemas/enums.py CHANGED
@@ -29,6 +29,10 @@ class CrimeKind(StrEnum):
29
  BLACKMAIL = "blackmail"
30
  ARSON = "arson"
31
  MISSING = "missing_person"
 
 
 
 
32
 
33
 
34
  class DiscoveryMethod(StrEnum):
 
29
  BLACKMAIL = "blackmail"
30
  ARSON = "arson"
31
  MISSING = "missing_person"
32
+ CON = "con"
33
+ POISONING = "poisoning"
34
+ RANSOM = "ransom"
35
+ SABOTAGE = "sabotage"
36
 
37
 
38
  class DiscoveryMethod(StrEnum):
tests/test_exhibits.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rich-exhibit synthesis: every payload kind has the exact wire shape the client
2
+ renders, and synthesized content never leaks sealed material."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+
9
+ from test_case_adapter import _casefile
10
+
11
+ from case_zero.api.case_adapter import casefile_to_public
12
+ from case_zero.api.exhibits import synthesize_payload
13
+ from case_zero.schemas.clue import Clue
14
+ from case_zero.schemas.enums import DiscoveryMethod
15
+
16
+
17
+ def _clue(case, name: str, reveal: str, method: DiscoveryMethod = DiscoveryMethod.SEARCH) -> Clue:
18
+ return Clue(
19
+ clue_id="C_t1",
20
+ name=name,
21
+ reveal_text=reveal,
22
+ discoverable_at_loc_id=case.setting.locations[0].loc_id,
23
+ discovery_method=method,
24
+ )
25
+
26
+
27
+ def test_phone_thread_shape():
28
+ case = _casefile()
29
+ clue = _clue(case, "burner phone", "You can't announce it tonight. Meet me first.")
30
+ out = synthesize_payload(case, clue, "phone", 0, 21 * 60 + 30, "9:30 PM")
31
+ assert out["type"] == "PHONE"
32
+ msgs = out["thread"]
33
+ assert 3 <= len(msgs) <= 5
34
+ for m in msgs:
35
+ assert m.from_ in ("me", "them")
36
+ assert re.match(r"^\d{1,2}:\d{2}$", m.t)
37
+ # the climactic lines are the clue's own reveal text, split at sentence bounds
38
+ joined = " ".join(m.m for m in msgs)
39
+ assert "Meet me first." in joined
40
+ # nobody is named unless the reveal names them
41
+ assert all(m.who in ("UNKNOWN", "CRANE") for m in msgs)
42
+
43
+
44
+ def test_phone_thread_names_only_verbatim_suspects():
45
+ case = _casefile()
46
+ clue = _clue(case, "message slip", "Miles Ardent never answered the last message.")
47
+ out = synthesize_payload(case, clue, "phone", 0, 21 * 60 + 30, "9:30 PM")
48
+ whos = {m.who for m in out["thread"]}
49
+ assert "ARDENT" in whos
50
+
51
+
52
+ def test_voicemail_shape():
53
+ case = _casefile()
54
+ clue = _clue(case, "wax cylinder recording", "I heard them argue about the press.")
55
+ out = synthesize_payload(case, clue, "voicemail", 0, 21 * 60, "9:00 PM")
56
+ assert out["type"] == "AUDIO"
57
+ assert out["transcript"].startswith("“")
58
+ assert re.match(r"^0:\d{2}$", out["dur"])
59
+
60
+
61
+ def test_keycard_rows_shape():
62
+ case = _casefile()
63
+ clue = _clue(case, "keycard log", "The service door opened twice after closing.")
64
+ out = synthesize_payload(case, clue, "keycard", 0, 21 * 60 + 41, "9:41 PM")
65
+ assert out["type"] == "DATA"
66
+ rows = out["rows"]
67
+ assert 2 <= len(rows) <= 6
68
+ assert all(len(r) == 4 for r in rows)
69
+ assert any(r[3] == "flag" for r in rows)
70
+ flag = next(r for r in rows if r[3] == "flag")
71
+ assert flag[2] == "UNREGISTERED" # reveal names nobody
72
+ assert [r[0] for r in rows] == sorted(r[0] for r in rows)
73
+
74
+
75
+ def test_cctv_and_photo_shapes():
76
+ case = _casefile()
77
+ cam = synthesize_payload(case, _clue(case, "cctv still", "A pale coat at the rail."), "cctv", 0, 0, "11:43 PM")
78
+ assert cam["type"] == "IMAGE" and cam["detail"].startswith("CAM —")
79
+ photo = synthesize_payload(case, _clue(case, "old photograph", "Two figures by the gate."), "photoEv", 2, 0, "9:00 PM")
80
+ assert photo["type"] == "IMAGE" and photo["icon"] == "photoEv" and "EVIDENCE MARKER 3" in photo["detail"]
81
+
82
+
83
+ def test_forensic_in_situ_keeps_icon():
84
+ case = _casefile()
85
+ clue = _clue(case, "partial fingerprint", "A partial print on the latch.", DiscoveryMethod.FORENSIC)
86
+ out = synthesize_payload(case, clue, "fingerprint", 0, 0, "9:00 PM")
87
+ assert out["type"] == "IMAGE"
88
+ assert "icon" not in out # the object icon stays
89
+
90
+
91
+ def test_paper_fallthrough():
92
+ case = _casefile()
93
+ out = synthesize_payload(case, _clue(case, "pawn ticket", "A stub from the city den."), "receipt", 0, 0, "9:00 PM")
94
+ assert out == {}
95
+
96
+
97
+ def test_synthesized_case_does_not_leak_sealed_material():
98
+ case = _casefile()
99
+ blob = json.dumps(casefile_to_public(case).model_dump(by_alias=True))
100
+ assert case.culprit.method_narrative not in blob
101
+ for s in case.suspects:
102
+ for secret in s.secrets:
103
+ assert secret not in blob
tests/test_runtime_pool.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Never-repeat pool logic: excluded case ids are never dealt, the shared rotation is
2
+ untouched by per-player filtering, and the fallback ladder degrades gracefully."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from test_case_adapter import _casefile
7
+
8
+ from case_zero.api.runtime import GameRuntime
9
+
10
+
11
+ def _pool(ids: list[str]):
12
+ base = _casefile()
13
+ return [base.model_copy(update={"case_id": cid}) for cid in ids]
14
+
15
+
16
+ def _runtime_with(ids: list[str]) -> GameRuntime:
17
+ rt = GameRuntime()
18
+ rt._prebaked_loaded = True
19
+ rt._prebaked = _pool(ids)
20
+ return rt
21
+
22
+
23
+ def test_rotation_unchanged_without_exclusions():
24
+ rt = _runtime_with(["A", "B", "C"])
25
+ dealt = [rt._take_prebaked().case_id for _ in range(3)]
26
+ assert dealt == ["A", "B", "C"]
27
+ assert rt._prebaked_idx == 3
28
+
29
+
30
+ def test_excluded_ids_never_dealt_and_rotation_untouched():
31
+ rt = _runtime_with(["A", "B", "C"])
32
+ for _ in range(20):
33
+ case = rt._take_prebaked({"A", "C"})
34
+ assert case.case_id == "B"
35
+ assert rt._prebaked_idx == 0 # per-player picks never advance the shared rotation
36
+
37
+
38
+ def test_all_excluded_returns_none():
39
+ rt = _runtime_with(["A", "B"])
40
+ assert rt._take_prebaked({"A", "B"}) is None
41
+
42
+
43
+ def test_excluded_buffered_case_stays_buffered():
44
+ rt = _runtime_with(["A"])
45
+ buffered = _pool(["FRESH"])[0]
46
+ rt._buffer = buffered
47
+ assert rt._take_buffered({"FRESH"}) is None
48
+ assert rt._buffer is buffered # still there for another player
49
+ assert rt._take_buffered(set()).case_id == "FRESH"
50
+ assert rt._buffer is None
51
+
52
+
53
+ def test_pressure_heuristic_spawns_generation(monkeypatch):
54
+ rt = _runtime_with(["A", "B", "C"])
55
+ spawned = []
56
+ monkeypatch.setattr(rt, "available", lambda: True)
57
+ monkeypatch.setattr(rt, "_spawn_gen", lambda: spawned.append(1))
58
+ monkeypatch.setattr(rt, "_register", lambda case: (case.case_id, "run"))
59
+ monkeypatch.setattr(rt, "_maybe_refill", lambda: None)
60
+ # two of three already played -> fewer than 3 unplayed -> pressure spawn
61
+ out = rt.new_generated_run(exclude=["A", "B"])
62
+ assert out == ("C", "run")
63
+ assert spawned
64
+
65
+
66
+ def test_lru_fallback_when_generation_fails(monkeypatch):
67
+ rt = _runtime_with(["A", "B"])
68
+ monkeypatch.setattr(rt, "available", lambda: True)
69
+ monkeypatch.setattr(rt, "_spawn_gen", lambda: None)
70
+ monkeypatch.setattr(rt, "_maybe_refill", lambda: None)
71
+
72
+ def boom(seed, **kw):
73
+ raise RuntimeError("no model")
74
+
75
+ monkeypatch.setattr(rt, "_generate", boom)
76
+ registered = []
77
+
78
+ def fake_load(case_id):
79
+ registered.append(case_id)
80
+ return (case_id, "run")
81
+
82
+ monkeypatch.setattr(rt, "load_generated_run", fake_load)
83
+ # everything played: generation fails -> oldest played id is re-dealt
84
+ out = rt.new_generated_run(exclude=["A", "B"])
85
+ assert out == ("A", "run")
86
+ assert registered == ["A"]
web/src/api.ts CHANGED
@@ -1,71 +1,73 @@
1
- // Typed client for the Case Zero game API (served by gradio.Server under /api).
2
- import type { InterrogateResult, PublicCase, VerdictResult } from './types'
3
-
4
- export interface CaseResponse {
5
- caseId: string
6
- runId: string
7
- case: PublicCase
8
- }
9
-
10
- async function postJSON<T>(url: string, body: unknown): Promise<T> {
11
- const res = await fetch(url, {
12
- method: 'POST',
13
- headers: { 'Content-Type': 'application/json' },
14
- body: JSON.stringify(body),
15
- })
16
- if (!res.ok) throw new Error(`POST ${url} failed: ${res.status}`)
17
- return (await res.json()) as T
18
- }
19
-
20
- async function getJSON<T>(url: string): Promise<T> {
21
- const res = await fetch(url)
22
- if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`)
23
- return (await res.json()) as T
24
- }
25
-
26
- export interface NewCaseRequest {
27
- seed?: number
28
- caseId?: string
29
- difficulty?: string
30
- }
31
-
32
- export function newCase(req: NewCaseRequest = {}): Promise<CaseResponse> {
33
- return postJSON<CaseResponse>('/api/case', req)
34
- }
35
-
36
- export function getCase(caseId: string): Promise<CaseResponse> {
37
- return getJSON<CaseResponse>(`/api/case/${encodeURIComponent(caseId)}`)
38
- }
39
-
40
- export interface InterrogateBody {
41
- questionId?: string
42
- freeText?: string
43
- presentEvidenceId?: string
44
- }
45
-
46
- export function interrogate(
47
- runId: string,
48
- suspectId: string,
49
- body: InterrogateBody,
50
- ): Promise<InterrogateResult> {
51
- return postJSON<InterrogateResult>(
52
- `/api/run/${encodeURIComponent(runId)}/interrogate/${encodeURIComponent(suspectId)}`,
53
- body,
54
- )
55
- }
56
-
57
- export function getHint(runId: string, screen: string): Promise<{ hint: string }> {
58
- return getJSON<{ hint: string }>(
59
- `/api/run/${encodeURIComponent(runId)}/hint?screen=${encodeURIComponent(screen)}`,
60
- )
61
- }
62
-
63
- export interface AccuseBody {
64
- suspectId: string
65
- motiveId: string
66
- evidenceIds: string[]
67
- }
68
-
69
- export function accuse(runId: string, body: AccuseBody): Promise<VerdictResult> {
70
- return postJSON<VerdictResult>(`/api/run/${encodeURIComponent(runId)}/accuse`, body)
71
- }
 
 
 
1
+ // Typed client for the Case Zero game API (served by gradio.Server under /api).
2
+ import type { InterrogateResult, PublicCase, VerdictResult } from './types'
3
+
4
+ export interface CaseResponse {
5
+ caseId: string
6
+ runId: string
7
+ case: PublicCase
8
+ }
9
+
10
+ async function postJSON<T>(url: string, body: unknown): Promise<T> {
11
+ const res = await fetch(url, {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify(body),
15
+ })
16
+ if (!res.ok) throw new Error(`POST ${url} failed: ${res.status}`)
17
+ return (await res.json()) as T
18
+ }
19
+
20
+ async function getJSON<T>(url: string): Promise<T> {
21
+ const res = await fetch(url)
22
+ if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`)
23
+ return (await res.json()) as T
24
+ }
25
+
26
+ export interface NewCaseRequest {
27
+ seed?: number
28
+ caseId?: string
29
+ difficulty?: string
30
+ /** Case ids this player already started, oldest first — the server won't re-deal them. */
31
+ excludeCaseIds?: string[]
32
+ }
33
+
34
+ export function newCase(req: NewCaseRequest = {}): Promise<CaseResponse> {
35
+ return postJSON<CaseResponse>('/api/case', req)
36
+ }
37
+
38
+ export function getCase(caseId: string): Promise<CaseResponse> {
39
+ return getJSON<CaseResponse>(`/api/case/${encodeURIComponent(caseId)}`)
40
+ }
41
+
42
+ export interface InterrogateBody {
43
+ questionId?: string
44
+ freeText?: string
45
+ presentEvidenceId?: string
46
+ }
47
+
48
+ export function interrogate(
49
+ runId: string,
50
+ suspectId: string,
51
+ body: InterrogateBody,
52
+ ): Promise<InterrogateResult> {
53
+ return postJSON<InterrogateResult>(
54
+ `/api/run/${encodeURIComponent(runId)}/interrogate/${encodeURIComponent(suspectId)}`,
55
+ body,
56
+ )
57
+ }
58
+
59
+ export function getHint(runId: string, screen: string): Promise<{ hint: string }> {
60
+ return getJSON<{ hint: string }>(
61
+ `/api/run/${encodeURIComponent(runId)}/hint?screen=${encodeURIComponent(screen)}`,
62
+ )
63
+ }
64
+
65
+ export interface AccuseBody {
66
+ suspectId: string
67
+ motiveId: string
68
+ evidenceIds: string[]
69
+ }
70
+
71
+ export function accuse(runId: string, body: AccuseBody): Promise<VerdictResult> {
72
+ return postJSON<VerdictResult>(`/api/run/${encodeURIComponent(runId)}/accuse`, body)
73
+ }
web/src/app.tsx CHANGED
@@ -1,161 +1,172 @@
1
- // Root shell: loads the case from the server (or ?case=ID), then mounts the game.
2
- import { useEffect, useState } from 'preact/hooks'
3
-
4
- import { getCase, newCase } from './api'
5
- import { RainFX, prefersReducedMotion } from './engine/pixel'
6
- import { GameProvider, type Screen, useGame, useMode, useTweaks } from './store'
7
- import type { PublicCase } from './types'
8
- import { SCREENS } from './screens'
9
- import { TitleScreen } from './screens/cold'
10
- import { Assistant } from './ui/assistant'
11
- import { unlockAudioOnce } from './ui/audio'
12
- import { Btn } from './ui/components'
13
-
14
- const RAIN_SCREENS = new Set(['title', 'interro', 'briefing', 'verdict', 'flashback', 'story'])
15
-
16
- function ScreenHost() {
17
- const g = useGame()
18
- const Screen = SCREENS[g.state.screen] || TitleScreen
19
- const t = g.state.tweaks
20
- const showRain = t.rain && t.fx !== 'low' && !prefersReducedMotion() && RAIN_SCREENS.has(g.state.screen)
21
- return (
22
- <div class="app__stage">
23
- <div class="app__frame">
24
- <Screen key={g.state.screen} />
25
- <div class="fx-layer fx-scanlines" />
26
- <div class="fx-layer fx-vignette" />
27
- <div class="fx-layer fx-flicker" />
28
- {showRain && (
29
- <div class="fx-layer">
30
- <RainFX density={t.fx === 'high' ? 130 : 80} />
31
- </div>
32
- )}
33
- </div>
34
- <Assistant />
35
- </div>
36
- )
37
- }
38
-
39
- function Loading() {
40
- const showRain = !prefersReducedMotion()
41
- return (
42
- <div class="app__stage">
43
- <div class="app__frame" style={{ position: 'relative', background: 'var(--ink-0)' }}>
44
- {showRain && (
45
- <div class="fx-layer">
46
- <RainFX density={80} />
47
- </div>
48
- )}
49
- <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(80% 70% at 50% 45%, transparent 35%, rgba(8,11,16,.85) 100%)' }} />
50
- <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
51
- <div class="col center" style={{ gap: 14, textAlign: 'center' }}>
52
- <div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>CASE ZERO</div>
53
- <div class="t-display" style={{ fontSize: 'clamp(18px,4vw,28px)', color: 'var(--bone-3)' }}>FORMING A CASE<span class="cursor" /></div>
54
- <div class="t-mono dim" style={{ fontSize: 'calc(15px*var(--mono-scale))', maxWidth: 320 }}>The city, the body, the lies — coming together from the night wire.</div>
55
- </div>
56
- </div>
57
- <div class="fx-layer fx-scanlines" />
58
- <div class="fx-layer fx-vignette" />
59
- </div>
60
- </div>
61
- )
62
- }
63
-
64
- function ErrorView({ msg, retry }: { msg: string; retry: () => void }) {
65
- return (
66
- <div class="app__stage">
67
- <div class="app__frame">
68
- <div class="screen-center">
69
- <div class="panel panel--ox col center" style={{ gap: 14, maxWidth: 420, textAlign: 'center', padding: 24 }}>
70
- <div class="t-display ox" style={{ fontSize: 14 }}>THE WIRE WENT DEAD</div>
71
- <div class="t-body" style={{ color: 'var(--bone-2)' }}>{msg}</div>
72
- <Btn variant="amber" onClick={retry}>Try again</Btn>
73
- </div>
74
- </div>
75
- </div>
76
- </div>
77
- )
78
- }
79
-
80
- export function Root() {
81
- const [data, setData] = useState<{ case: PublicCase; runId: string } | null>(null)
82
- const [error, setError] = useState<string | null>(null)
83
- const [startScreen, setStartScreen] = useState<Screen>('title')
84
- const mode = useMode('auto') // always responsive to the real viewport
85
- const [tweaks, setTweak] = useTweaks()
86
-
87
- useEffect(() => {
88
- const r = document.documentElement
89
- r.setAttribute('data-palette', tweaks.palette)
90
- r.setAttribute('data-fonts', tweaks.fonts)
91
- r.setAttribute('data-fx', tweaks.fx)
92
- r.setAttribute('data-mood', tweaks.mood)
93
- window.__pxScale = tweaks.pixelScale
94
- }, [tweaks])
95
-
96
- useEffect(unlockAudioOnce, []) // grant audio playback on first tap (mobile autoplay policy)
97
-
98
- const load = () => {
99
- setError(null)
100
- setData(null)
101
- setStartScreen('title')
102
- const params = new URLSearchParams(window.location.search)
103
- const cid = params.get('case')
104
- const req = cid ? getCase(cid) : newCase()
105
- req.then((r) => setData({ case: r.case, runId: r.runId })).catch((e) => setError(e instanceof Error ? e.message : String(e)))
106
- }
107
- useEffect(load, [])
108
-
109
- // "Begin New Case" - always fetch a FRESH case from the server (never replay the loaded one)
110
- // and start playing it. A shared ?case= is cleared so a refresh won't snap back to it.
111
- const beginNewCase = () => {
112
- setError(null)
113
- setData(null)
114
- setStartScreen('story')
115
- const u = new URL(window.location.href)
116
- if (u.searchParams.has('case')) {
117
- u.searchParams.delete('case')
118
- window.history.replaceState({}, '', u.pathname + u.search)
119
- }
120
- newCase()
121
- .then((r) => setData({ case: r.case, runId: r.runId }))
122
- .catch((e) => setError(e instanceof Error ? e.message : String(e)))
123
- }
124
-
125
- // "Enter Case ID" - load that exact case (fresh run) and jump straight into playing it.
126
- const loadCaseById = (id: string) => {
127
- const cid = id.trim()
128
- if (!cid) return
129
- setError(null)
130
- setData(null)
131
- setStartScreen('story')
132
- const u = new URL(window.location.href)
133
- u.searchParams.set('case', cid)
134
- window.history.replaceState({}, '', u.pathname + u.search)
135
- getCase(cid)
136
- .then((r) => setData({ case: r.case, runId: r.runId }))
137
- .catch((e) => setError(e instanceof Error ? e.message : String(e)))
138
- }
139
-
140
- if (error) {
141
- return (
142
- <div class="app" data-mode={mode}>
143
- <ErrorView msg={error} retry={load} />
144
- </div>
145
- )
146
- }
147
- if (!data) {
148
- return (
149
- <div class="app" data-mode={mode}>
150
- <Loading />
151
- </div>
152
- )
153
- }
154
- return (
155
- <div class="app" data-mode={mode}>
156
- <GameProvider key={data.runId} case={data.case} runId={data.runId} mode={mode} tweaks={tweaks} setTweak={setTweak} initialScreen={startScreen} newCase={beginNewCase} loadCase={loadCaseById}>
157
- <ScreenHost />
158
- </GameProvider>
159
- </div>
160
- )
161
- }
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Root shell: loads the case from the server (or ?case=ID), then mounts the game.
2
+ import { useEffect, useState } from 'preact/hooks'
3
+
4
+ import { type CaseResponse, getCase, newCase } from './api'
5
+ import { RainFX, prefersReducedMotion } from './engine/pixel'
6
+ import { getCaseTheme, setCaseTheme, themeFromCase } from './engine/theme'
7
+ import { getPlayed, markPlayed } from './played'
8
+ import { GameProvider, type Screen, useGame, useMode, useTweaks } from './store'
9
+ import type { PublicCase } from './types'
10
+ import { SCREENS } from './screens'
11
+ import { TitleScreen } from './screens/cold'
12
+ import { Assistant } from './ui/assistant'
13
+ import { unlockAudioOnce } from './ui/audio'
14
+ import { Btn } from './ui/components'
15
+
16
+ const RAIN_SCREENS = new Set(['title', 'interro', 'briefing', 'verdict', 'flashback', 'story'])
17
+
18
+ function ScreenHost() {
19
+ const g = useGame()
20
+ const Screen = SCREENS[g.state.screen] || TitleScreen
21
+ const t = g.state.tweaks
22
+ const wet = ['rain', 'sleet'].includes(getCaseTheme().weather)
23
+ const showRain = wet && t.rain && t.fx !== 'low' && !prefersReducedMotion() && RAIN_SCREENS.has(g.state.screen)
24
+ return (
25
+ <div class="app__stage">
26
+ <div class="app__frame">
27
+ <Screen key={g.state.screen} />
28
+ <div class="fx-layer fx-scanlines" />
29
+ <div class="fx-layer fx-vignette" />
30
+ <div class="fx-layer fx-flicker" />
31
+ {showRain && (
32
+ <div class="fx-layer">
33
+ <RainFX density={t.fx === 'high' ? 130 : 80} />
34
+ </div>
35
+ )}
36
+ </div>
37
+ <Assistant />
38
+ </div>
39
+ )
40
+ }
41
+
42
+ function Loading() {
43
+ const showRain = !prefersReducedMotion()
44
+ return (
45
+ <div class="app__stage">
46
+ <div class="app__frame" style={{ position: 'relative', background: 'var(--ink-0)' }}>
47
+ {showRain && (
48
+ <div class="fx-layer">
49
+ <RainFX density={80} />
50
+ </div>
51
+ )}
52
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(80% 70% at 50% 45%, transparent 35%, rgba(8,11,16,.85) 100%)' }} />
53
+ <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
54
+ <div class="col center" style={{ gap: 14, textAlign: 'center' }}>
55
+ <div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>CASE ZERO</div>
56
+ <div class="t-display" style={{ fontSize: 'clamp(18px,4vw,28px)', color: 'var(--bone-3)' }}>FORMING A CASE<span class="cursor" /></div>
57
+ <div class="t-mono dim" style={{ fontSize: 'calc(15px*var(--mono-scale))', maxWidth: 320 }}>The city, the body, the lies — coming together from the night wire.</div>
58
+ </div>
59
+ </div>
60
+ <div class="fx-layer fx-scanlines" />
61
+ <div class="fx-layer fx-vignette" />
62
+ </div>
63
+ </div>
64
+ )
65
+ }
66
+
67
+ function ErrorView({ msg, retry }: { msg: string; retry: () => void }) {
68
+ return (
69
+ <div class="app__stage">
70
+ <div class="app__frame">
71
+ <div class="screen-center">
72
+ <div class="panel panel--ox col center" style={{ gap: 14, maxWidth: 420, textAlign: 'center', padding: 24 }}>
73
+ <div class="t-display ox" style={{ fontSize: 14 }}>THE WIRE WENT DEAD</div>
74
+ <div class="t-body" style={{ color: 'var(--bone-2)' }}>{msg}</div>
75
+ <Btn variant="amber" onClick={retry}>Try again</Btn>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ export function Root() {
84
+ const [data, setData] = useState<{ case: PublicCase; runId: string } | null>(null)
85
+ const [error, setError] = useState<string | null>(null)
86
+ const [startScreen, setStartScreen] = useState<Screen>('title')
87
+ const mode = useMode('auto') // always responsive to the real viewport
88
+ const [tweaks, setTweak] = useTweaks()
89
+
90
+ useEffect(() => {
91
+ const r = document.documentElement
92
+ r.setAttribute('data-palette', tweaks.palette)
93
+ r.setAttribute('data-fonts', tweaks.fonts)
94
+ r.setAttribute('data-fx', tweaks.fx)
95
+ r.setAttribute('data-mood', tweaks.mood)
96
+ window.__pxScale = tweaks.pixelScale
97
+ }, [tweaks])
98
+
99
+ useEffect(unlockAudioOnce, []) // grant audio playback on first tap (mobile autoplay policy)
100
+
101
+ // Every path a case arrives through: set its visual theme and remember it as
102
+ // played (so New Case never deals this browser the same mystery again).
103
+ const apply = (r: CaseResponse) => {
104
+ setCaseTheme(themeFromCase(r.case))
105
+ markPlayed(r.case.id)
106
+ setData({ case: r.case, runId: r.runId })
107
+ }
108
+
109
+ const load = () => {
110
+ setError(null)
111
+ setData(null)
112
+ setStartScreen('title')
113
+ const params = new URLSearchParams(window.location.search)
114
+ const cid = params.get('case')
115
+ const req = cid ? getCase(cid) : newCase({ excludeCaseIds: getPlayed() })
116
+ req.then(apply).catch((e) => setError(e instanceof Error ? e.message : String(e)))
117
+ }
118
+ useEffect(load, [])
119
+
120
+ // "Begin New Case" - always fetch a FRESH case from the server (never replay the loaded one)
121
+ // and start playing it. A shared ?case= is cleared so a refresh won't snap back to it.
122
+ const beginNewCase = () => {
123
+ setError(null)
124
+ setData(null)
125
+ setStartScreen('story')
126
+ const u = new URL(window.location.href)
127
+ if (u.searchParams.has('case')) {
128
+ u.searchParams.delete('case')
129
+ window.history.replaceState({}, '', u.pathname + u.search)
130
+ }
131
+ newCase({ excludeCaseIds: getPlayed() })
132
+ .then(apply)
133
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
134
+ }
135
+
136
+ // "Enter Case ID" - load that exact case (fresh run) and jump straight into playing it.
137
+ const loadCaseById = (id: string) => {
138
+ const cid = id.trim()
139
+ if (!cid) return
140
+ setError(null)
141
+ setData(null)
142
+ setStartScreen('story')
143
+ const u = new URL(window.location.href)
144
+ u.searchParams.set('case', cid)
145
+ window.history.replaceState({}, '', u.pathname + u.search)
146
+ getCase(cid)
147
+ .then(apply)
148
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
149
+ }
150
+
151
+ if (error) {
152
+ return (
153
+ <div class="app" data-mode={mode}>
154
+ <ErrorView msg={error} retry={load} />
155
+ </div>
156
+ )
157
+ }
158
+ if (!data) {
159
+ return (
160
+ <div class="app" data-mode={mode}>
161
+ <Loading />
162
+ </div>
163
+ )
164
+ }
165
+ return (
166
+ <div class="app" data-mode={mode}>
167
+ <GameProvider key={data.runId} case={data.case} runId={data.runId} mode={mode} tweaks={tweaks} setTweak={setTweak} initialScreen={startScreen} newCase={beginNewCase} loadCase={loadCaseById}>
168
+ <ScreenHost />
169
+ </GameProvider>
170
+ </div>
171
+ )
172
+ }
web/src/engine/art.ts CHANGED
@@ -4,6 +4,7 @@
4
  import { BAYER4, ditherGrad } from './draw'
5
  import type { Pal } from './draw'
6
  import type { ScenePainter } from './pixel'
 
7
 
8
  type Grid = string[][]
9
  interface Sprite {
@@ -236,29 +237,39 @@ export const BODIES: Record<string, Sprite> = {
236
  }
237
 
238
  // Gender-matched fallback casts for generated suspects (named portraits are golden-only).
239
- // A female suspect always draws a female portrait/body; a male suspect a male one.
240
- const _M_PORTRAITS: Sprite[] = [
241
- PORTRAITS.wexler, PORTRAITS.teo,
242
- makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i }),
243
- makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'bald', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, beard: '#5a4030' }),
244
- makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.y, hairHl: SPAL.Y, style: 'short', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, stubble: true }),
 
 
245
  ]
246
- const _F_PORTRAITS: Sprite[] = [
247
- PORTRAITS.iris, PORTRAITS.frost,
248
- makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'bun', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p }),
249
- makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p }),
250
- makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'wave', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p }),
 
251
  ]
252
- const _M_BODIES: Sprite[] = [
253
- BODIES.wexler, BODIES.teo,
254
- makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, legs: SPAL.m }),
255
- makeBody({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, legs: SPAL.m, beard: '#5a4030' }),
 
256
  ]
257
- const _F_BODIES: Sprite[] = [
258
- BODIES.iris, BODIES.frost,
259
- makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'long', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p }),
260
- makeBody({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p }),
 
261
  ]
 
 
 
 
 
262
  function _hash(s: string): number {
263
  let h = 0
264
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
@@ -267,15 +278,34 @@ function _hash(s: string): number {
267
  function _isFemale(gender?: string): boolean {
268
  return (gender || '').toLowerCase().startsWith('f')
269
  }
270
- export function portraitFor(id: string, gender?: string): Sprite {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  if (PORTRAITS[id]) return PORTRAITS[id]
272
- const pool = _isFemale(gender) ? _F_PORTRAITS : _M_PORTRAITS
273
- return pool[_hash(id) % pool.length]
 
 
274
  }
275
- export function bodyFor(id: string, gender?: string): Sprite {
276
  if (BODIES[id]) return BODIES[id]
277
- const pool = _isFemale(gender) ? _F_BODIES : _M_BODIES
278
- return pool[_hash(id) % pool.length]
 
 
279
  }
280
 
281
  export const IPAL: Record<string, string> = {
@@ -334,7 +364,21 @@ function lampCone(ctx: CanvasRenderingContext2D, cx: number, cy: number, w: numb
334
  }
335
  ctx.globalAlpha = 1
336
  }
 
 
337
  function rainStreaks(ctx: CanvasRenderingContext2D, w: number, h: number, t: number): void {
 
 
 
 
 
 
 
 
 
 
 
 
338
  ctx.fillStyle = 'rgba(170,190,200,0.22)'
339
  for (let i = 0; i < 40; i++) {
340
  const x = (i * 37 + t * 4) % w
@@ -343,7 +387,7 @@ function rainStreaks(ctx: CanvasRenderingContext2D, w: number, h: number, t: num
343
  }
344
  }
345
 
346
- const paintSkyline: ScenePainter = (ctx, w, h) => {
347
  const horizon = Math.floor(h * 0.5)
348
  ditherGrad(ctx, 0, 0, w, horizon, C.sky2, C.sky1)
349
  for (let y = horizon - 22; y < horizon; y++) {
@@ -355,25 +399,26 @@ const paintSkyline: ScenePainter = (ctx, w, h) => {
355
  }
356
  ditherGrad(ctx, 0, horizon, w, h - horizon, '#0c1620', '#0a121a')
357
  const dist = [[0.0, 0.16, 0.09], [0.08, 0.28, 0.07], [0.14, 0.18, 0.1], [0.23, 0.34, 0.08], [0.31, 0.22, 0.1], [0.4, 0.4, 0.09], [0.49, 0.2, 0.11], [0.58, 0.3, 0.09], [0.66, 0.24, 0.1], [0.75, 0.38, 0.08], [0.83, 0.2, 0.1], [0.91, 0.28, 0.1]]
 
358
  for (const [bx, bh, bw] of dist) {
359
  const x = Math.floor(bx * w)
360
- const bht = Math.floor(bh * h * 0.44)
361
  const wd = Math.ceil(bw * w)
362
  ctx.fillStyle = '#0a121a'; ctx.fillRect(x, horizon - bht, wd, bht)
363
  ctx.fillStyle = C.bldgL; ctx.fillRect(x, horizon - bht, 1, bht)
364
  for (let wy = horizon - bht + 3; wy < horizon - 2; wy += 5)
365
- for (let wx = x + 2; wx < x + wd - 2; wx += 4) { ctx.fillStyle = (wx + wy) % 3 ? C.winDim : C.win; ctx.fillRect(wx, wy, 2, 2) }
366
  }
367
  const fg = [[0.0, 0.4, 0.16], [0.15, 0.3, 0.13], [0.27, 0.52, 0.15], [0.41, 0.34, 0.12], [0.52, 0.46, 0.16], [0.67, 0.3, 0.14], [0.8, 0.5, 0.2]]
368
  for (const [bx, bh, bw] of fg) {
369
  const x = Math.floor(bx * w)
370
  const wd = Math.ceil(bw * w)
371
- const topY = Math.floor(h * (0.5 + (1 - bh) * 0.22))
372
  ctx.fillStyle = '#070d13'; ctx.fillRect(x, topY, wd, h - topY)
373
  ctx.fillStyle = '#0e1a24'; ctx.fillRect(x, topY, wd, 2)
374
  ctx.fillStyle = '#10202b'; ctx.fillRect(x, topY, 1, h - topY)
375
  for (let wy = topY + 5; wy < h - 3; wy += 8)
376
- for (let wx = x + 3; wx < x + wd - 4; wx += 8) { const lit = (wx * 3 + wy) % 5; ctx.fillStyle = lit === 0 ? C.amber : lit === 1 ? C.win : '#0c151d'; ctx.fillRect(wx, wy, 3, 4) }
377
  }
378
  }
379
 
@@ -527,13 +572,13 @@ const paintKitchen: ScenePainter = (ctx, w, h, t) => {
527
  rainStreaks(ctx, w, h * 0.4, t)
528
  }
529
 
530
- const paintStudy: ScenePainter = (ctx, w, h) => {
531
  ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0)
532
  const shelf = (sx: number, sw: number) => {
533
  ctx.fillStyle = _WOOD; ctx.fillRect(sx, 12, sw, h - 40)
534
  for (let sy = 18; sy < h - 34; sy += 16) {
535
  ctx.fillStyle = '#241a10'; ctx.fillRect(sx + 2, sy + 12, sw - 4, 3)
536
- for (let bx = sx + 3; bx < sx + sw - 3; bx += 3) { ctx.fillStyle = _BOOK[(bx + sy) % _BOOK.length]; ctx.fillRect(bx, sy, 2, 12) }
537
  }
538
  }
539
  shelf(8, Math.floor(w * 0.34))
@@ -549,22 +594,26 @@ const paintStudy: ScenePainter = (ctx, w, h) => {
549
  ctx.fillStyle = C.ox; ctx.fillRect(lx + 12, dy - 3, 3, 3)
550
  }
551
 
552
- const paintParlor: ScenePainter = (ctx, w, h, t) => {
553
  ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0)
554
  ctx.fillStyle = C.bldgL; ctx.fillRect(0, Math.floor(h * 0.55), w, 2)
555
- const fx = 12
 
 
556
  const fy = Math.floor(h * 0.32)
557
  ctx.fillStyle = '#1a1410'; ctx.fillRect(fx, fy, 40, h - fy - 22)
558
  ctx.fillStyle = _WOOD; ctx.fillRect(fx - 3, fy - 4, 46, 5)
559
  ctx.fillStyle = '#0a0805'; ctx.fillRect(fx + 8, fy + 10, 24, h - fy - 40)
560
- for (let i = 0; i < 30; i++) { const ex = fx + 10 + ((i * 7) % 20); const ey = h - 36 - ((i * 5) % 16); ctx.fillStyle = i % 3 ? C.amberD : C.amber; ctx.fillRect(ex, ey, 2, 2) }
561
- ctx.fillStyle = _WOOD_L; ctx.fillRect(w - 46, fy, 30, 22); ctx.fillStyle = C.slate; ctx.fillRect(w - 43, fy + 3, 24, 16)
 
562
  const sy = h - 30
563
  const sofX = Math.floor(w * 0.34)
564
  const sofW = Math.floor(w * 0.4)
565
- ctx.fillStyle = C.slate; ctx.fillRect(sofX, sy, sofW, 18)
 
566
  ctx.fillStyle = C.slateL; ctx.fillRect(sofX, sy, sofW, 4)
567
- ctx.fillStyle = C.slate; ctx.fillRect(sofX - 4, sy - 6, 6, 24); ctx.fillRect(sofX + sofW, sy - 6, 6, 24)
568
  ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 12, w, 12)
569
  ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.52), h - 8, 3, 3)
570
  lampCone(ctx, Math.floor(w * 0.5), 8, 50, h - 16)
@@ -590,14 +639,14 @@ const paintBedroom: ScenePainter = (ctx, w, h) => {
590
  ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 10, w, 10)
591
  }
592
 
593
- const paintAlley: ScenePainter = (ctx, w, h, t) => {
594
  ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
595
  // facing walls funneling to a lit back street
596
  ctx.fillStyle = '#0a121a'; ctx.fillRect(0, 0, Math.floor(w * 0.3), h)
597
  ctx.fillStyle = '#0c141c'; ctx.fillRect(Math.floor(w * 0.7), 0, Math.ceil(w * 0.3), h)
598
  for (let y = 8; y < h - 30; y += 14) {
599
  ctx.fillStyle = C.bldgL; ctx.fillRect(8, y, 14, 8); ctx.fillRect(w - 24, y + 5, 14, 8)
600
- ctx.fillStyle = (y % 28) ? C.winDim : C.win; ctx.fillRect(11, y + 2, 3, 3); ctx.fillRect(w - 21, y + 7, 3, 3)
601
  }
602
  // fire escape zig-zag on the left wall
603
  ctx.fillStyle = _METAL
@@ -609,11 +658,12 @@ const paintAlley: ScenePainter = (ctx, w, h, t) => {
609
  ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 18)
610
  ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 3)
611
  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)
612
- // buzzing sign at the alley mouth
613
  const sx = Math.floor(w * 0.62)
614
  ctx.fillStyle = C.shadow; ctx.fillRect(sx, 18, 3, 16)
615
- ctx.fillStyle = C.ox; ctx.fillRect(sx - 12, 20, 12, 12)
616
- ctx.fillStyle = '#b8443f'; ctx.fillRect(sx - 10, 22, 8, 8)
 
617
  lampCone(ctx, sx - 6, 32, 30, h - 60)
618
  rainStreaks(ctx, w, h, t)
619
  }
@@ -769,14 +819,14 @@ const paintRooftop: ScenePainter = (ctx, w, h, t) => {
769
  rainStreaks(ctx, w, h, t)
770
  }
771
 
772
- const paintOffice: ScenePainter = (ctx, w, h, t) => {
773
  ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
774
- // blinds with light slicing through
775
  const bw = Math.floor(w * 0.3)
776
  ctx.fillStyle = C.sky2; ctx.fillRect(w - bw - 10, 8, bw, 44)
777
- for (let y = 10; y < 50; y += 4) { ctx.fillStyle = (y % 8) ? C.shadow : C.winDim; ctx.fillRect(w - bw - 10, y, bw, 2) }
778
- // filing cabinets
779
- for (let i = 0; i < 2; i++) {
780
  const x = 10 + i * 22
781
  ctx.fillStyle = _METAL; ctx.fillRect(x, h - 64, 18, 40)
782
  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) }
@@ -1063,6 +1113,176 @@ const paintVault: ScenePainter = (ctx, w, h) => {
1063
  lampCone(ctx, Math.floor(w * 0.4), 2, 50, h - 30)
1064
  }
1065
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1066
  export const SCENES: Record<string, ScenePainter> = {
1067
  skyline: paintSkyline, desk: paintDesk, atrium: paintAtrium, interro: paintInterro,
1068
  seawall: paintSeawall, mezzanine: paintMezzanine, map: paintMap,
@@ -1071,6 +1291,7 @@ export const SCENES: Record<string, ScenePainter> = {
1071
  warehouse: paintWarehouse, rooftop: paintRooftop, office: paintOffice, lobby: paintLobby,
1072
  station: paintStation, garage: paintGarage, chapel: paintChapel, gallery: paintGallery,
1073
  cellar: paintCellar, greenhouse: paintGreenhouse, diner: paintDiner, vault: paintVault,
 
1074
  }
1075
 
1076
  // Map a free-text location name (generated cases invent rooms) to the closest set.
@@ -1092,9 +1313,9 @@ const _ROOM_MAP: [RegExp, string][] = [
1092
  [/vault|safe\s*room|strong\s*room|\bbank\b|deposit|counting\s*house/i, 'vault'],
1093
  [/greenhouse|conservatory|garden|orchard|arboretum|nursery\s*garden/i, 'greenhouse'],
1094
  [/diner|caf[eé]|coffee|canteen|tea\s*room|bistro|restaurant/i, 'diner'],
1095
- [/kitchen|pantry|galley|scullery|bakery/i, 'kitchen'],
1096
- [/librar|study|den\b|archive|records|reading\s*room/i, 'study'],
1097
- [/bed|chamber|boudoir|suite|nursery|dormitor/i, 'bedroom'],
1098
  [/mezzanine|\brail\b|balcon|landing|stairwell/i, 'mezzanine'],
1099
  [/dock|harbou?r|pier|seawall|wharf|seaside|waterfront|quay|marina|lighthouse/i, 'seawall'],
1100
  [/parlou?r|lounge|living|sitting|drawing|salon|terrace|veranda|smoking\s*room/i, 'parlor'],
@@ -1106,6 +1327,32 @@ export function sceneForRoom(name: string): ScenePainter {
1106
  return SCENES.parlor // generic interior
1107
  }
1108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1109
  // ---- exhibit illustrations ----
1110
  // Each exhibit gets a procedural "evidence photo": the object, large, on a forensic
1111
  // table under a spot, with a measuring strip. The kind is read from the exhibit's
@@ -1446,3 +1693,255 @@ export function sceneFor(name: string): ScenePainter {
1446
  const room = /[—–]/.test(name) ? name.split(/[—–]/).pop()!.trim() : name
1447
  return sceneForRoom(room)
1448
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import { BAYER4, ditherGrad } from './draw'
5
  import type { Pal } from './draw'
6
  import type { ScenePainter } from './pixel'
7
+ import { getCaseTheme } from './theme'
8
 
9
  type Grid = string[][]
10
  interface Sprite {
 
237
  }
238
 
239
  // Gender-matched fallback casts for generated suspects (named portraits are golden-only).
240
+ // Kept as OPTION sets so a per-case accent color can re-dress the same face; the base
241
+ // sprites (no accent) are prebuilt and identical to the original pools.
242
+ const _M_PORTRAIT_OPTS: PortraitOpts[] = [
243
+ { skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'slick', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.i, tie: SPAL.x, beard: '#7d7c72', stubble: true },
244
+ { skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, stubble: true },
245
+ { skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i },
246
+ { skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'bald', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, beard: '#5a4030' },
247
+ { skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.y, hairHl: SPAL.Y, style: 'short', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, stubble: true },
248
  ]
249
+ const _F_PORTRAIT_OPTS: PortraitOpts[] = [
250
+ { skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p },
251
+ { skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'wave', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, glasses: SPAL.g, lip: SPAL.p },
252
+ { skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'bun', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p },
253
+ { skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p },
254
+ { skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'wave', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p },
255
  ]
256
+ const _M_BODY_OPTS: PortraitOpts[] = [
257
+ { skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'slick', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.i, tie: SPAL.x, beard: '#7d7c72' },
258
+ { skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, legs: SPAL.m },
259
+ { skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, legs: SPAL.m },
260
+ { skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, legs: SPAL.m, beard: '#5a4030' },
261
  ]
262
+ const _F_BODY_OPTS: PortraitOpts[] = [
263
+ { skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p },
264
+ { skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'wave', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, glasses: SPAL.g, lip: SPAL.p },
265
+ { skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'long', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p },
266
+ { skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p },
267
  ]
268
+ const _M_PORTRAITS: Sprite[] = _M_PORTRAIT_OPTS.map((o) => makePortrait(o))
269
+ const _F_PORTRAITS: Sprite[] = _F_PORTRAIT_OPTS.map((o) => makePortrait(o))
270
+ const _M_BODIES: Sprite[] = _M_BODY_OPTS.map((o) => makeBody(o))
271
+ const _F_BODIES: Sprite[] = _F_BODY_OPTS.map((o) => makeBody(o))
272
+
273
  function _hash(s: string): number {
274
  let h = 0
275
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
 
278
  function _isFemale(gender?: string): boolean {
279
  return (gender || '').toLowerCase().startsWith('f')
280
  }
281
+
282
+ // Accent-recolored sprites are memoized: one rebuild per (face, accent) pair ever.
283
+ const _accentCache = new Map<string, Sprite>()
284
+ function _accented(kind: 'p' | 'b', female: boolean, idx: number, accent: string): Sprite {
285
+ const key = `${kind}:${female ? 'f' : 'm'}:${idx}:${accent}`
286
+ let s = _accentCache.get(key)
287
+ if (!s) {
288
+ const opts = kind === 'p' ? (female ? _F_PORTRAIT_OPTS : _M_PORTRAIT_OPTS) : (female ? _F_BODY_OPTS : _M_BODY_OPTS)
289
+ const o = { ...opts[idx], accent, tie: accent }
290
+ s = kind === 'p' ? makePortrait(o) : makeBody(o)
291
+ _accentCache.set(key, s)
292
+ }
293
+ return s
294
+ }
295
+
296
+ export function portraitFor(id: string, gender?: string, accent?: string): Sprite {
297
  if (PORTRAITS[id]) return PORTRAITS[id]
298
+ const female = _isFemale(gender)
299
+ const pool = female ? _F_PORTRAITS : _M_PORTRAITS
300
+ const idx = _hash(id) % pool.length
301
+ return accent ? _accented('p', female, idx, accent) : pool[idx]
302
  }
303
+ export function bodyFor(id: string, gender?: string, accent?: string): Sprite {
304
  if (BODIES[id]) return BODIES[id]
305
+ const female = _isFemale(gender)
306
+ const pool = female ? _F_BODIES : _M_BODIES
307
+ const idx = _hash(id) % pool.length
308
+ return accent ? _accented('b', female, idx, accent) : pool[idx]
309
  }
310
 
311
  export const IPAL: Record<string, string> = {
 
364
  }
365
  ctx.globalAlpha = 1
366
  }
367
+ // Weather-aware: every painter that calls this automatically honors the case's
368
+ // weather — fog and dry nights get no streaks, sleet falls in slanted steps.
369
  function rainStreaks(ctx: CanvasRenderingContext2D, w: number, h: number, t: number): void {
370
+ const wk = getCaseTheme().weather
371
+ if (wk === 'fog' || wk === 'dry') return
372
+ if (wk === 'sleet') {
373
+ ctx.fillStyle = 'rgba(195,205,215,0.24)'
374
+ for (let i = 0; i < 30; i++) {
375
+ const x = (i * 43 + t * 5) % w
376
+ const y = (i * 53 + t * 7) % h
377
+ ctx.fillRect(Math.floor(x), Math.floor(y), 1, 2)
378
+ ctx.fillRect(Math.floor(x) + 1, Math.floor(y) + 2, 1, 2)
379
+ }
380
+ return
381
+ }
382
  ctx.fillStyle = 'rgba(170,190,200,0.22)'
383
  for (let i = 0; i < 40; i++) {
384
  const x = (i * 37 + t * 4) % w
 
387
  }
388
  }
389
 
390
+ const paintSkyline: ScenePainter = (ctx, w, h, _t, seed = 0) => {
391
  const horizon = Math.floor(h * 0.5)
392
  ditherGrad(ctx, 0, 0, w, horizon, C.sky2, C.sky1)
393
  for (let y = horizon - 22; y < horizon; y++) {
 
399
  }
400
  ditherGrad(ctx, 0, horizon, w, h - horizon, '#0c1620', '#0a121a')
401
  const dist = [[0.0, 0.16, 0.09], [0.08, 0.28, 0.07], [0.14, 0.18, 0.1], [0.23, 0.34, 0.08], [0.31, 0.22, 0.1], [0.4, 0.4, 0.09], [0.49, 0.2, 0.11], [0.58, 0.3, 0.09], [0.66, 0.24, 0.1], [0.75, 0.38, 0.08], [0.83, 0.2, 0.1], [0.91, 0.28, 0.1]]
402
+ let bi = 0
403
  for (const [bx, bh, bw] of dist) {
404
  const x = Math.floor(bx * w)
405
+ const bht = Math.floor(bh * h * 0.44) + ((seed >> (bi++ % 8)) & 3)
406
  const wd = Math.ceil(bw * w)
407
  ctx.fillStyle = '#0a121a'; ctx.fillRect(x, horizon - bht, wd, bht)
408
  ctx.fillStyle = C.bldgL; ctx.fillRect(x, horizon - bht, 1, bht)
409
  for (let wy = horizon - bht + 3; wy < horizon - 2; wy += 5)
410
+ for (let wx = x + 2; wx < x + wd - 2; wx += 4) { ctx.fillStyle = (wx + wy + seed) % 3 ? C.winDim : C.win; ctx.fillRect(wx, wy, 2, 2) }
411
  }
412
  const fg = [[0.0, 0.4, 0.16], [0.15, 0.3, 0.13], [0.27, 0.52, 0.15], [0.41, 0.34, 0.12], [0.52, 0.46, 0.16], [0.67, 0.3, 0.14], [0.8, 0.5, 0.2]]
413
  for (const [bx, bh, bw] of fg) {
414
  const x = Math.floor(bx * w)
415
  const wd = Math.ceil(bw * w)
416
+ const topY = Math.floor(h * (0.5 + (1 - bh) * 0.22)) + ((seed >> (bi++ % 8)) & 3)
417
  ctx.fillStyle = '#070d13'; ctx.fillRect(x, topY, wd, h - topY)
418
  ctx.fillStyle = '#0e1a24'; ctx.fillRect(x, topY, wd, 2)
419
  ctx.fillStyle = '#10202b'; ctx.fillRect(x, topY, 1, h - topY)
420
  for (let wy = topY + 5; wy < h - 3; wy += 8)
421
+ for (let wx = x + 3; wx < x + wd - 4; wx += 8) { const lit = (wx * 3 + wy + seed) % 5; ctx.fillStyle = lit === 0 ? C.amber : lit === 1 ? C.win : '#0c151d'; ctx.fillRect(wx, wy, 3, 4) }
422
  }
423
  }
424
 
 
572
  rainStreaks(ctx, w, h * 0.4, t)
573
  }
574
 
575
+ const paintStudy: ScenePainter = (ctx, w, h, _t, seed = 0) => {
576
  ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0)
577
  const shelf = (sx: number, sw: number) => {
578
  ctx.fillStyle = _WOOD; ctx.fillRect(sx, 12, sw, h - 40)
579
  for (let sy = 18; sy < h - 34; sy += 16) {
580
  ctx.fillStyle = '#241a10'; ctx.fillRect(sx + 2, sy + 12, sw - 4, 3)
581
+ for (let bx = sx + 3; bx < sx + sw - 3; bx += 3) { ctx.fillStyle = _BOOK[(bx + sy + seed) % _BOOK.length]; ctx.fillRect(bx, sy, 2, 12) }
582
  }
583
  }
584
  shelf(8, Math.floor(w * 0.34))
 
594
  ctx.fillStyle = C.ox; ctx.fillRect(lx + 12, dy - 3, 3, 3)
595
  }
596
 
597
+ const paintParlor: ScenePainter = (ctx, w, h, t, seed = 0) => {
598
  ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0)
599
  ctx.fillStyle = C.bldgL; ctx.fillRect(0, Math.floor(h * 0.55), w, 2)
600
+ // fireplace flips sides per case; the painting takes the opposite wall
601
+ const flip = (seed & 1) === 1
602
+ const fx = flip ? w - 52 : 12
603
  const fy = Math.floor(h * 0.32)
604
  ctx.fillStyle = '#1a1410'; ctx.fillRect(fx, fy, 40, h - fy - 22)
605
  ctx.fillStyle = _WOOD; ctx.fillRect(fx - 3, fy - 4, 46, 5)
606
  ctx.fillStyle = '#0a0805'; ctx.fillRect(fx + 8, fy + 10, 24, h - fy - 40)
607
+ for (let i = 0; i < 30; i++) { const ex = fx + 10 + ((i * 7 + seed) % 20); const ey = h - 36 - ((i * 5 + seed) % 16); ctx.fillStyle = i % 3 ? C.amberD : C.amber; ctx.fillRect(ex, ey, 2, 2) }
608
+ const px = flip ? 16 : w - 46
609
+ ctx.fillStyle = _WOOD_L; ctx.fillRect(px, fy, 30, 22); ctx.fillStyle = C.slate; ctx.fillRect(px + 3, fy + 3, 24, 16)
610
  const sy = h - 30
611
  const sofX = Math.floor(w * 0.34)
612
  const sofW = Math.floor(w * 0.4)
613
+ const sofa = [C.slate, '#3f5161', '#54424a'][seed % 3]
614
+ ctx.fillStyle = sofa; ctx.fillRect(sofX, sy, sofW, 18)
615
  ctx.fillStyle = C.slateL; ctx.fillRect(sofX, sy, sofW, 4)
616
+ ctx.fillStyle = sofa; ctx.fillRect(sofX - 4, sy - 6, 6, 24); ctx.fillRect(sofX + sofW, sy - 6, 6, 24)
617
  ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 12, w, 12)
618
  ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.52), h - 8, 3, 3)
619
  lampCone(ctx, Math.floor(w * 0.5), 8, 50, h - 16)
 
639
  ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 10, w, 10)
640
  }
641
 
642
+ const paintAlley: ScenePainter = (ctx, w, h, t, seed = 0) => {
643
  ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
644
  // facing walls funneling to a lit back street
645
  ctx.fillStyle = '#0a121a'; ctx.fillRect(0, 0, Math.floor(w * 0.3), h)
646
  ctx.fillStyle = '#0c141c'; ctx.fillRect(Math.floor(w * 0.7), 0, Math.ceil(w * 0.3), h)
647
  for (let y = 8; y < h - 30; y += 14) {
648
  ctx.fillStyle = C.bldgL; ctx.fillRect(8, y, 14, 8); ctx.fillRect(w - 24, y + 5, 14, 8)
649
+ ctx.fillStyle = ((y + (seed % 14)) % 28) ? C.winDim : C.win; ctx.fillRect(11, y + 2, 3, 3); ctx.fillRect(w - 21, y + 7, 3, 3)
650
  }
651
  // fire escape zig-zag on the left wall
652
  ctx.fillStyle = _METAL
 
658
  ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 18)
659
  ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 3)
660
  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)
661
+ // buzzing sign at the alley mouth (hue varies per case)
662
  const sx = Math.floor(w * 0.62)
663
  ctx.fillStyle = C.shadow; ctx.fillRect(sx, 18, 3, 16)
664
+ const sign = [[C.ox, '#b8443f'], ['#1d5a52', '#2d8a7a'], ['#8a6a1e', '#c2a23f']][seed % 3]
665
+ ctx.fillStyle = sign[0]; ctx.fillRect(sx - 12, 20, 12, 12)
666
+ ctx.fillStyle = sign[1]; ctx.fillRect(sx - 10, 22, 8, 8)
667
  lampCone(ctx, sx - 6, 32, 30, h - 60)
668
  rainStreaks(ctx, w, h, t)
669
  }
 
819
  rainStreaks(ctx, w, h, t)
820
  }
821
 
822
+ const paintOffice: ScenePainter = (ctx, w, h, t, seed = 0) => {
823
  ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0)
824
+ // blinds with light slicing through (slice phase varies per case)
825
  const bw = Math.floor(w * 0.3)
826
  ctx.fillStyle = C.sky2; ctx.fillRect(w - bw - 10, 8, bw, 44)
827
+ for (let y = 10; y < 50; y += 4) { ctx.fillStyle = ((y + (seed % 4) * 2) % 8) ? C.shadow : C.winDim; ctx.fillRect(w - bw - 10, y, bw, 2) }
828
+ // filing cabinets (2 or 3)
829
+ for (let i = 0; i < 2 + (seed % 2); i++) {
830
  const x = 10 + i * 22
831
  ctx.fillStyle = _METAL; ctx.fillRect(x, h - 64, 18, 40)
832
  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) }
 
1113
  lampCone(ctx, Math.floor(w * 0.4), 2, 50, h - 30)
1114
  }
1115
 
1116
+ // ---- establishing exteriors (the building the case happens in) ----
1117
+ const paintManor: ScenePainter = (ctx, w, h, t, seed = 0) => {
1118
+ const horizon = Math.floor(h * 0.64)
1119
+ ditherGrad(ctx, 0, 0, w, horizon, C.sky2, C.sky1)
1120
+ // low moon behind the roofline
1121
+ const mx = Math.floor(w * (0.72 + ((seed >> 2) % 3) * 0.06))
1122
+ ctx.fillStyle = '#b9c2c8'
1123
+ for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
1124
+ if (dx * dx + dy * dy <= 9 && (BAYER4[(dy + 8) & 3][(dx + 8) & 3] + 0.5) / 16 < 0.7) ctx.fillRect(mx + dx, Math.floor(h * 0.14) + dy, 1, 1)
1125
+ }
1126
+ // grounds
1127
+ ditherGrad(ctx, 0, horizon, w, h - horizon, '#0c141c', '#070b0f')
1128
+ // two-story body
1129
+ const bx = Math.floor(w * 0.2)
1130
+ const bw = Math.floor(w * 0.6)
1131
+ const by = Math.floor(h * 0.3)
1132
+ const bh = horizon - by
1133
+ ctx.fillStyle = '#0a1118'
1134
+ ctx.fillRect(bx, by, bw, bh)
1135
+ ctx.fillStyle = '#13202b'
1136
+ ctx.fillRect(bx, by, bw, 1)
1137
+ ctx.fillRect(bx, by, 1, bh)
1138
+ // gables: 2-3 stepped peaks along the roofline
1139
+ const gables = 2 + (seed % 2)
1140
+ for (let g = 0; g < gables; g++) {
1141
+ const gx = bx + Math.floor(((g + 0.5) / gables) * bw)
1142
+ for (let r = 0; r < 7; r++) {
1143
+ ctx.fillStyle = '#0a1118'
1144
+ ctx.fillRect(gx - (7 - r), by - r, (7 - r) * 2, 1)
1145
+ }
1146
+ ctx.fillStyle = '#13202b'
1147
+ ctx.fillRect(gx, by - 7, 1, 1)
1148
+ }
1149
+ // windows: tall pairs per floor, a few lit by seed
1150
+ for (let fy = 0; fy < 2; fy++) {
1151
+ const wy = by + 5 + fy * Math.floor(bh * 0.45)
1152
+ for (let i = 0; i < 5; i++) {
1153
+ const wx = bx + 6 + i * Math.floor((bw - 12) / 4.6)
1154
+ const lit = ((i * 7 + fy * 13 + seed) % 5) < 2
1155
+ ctx.fillStyle = lit ? C.win : '#101b24'
1156
+ ctx.fillRect(wx, wy, 3, 5)
1157
+ if (lit) { ctx.fillStyle = C.winDim; ctx.fillRect(wx, wy + 3, 3, 2) }
1158
+ }
1159
+ }
1160
+ // door + steps
1161
+ const dx = bx + Math.floor(bw / 2) - 2
1162
+ ctx.fillStyle = '#1a1208'
1163
+ ctx.fillRect(dx, horizon - 8, 5, 8)
1164
+ ctx.fillStyle = C.winDim
1165
+ ctx.fillRect(dx + 3, horizon - 5, 1, 1)
1166
+ ctx.fillStyle = '#0e1820'
1167
+ ctx.fillRect(dx - 2, horizon, 9, 2)
1168
+ // hedge + gate posts
1169
+ ctx.fillStyle = '#0c1812'
1170
+ for (let x = 0; x < w; x += 3) ctx.fillRect(x, horizon + Math.floor(h * 0.1) + ((x >> 2) % 2), 3, 4)
1171
+ ctx.fillStyle = '#161616'
1172
+ ctx.fillRect(Math.floor(w * 0.47), horizon + Math.floor(h * 0.08), 2, 8)
1173
+ ctx.fillRect(Math.floor(w * 0.53), horizon + Math.floor(h * 0.08), 2, 8)
1174
+ // lamppost by the walk
1175
+ const lx = Math.floor(w * 0.12)
1176
+ ctx.fillStyle = '#161c22'
1177
+ ctx.fillRect(lx, horizon - 16, 1, Math.floor(h * 0.26))
1178
+ ctx.fillStyle = C.lamp
1179
+ ctx.fillRect(lx - 1, horizon - 18, 3, 3)
1180
+ lampCone(ctx, lx, horizon - 15, 26, 30)
1181
+ rainStreaks(ctx, w, h, t)
1182
+ }
1183
+
1184
+ const paintFacade: ScenePainter = (ctx, w, h, t, seed = 0) => {
1185
+ const ground = Math.floor(h * 0.8)
1186
+ ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.4), C.sky1, C.sky2)
1187
+ // building face
1188
+ ctx.fillStyle = '#0d141c'
1189
+ ctx.fillRect(0, Math.floor(h * 0.16), w, ground - Math.floor(h * 0.16))
1190
+ // upper-floor windows
1191
+ for (let i = 0; i < 6; i++) {
1192
+ const wx = 8 + i * Math.floor((w - 16) / 5.6)
1193
+ ctx.fillStyle = ((i + seed) % 3) === 0 ? C.winDim : '#101b24'
1194
+ ctx.fillRect(wx, Math.floor(h * 0.22), 4, 6)
1195
+ }
1196
+ // awning: striped, color flips by seed
1197
+ const ay = Math.floor(h * 0.44)
1198
+ const aw = Math.floor(w * 0.62)
1199
+ const ax = Math.floor(w * 0.19)
1200
+ for (let x = 0; x < aw; x += 4) {
1201
+ ctx.fillStyle = (Math.floor(x / 4) % 2) === 0 ? (seed % 2 ? C.ox : '#2d4a52') : C.bone
1202
+ ctx.fillRect(ax + x, ay, Math.min(4, aw - x), 4)
1203
+ }
1204
+ ctx.fillStyle = '#070b0f'
1205
+ ctx.fillRect(ax, ay + 4, aw, 1)
1206
+ // glowing plate glass + door
1207
+ const gy = ay + 6
1208
+ const gh = ground - gy
1209
+ ctx.fillStyle = '#2a2214'
1210
+ ctx.fillRect(ax + 2, gy, aw - 14, gh)
1211
+ for (let y = gy; y < ground; y++) for (let x = ax + 2; x < ax + aw - 12; x++) {
1212
+ const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
1213
+ if (thr < 0.32) { ctx.fillStyle = C.winDim; ctx.fillRect(x, y, 1, 1) }
1214
+ }
1215
+ ctx.fillStyle = '#191007'
1216
+ ctx.fillRect(ax + aw - 10, gy, 8, gh)
1217
+ ctx.fillStyle = C.win
1218
+ ctx.fillRect(ax + aw - 5, gy + Math.floor(gh / 2), 1, 1)
1219
+ // hanging sign on a bracket
1220
+ ctx.fillStyle = '#161c22'
1221
+ ctx.fillRect(ax - 7, Math.floor(h * 0.3), 8, 1)
1222
+ ctx.fillStyle = seed % 2 ? C.amber : C.slateL
1223
+ ctx.fillRect(ax - 8, Math.floor(h * 0.3) + 1, 6, 7)
1224
+ ctx.fillStyle = '#070b0f'
1225
+ ctx.fillRect(ax - 7, Math.floor(h * 0.3) + 3, 4, 1)
1226
+ ctx.fillRect(ax - 7, Math.floor(h * 0.3) + 5, 4, 1)
1227
+ // wet pavement
1228
+ ditherGrad(ctx, 0, ground, w, h - ground, '#0e161e', '#080d12')
1229
+ ctx.fillStyle = 'rgba(224,164,76,0.10)'
1230
+ ctx.fillRect(ax + 2, ground, aw - 14, Math.floor((h - ground) * 0.7))
1231
+ rainStreaks(ctx, w, h, t)
1232
+ }
1233
+
1234
+ const paintTower: ScenePainter = (ctx, w, h, t, seed = 0) => {
1235
+ ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky1)
1236
+ // low rooftops
1237
+ const base = Math.floor(h * 0.78)
1238
+ for (let i = 0; i < 7; i++) {
1239
+ const rx = i * Math.floor(w / 6.5)
1240
+ const rh = Math.floor(h * (0.1 + ((i * 5 + seed) % 4) * 0.03))
1241
+ ctx.fillStyle = '#0a121a'
1242
+ ctx.fillRect(rx, base - rh, Math.floor(w / 6) + 2, rh + (h - base))
1243
+ ctx.fillStyle = C.winDim
1244
+ if ((i + seed) % 3 === 0) ctx.fillRect(rx + 4, base - rh + 3, 2, 2)
1245
+ }
1246
+ // tower shaft
1247
+ const tx = Math.floor(w * 0.44)
1248
+ const tw = Math.floor(w * 0.13)
1249
+ const ty = Math.floor(h * 0.1)
1250
+ ctx.fillStyle = '#0c141c'
1251
+ ctx.fillRect(tx, ty, tw, base - ty)
1252
+ ctx.fillStyle = '#15222d'
1253
+ ctx.fillRect(tx, ty, 1, base - ty)
1254
+ ctx.fillRect(tx, ty, tw, 1)
1255
+ // belfry ledge + cap
1256
+ ctx.fillStyle = '#0c141c'
1257
+ ctx.fillRect(tx - 2, ty + 2, tw + 4, 2)
1258
+ for (let r = 0; r < 6; r++) ctx.fillRect(tx + r, ty - 5 + r, tw - r * 2, 1)
1259
+ // lit clock face
1260
+ const cx = tx + Math.floor(tw / 2)
1261
+ const cy = ty + Math.floor(h * 0.13)
1262
+ const rad = Math.floor(tw * 0.42)
1263
+ ctx.fillStyle = C.lamp
1264
+ for (let dy = -rad; dy <= rad; dy++) for (let dx = -rad; dx <= rad; dx++) {
1265
+ if (dx * dx + dy * dy <= rad * rad) ctx.fillRect(cx + dx, cy + dy, 1, 1)
1266
+ }
1267
+ ctx.fillStyle = '#3a2a10'
1268
+ ctx.fillRect(cx, cy - rad + 1, 1, rad - 1) // minute hand to 12
1269
+ const hourDx = ((seed % 3) - 1) * 2
1270
+ ctx.fillRect(cx, cy, hourDx === 0 ? 1 : Math.abs(hourDx), 1)
1271
+ if (hourDx < 0) ctx.fillRect(cx + hourDx, cy, -hourDx, 1)
1272
+ // tall shaft windows
1273
+ ctx.fillStyle = '#101b24'
1274
+ ctx.fillRect(cx - 1, cy + rad + 4, 3, 8)
1275
+ ctx.fillStyle = C.winDim
1276
+ ctx.fillRect(cx - 1, base - 12, 3, 5)
1277
+ // mist band at the base
1278
+ ctx.fillStyle = 'rgba(170,185,195,0.22)'
1279
+ for (let y = base - 4; y < base + 4 && y < h; y++) for (let x = 0; x < w; x++) {
1280
+ const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
1281
+ if (thr < 0.3) ctx.fillRect(x, y, 1, 1)
1282
+ }
1283
+ rainStreaks(ctx, w, h, t)
1284
+ }
1285
+
1286
  export const SCENES: Record<string, ScenePainter> = {
1287
  skyline: paintSkyline, desk: paintDesk, atrium: paintAtrium, interro: paintInterro,
1288
  seawall: paintSeawall, mezzanine: paintMezzanine, map: paintMap,
 
1291
  warehouse: paintWarehouse, rooftop: paintRooftop, office: paintOffice, lobby: paintLobby,
1292
  station: paintStation, garage: paintGarage, chapel: paintChapel, gallery: paintGallery,
1293
  cellar: paintCellar, greenhouse: paintGreenhouse, diner: paintDiner, vault: paintVault,
1294
+ manor: paintManor, facade: paintFacade, tower: paintTower,
1295
  }
1296
 
1297
  // Map a free-text location name (generated cases invent rooms) to the closest set.
 
1313
  [/vault|safe\s*room|strong\s*room|\bbank\b|deposit|counting\s*house/i, 'vault'],
1314
  [/greenhouse|conservatory|garden|orchard|arboretum|nursery\s*garden/i, 'greenhouse'],
1315
  [/diner|caf[eé]|coffee|canteen|tea\s*room|bistro|restaurant/i, 'diner'],
1316
+ [/kitchen|pantry|galley|scullery|bakery|oven/i, 'kitchen'],
1317
+ [/librar|study|den\b|archive|records|reading\s*room|biblioteca|stacks?\b/i, 'study'],
1318
+ [/bed|chamber|boudoir|suite|nursery|dormitor|guest\s*room|bath|washroom|lavator/i, 'bedroom'],
1319
  [/mezzanine|\brail\b|balcon|landing|stairwell/i, 'mezzanine'],
1320
  [/dock|harbou?r|pier|seawall|wharf|seaside|waterfront|quay|marina|lighthouse/i, 'seawall'],
1321
  [/parlou?r|lounge|living|sitting|drawing|salon|terrace|veranda|smoking\s*room/i, 'parlor'],
 
1327
  return SCENES.parlor // generic interior
1328
  }
1329
 
1330
+ // Map a PLACE (the building/venue a case happens in) to an establishing shot.
1331
+ // Specific venue words first; unknown venues read best as a lit storefront.
1332
+ const _PLACE_MAP: [RegExp, string][] = [
1333
+ [/manor|mansion|palace|estate|villa|ch[âa]teau|abbey|residence/i, 'manor'],
1334
+ [/tower|spire|belfry/i, 'tower'],
1335
+ [/museum|galler|exhibit/i, 'gallery'],
1336
+ [/hotel|\binn\b|lodge|resort/i, 'lobby'],
1337
+ [/librar|archive|ath?enaeum/i, 'facade'],
1338
+ [/theat|playhouse|cabaret|opera|club/i, 'theater'],
1339
+ [/casino|gambling/i, 'casino'],
1340
+ [/\bbank\b|vault|exchange|trust\s*house/i, 'vault'],
1341
+ [/church|chapel|cathedral|abbey/i, 'chapel'],
1342
+ [/station|terminus|railway/i, 'station'],
1343
+ [/dock|harbou?r|wharf|pier|marina|seawall|waterfront/i, 'seawall'],
1344
+ [/bakery|shop|store|boutique|emporium|pawn|salon|caf[eé]|diner|restaurant|bistro/i, 'facade'],
1345
+ [/precinct|district|city|downtown|quarter|street/i, 'skyline'],
1346
+ ]
1347
+
1348
+ export function sceneForPlace(name: string): ScenePainter {
1349
+ const n = (name || '').trim()
1350
+ if (!n) return SCENES.skyline
1351
+ if (SCENES[n]) return SCENES[n] // exact painter key (golden beats pass 'skyline')
1352
+ for (const [re, key] of _PLACE_MAP) if (re.test(n)) return SCENES[key]
1353
+ return SCENES.facade
1354
+ }
1355
+
1356
  // ---- exhibit illustrations ----
1357
  // Each exhibit gets a procedural "evidence photo": the object, large, on a forensic
1358
  // table under a spot, with a measuring strip. The kind is read from the exhibit's
 
1693
  const room = /[—–]/.test(name) ? name.split(/[—–]/).pop()!.trim() : name
1694
  return sceneForRoom(room)
1695
  }
1696
+
1697
+ // ---- incident vignettes ----
1698
+ // The story beat that carries the MAIN ACTION ("…was poisoned", "…was stolen") draws
1699
+ // the act itself, big and center-stage, over the dimmed crime room: you should SEE the
1700
+ // crime, not just the wallpaper. One vignette per crime kind.
1701
+ type IncidentVignette = (ctx: CanvasRenderingContext2D, w: number, h: number) => void
1702
+
1703
+ function _evidenceTent(ctx: CanvasRenderingContext2D, x: number, y: number): void {
1704
+ for (let r = 0; r < 7; r++) {
1705
+ ctx.fillStyle = C.amber
1706
+ ctx.fillRect(x - r, y + r, r * 2 + 1, 1)
1707
+ }
1708
+ ctx.fillStyle = '#3a2a10'
1709
+ ctx.fillRect(x - 1, y + 3, 3, 2)
1710
+ }
1711
+
1712
+ const _INCIDENTS: Record<string, IncidentVignette> = {
1713
+ homicide: (ctx, w, h) => {
1714
+ const cx = Math.floor(w / 2)
1715
+ const gy = Math.floor(h * 0.74)
1716
+ // blood pool, dithered
1717
+ for (let y = -2; y < 9; y++) for (let x = -30; x < 30; x++) {
1718
+ if ((x * x) / 900 + (y * y) / 81 <= 1 && (BAYER4[(gy + y) & 3][(cx + x) & 3] + 0.5) / 16 < 0.55) {
1719
+ ctx.fillStyle = '#5e1c1c'
1720
+ ctx.fillRect(cx + x + 8, gy + y + 4, 1, 1)
1721
+ }
1722
+ }
1723
+ // chalk outline of the fallen figure
1724
+ ctx.fillStyle = C.bone
1725
+ const hx = cx - 34
1726
+ const hy = gy - 2
1727
+ ctx.fillRect(hx - 5, hy - 2, 2, 7); ctx.fillRect(hx + 4, hy - 2, 2, 7)
1728
+ ctx.fillRect(hx - 4, hy - 4, 9, 2); ctx.fillRect(hx - 4, hy + 5, 9, 2)
1729
+ ctx.fillRect(hx + 6, hy, 26, 2) // torso
1730
+ ctx.fillRect(hx + 12, hy - 9, 2, 9); ctx.fillRect(hx + 12, hy - 9, 9, 2) // raised arm
1731
+ ctx.fillRect(hx + 19, hy + 2, 2, 9) // lower arm
1732
+ ctx.fillRect(hx + 32, hy, 2, 11); ctx.fillRect(hx + 32, hy + 9, 12, 2) // bent leg
1733
+ ctx.fillRect(hx + 32, hy - 7, 14, 2); ctx.fillRect(hx + 44, hy - 7, 2, 7) // straight leg
1734
+ _evidenceTent(ctx, cx + 22, gy - 10)
1735
+ _evidenceTent(ctx, cx - 46, gy + 8)
1736
+ },
1737
+ poisoning: (ctx, w, h) => {
1738
+ const cx = Math.floor(w / 2)
1739
+ const gy = Math.floor(h * 0.72)
1740
+ // table edge
1741
+ ctx.fillStyle = _WOOD; ctx.fillRect(cx - 40, gy, 80, 6)
1742
+ ctx.fillStyle = _WOOD_L; ctx.fillRect(cx - 40, gy, 80, 2)
1743
+ // toppled goblet on its side
1744
+ ctx.fillStyle = '#b9c2c8'
1745
+ ctx.fillRect(cx - 14, gy - 9, 11, 8) // bowl
1746
+ ctx.fillRect(cx - 3, gy - 6, 9, 2) // stem
1747
+ ctx.fillRect(cx + 6, gy - 8, 2, 6) // base
1748
+ ctx.fillStyle = '#070b0f'; ctx.fillRect(cx - 13, gy - 8, 2, 6) // bowl mouth shadow
1749
+ // the spill: sickly green pool running off the table edge
1750
+ for (let y = 0; y < 4; y++) for (let x = -22; x < 4; x++) {
1751
+ if ((BAYER4[(gy + y) & 3][(cx + x) & 3] + 0.5) / 16 < 0.7) {
1752
+ ctx.fillStyle = '#3f6b2a'
1753
+ ctx.fillRect(cx + x - 6, gy - y - 1, 1, 1)
1754
+ }
1755
+ }
1756
+ ctx.fillStyle = '#3f6b2a'
1757
+ ctx.fillRect(cx - 24, gy + 2, 2, 9) // drip over the edge
1758
+ ctx.fillRect(cx - 25, gy + 12, 4, 2) // floor drop
1759
+ _evidenceTent(ctx, cx + 26, gy - 10)
1760
+ },
1761
+ theft: (ctx, w, h) => {
1762
+ const cx = Math.floor(w / 2)
1763
+ const gy = Math.floor(h * 0.78)
1764
+ // display pedestal
1765
+ ctx.fillStyle = '#2a2417'; ctx.fillRect(cx - 13, gy - 20, 26, 20)
1766
+ ctx.fillStyle = '#3a3324'; ctx.fillRect(cx - 13, gy - 20, 26, 2)
1767
+ ctx.fillStyle = '#15110a'; ctx.fillRect(cx - 16, gy - 22, 32, 3)
1768
+ // glass dome set aside, ajar
1769
+ ctx.fillStyle = '#5d8a8a'
1770
+ ctx.fillRect(cx + 20, gy - 14, 2, 14); ctx.fillRect(cx + 34, gy - 14, 2, 14)
1771
+ ctx.fillRect(cx + 21, gy - 16, 14, 2); ctx.fillRect(cx + 20, gy, 16, 2)
1772
+ // dashed ghost of the missing prize
1773
+ ctx.fillStyle = C.bone
1774
+ for (let i = 0; i < 4; i++) {
1775
+ ctx.fillRect(cx - 8 + i * 5, gy - 34, 3, 1)
1776
+ ctx.fillRect(cx - 8 + i * 5, gy - 25, 3, 1)
1777
+ }
1778
+ for (let i = 0; i < 3; i++) {
1779
+ ctx.fillRect(cx - 9, gy - 33 + i * 3, 1, 2)
1780
+ ctx.fillRect(cx + 11, gy - 33 + i * 3, 1, 2)
1781
+ }
1782
+ _evidenceTent(ctx, cx - 30, gy - 8)
1783
+ },
1784
+ fraud: (ctx, w, h) => {
1785
+ const cx = Math.floor(w / 2)
1786
+ const gy = Math.floor(h * 0.74)
1787
+ // open ledger, two pages
1788
+ ctx.fillStyle = C.bone
1789
+ ctx.fillRect(cx - 30, gy - 18, 28, 22); ctx.fillRect(cx + 2, gy - 18, 28, 22)
1790
+ ctx.fillStyle = '#0a0805'; ctx.fillRect(cx - 1, gy - 18, 2, 22)
1791
+ // entry lines
1792
+ ctx.fillStyle = '#4a4234'
1793
+ for (let i = 0; i < 5; i++) {
1794
+ ctx.fillRect(cx - 26, gy - 13 + i * 4, 20, 1)
1795
+ ctx.fillRect(cx + 6, gy - 13 + i * 4, 20, 1)
1796
+ }
1797
+ // the doctored entries, struck in red
1798
+ ctx.fillStyle = '#8a2a2a'
1799
+ ctx.fillRect(cx + 4, gy - 14, 24, 2)
1800
+ ctx.fillRect(cx + 6, gy - 7, 22, 2)
1801
+ ctx.fillRect(cx - 27, gy - 6, 6, 6) // ink blot
1802
+ _evidenceTent(ctx, cx + 36, gy - 4)
1803
+ },
1804
+ blackmail: (ctx, w, h) => {
1805
+ const cx = Math.floor(w / 2)
1806
+ const gy = Math.floor(h * 0.72)
1807
+ // the envelope, large, leaning into the light
1808
+ ctx.fillStyle = C.bone; ctx.fillRect(cx - 22, gy - 22, 44, 28)
1809
+ ctx.fillStyle = '#9a937e'
1810
+ ctx.fillRect(cx - 22, gy - 22, 22, 2); ctx.fillRect(cx, gy - 22, 22, 2)
1811
+ // flap V
1812
+ for (let i = 0; i < 11; i++) {
1813
+ ctx.fillRect(cx - 22 + i * 2, gy - 21 + i, 2, 1)
1814
+ ctx.fillRect(cx + 20 - i * 2, gy - 21 + i, 2, 1)
1815
+ }
1816
+ // wax seal
1817
+ ctx.fillStyle = '#8a2a2a'; ctx.fillRect(cx - 4, gy - 14, 9, 9)
1818
+ ctx.fillStyle = '#5e1c1c'; ctx.fillRect(cx - 2, gy - 12, 5, 5)
1819
+ _evidenceTent(ctx, cx + 30, gy)
1820
+ },
1821
+ arson: (ctx, w, h) => {
1822
+ const cx = Math.floor(w / 2)
1823
+ const gy = Math.floor(h * 0.8)
1824
+ // scorched floor
1825
+ for (let y = 0; y < 6; y++) for (let x = -36; x < 36; x++) {
1826
+ if ((BAYER4[(gy + y) & 3][(cx + x) & 3] + 0.5) / 16 < 0.6) {
1827
+ ctx.fillStyle = '#070707'
1828
+ ctx.fillRect(cx + x, gy + y, 1, 1)
1829
+ }
1830
+ }
1831
+ // flames: clustered tongues of amber
1832
+ for (let i = -3; i <= 3; i++) {
1833
+ const fx = cx + i * 9
1834
+ const fh = 14 + ((i * 13 + 7) % 9)
1835
+ for (let y = 0; y < fh; y++) {
1836
+ const ww = Math.max(1, Math.floor((1 - y / fh) * 4))
1837
+ ctx.fillStyle = y > fh * 0.55 ? C.amber : '#f5d08a'
1838
+ ctx.fillRect(fx - ww + ((y * 3 + i) % 2), gy - y, ww * 2, 1)
1839
+ }
1840
+ }
1841
+ // smoke drifting up, dithered
1842
+ for (let y = 0; y < 22; y++) for (let x = -20; x < 20; x++) {
1843
+ if ((BAYER4[y & 3][x & 3] + 0.5) / 16 < 0.16) {
1844
+ ctx.fillStyle = 'rgba(150,150,150,0.5)'
1845
+ ctx.fillRect(cx + x + Math.floor(y / 3), gy - 22 - y, 1, 1)
1846
+ }
1847
+ }
1848
+ },
1849
+ missing_person: (ctx, w, h) => {
1850
+ const cx = Math.floor(w / 2)
1851
+ const gy = Math.floor(h * 0.78)
1852
+ // door ajar, light spilling
1853
+ ctx.fillStyle = '#15110a'; ctx.fillRect(cx + 14, gy - 42, 22, 42)
1854
+ ctx.fillStyle = C.lamp; ctx.fillRect(cx + 17, gy - 39, 7, 39)
1855
+ for (let y = 0; y < 14; y++) {
1856
+ if ((BAYER4[y & 3][1] + 0.5) / 16 < 0.5) { ctx.fillStyle = 'rgba(245,208,138,0.4)'; ctx.fillRect(cx + 8 - y, gy - 2 + Math.floor(y / 4), y, 1) }
1857
+ }
1858
+ // overturned chair
1859
+ ctx.fillStyle = _WOOD
1860
+ ctx.fillRect(cx - 34, gy - 4, 18, 4) // seat on its side
1861
+ ctx.fillRect(cx - 36, gy - 16, 4, 14) // back flat on floor... legs up
1862
+ ctx.fillRect(cx - 18, gy - 12, 3, 10)
1863
+ ctx.fillRect(cx - 26, gy - 12, 3, 10)
1864
+ // a dropped glove
1865
+ ctx.fillStyle = C.slate; ctx.fillRect(cx - 4, gy + 2, 7, 4)
1866
+ _evidenceTent(ctx, cx, gy - 14)
1867
+ },
1868
+ con: (ctx, w, h) => {
1869
+ const cx = Math.floor(w / 2)
1870
+ const gy = Math.floor(h * 0.74)
1871
+ // worthless certificates fanned on the table
1872
+ ctx.fillStyle = _WOOD; ctx.fillRect(cx - 40, gy + 2, 80, 5)
1873
+ for (let i = 0; i < 3; i++) {
1874
+ const ox = cx - 26 + i * 16
1875
+ const oy = gy - 16 + (i % 2) * 3
1876
+ ctx.fillStyle = C.bone; ctx.fillRect(ox, oy, 22, 16)
1877
+ ctx.fillStyle = '#b8943e'; ctx.fillRect(ox + 2, oy + 2, 18, 1); ctx.fillRect(ox + 2, oy + 13, 18, 1)
1878
+ ctx.fillStyle = '#4a4234'; ctx.fillRect(ox + 4, oy + 5, 14, 1); ctx.fillRect(ox + 4, oy + 8, 10, 1)
1879
+ }
1880
+ // the red verdict, stamped across them
1881
+ ctx.fillStyle = '#8a2a2a'
1882
+ ctx.fillRect(cx - 24, gy - 9, 48, 3)
1883
+ // an emptied cash box
1884
+ ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 26, gy - 10, 16, 10)
1885
+ ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 28, gy - 8, 12, 6)
1886
+ ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 24, gy - 14, 20, 3)
1887
+ },
1888
+ ransom: (ctx, w, h) => {
1889
+ const cx = Math.floor(w / 2)
1890
+ const gy = Math.floor(h * 0.74)
1891
+ // the demand, propped center
1892
+ ctx.fillStyle = C.bone; ctx.fillRect(cx - 20, gy - 26, 40, 30)
1893
+ ctx.fillStyle = '#9a937e'; ctx.fillRect(cx - 20, gy + 2, 40, 2)
1894
+ // pasted cut-out letters, uneven
1895
+ const cols = ['#0a0e13', '#8a2a2a', '#2a2417', '#46506b']
1896
+ for (let r = 0; r < 3; r++) {
1897
+ let x = cx - 15
1898
+ for (let i = 0; i < 5 - (r % 2); i++) {
1899
+ ctx.fillStyle = cols[(r * 5 + i) % cols.length]
1900
+ const cw = 4 + ((r + i) % 3)
1901
+ ctx.fillRect(x, gy - 21 + r * 8 + ((i % 2) ? 1 : -1), cw, 6)
1902
+ x += cw + 2
1903
+ }
1904
+ }
1905
+ // the empty chair it was left on
1906
+ ctx.fillStyle = _WOOD
1907
+ ctx.fillRect(cx - 30, gy + 4, 3, 10); ctx.fillRect(cx + 27, gy + 4, 3, 10)
1908
+ _evidenceTent(ctx, cx + 32, gy - 12)
1909
+ },
1910
+ sabotage: (ctx, w, h) => {
1911
+ const cx = Math.floor(w / 2)
1912
+ const gy = Math.floor(h * 0.74)
1913
+ // the cable, snapped: two frayed halves with a violent gap
1914
+ ctx.fillStyle = '#3a444e'
1915
+ ctx.fillRect(cx - 44, gy - 8, 32, 4)
1916
+ ctx.fillRect(cx + 12, gy - 8, 32, 4)
1917
+ ctx.fillStyle = '#5d6a76'
1918
+ for (let i = 0; i < 4; i++) {
1919
+ ctx.fillRect(cx - 12 + i, gy - 8 + i, 4 - i, 1) // frayed strands
1920
+ ctx.fillRect(cx + 9 - i, gy - 5 - i, 4 - i, 1)
1921
+ }
1922
+ // sparks at the break
1923
+ ctx.fillStyle = '#f5d08a'
1924
+ ctx.fillRect(cx - 2, gy - 12, 2, 2); ctx.fillRect(cx + 4, gy - 16, 1, 1)
1925
+ ctx.fillRect(cx - 7, gy - 15, 1, 1); ctx.fillRect(cx + 1, gy - 4, 1, 1)
1926
+ // a fallen gear and scattered bolts
1927
+ ctx.fillStyle = '#3a444e'
1928
+ ctx.fillRect(cx - 28, gy + 4, 12, 12)
1929
+ ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - 25, gy + 7, 6, 6)
1930
+ ctx.fillStyle = '#5d6a76'
1931
+ ctx.fillRect(cx + 18, gy + 8, 3, 3); ctx.fillRect(cx + 28, gy + 12, 3, 3); ctx.fillRect(cx + 8, gy + 13, 3, 3)
1932
+ _evidenceTent(ctx, cx + 38, gy - 4)
1933
+ },
1934
+ }
1935
+
1936
+ /** Compose a crime-kind vignette over a dimmed, spotlit room: the player should see
1937
+ * and feel the main action, not just the room it happened in. */
1938
+ export function incidentPainter(kind: string, room: ScenePainter): ScenePainter {
1939
+ const vignette = _INCIDENTS[kind] || _INCIDENTS.homicide
1940
+ return (ctx, w, h, t, seed) => {
1941
+ room(ctx, w, h, t, seed)
1942
+ ctx.fillStyle = 'rgba(6,9,13,0.52)'
1943
+ ctx.fillRect(0, 0, w, h)
1944
+ lampCone(ctx, Math.floor(w / 2), 0, Math.floor(w * 0.95), Math.floor(h * 0.95))
1945
+ vignette(ctx, w, h)
1946
+ }
1947
+ }
web/src/engine/pixel.tsx CHANGED
@@ -3,12 +3,14 @@ import { useEffect, useRef, useState } from 'preact/hooks'
3
  import type { JSX } from 'preact'
4
 
5
  import { type Pal, type PixelMap, drawMap } from './draw'
 
6
 
7
  export type ScenePainter = (
8
  ctx: CanvasRenderingContext2D,
9
  w: number,
10
  h: number,
11
  t: number,
 
12
  ) => void
13
 
14
  interface PixelCanvasProps {
@@ -78,6 +80,9 @@ interface SceneCanvasProps {
78
  anim?: boolean
79
  full?: boolean
80
  rain?: boolean
 
 
 
81
  }
82
 
83
  /** Procedural painter at low internal res. Static scenes paint once to an offscreen
@@ -93,6 +98,7 @@ export function SceneCanvas({
93
  anim = false,
94
  full = false,
95
  rain = true,
 
96
  }: SceneCanvasProps) {
97
  const ref = useRef<HTMLCanvasElement>(null)
98
  const bufRef = useRef<HTMLCanvasElement | null>(null)
@@ -103,12 +109,14 @@ export function SceneCanvas({
103
  const ctx = cv.getContext('2d')!
104
  ctx.imageSmoothingEnabled = false
105
 
 
106
  const buf = document.createElement('canvas')
107
  buf.width = w
108
  buf.height = h
109
  const bctx = buf.getContext('2d')!
110
  bctx.imageSmoothingEnabled = false
111
- paint(bctx, w, h, 0)
 
112
  bufRef.current = buf
113
 
114
  let raf = 0
@@ -122,7 +130,8 @@ export function SceneCanvas({
122
  last = ts
123
  tRef.current += 1
124
  ctx.clearRect(0, 0, w, h)
125
- paint(ctx, w, h, tRef.current)
 
126
  }
127
  raf = requestAnimationFrame(loop)
128
  }
@@ -135,12 +144,16 @@ export function SceneCanvas({
135
  tRef.current += 1
136
  ctx.clearRect(0, 0, w, h)
137
  ctx.drawImage(buf, 0, 0)
138
- ctx.fillStyle = 'rgba(176,196,206,0.26)'
139
  const t = tRef.current
140
- for (let i = 0; i < 36; i++) {
141
- const x = (i * 41 + t * 5) % w
142
- const y = (i * 57 + t * 9) % h
143
- ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3)
 
 
 
 
 
144
  }
145
  }
146
  raf = requestAnimationFrame(loop)
 
3
  import type { JSX } from 'preact'
4
 
5
  import { type Pal, type PixelMap, drawMap } from './draw'
6
+ import { applySceneTheme, getCaseTheme, weatherOverlay } from './theme'
7
 
8
  export type ScenePainter = (
9
  ctx: CanvasRenderingContext2D,
10
  w: number,
11
  h: number,
12
  t: number,
13
+ seed?: number,
14
  ) => void
15
 
16
  interface PixelCanvasProps {
 
80
  anim?: boolean
81
  full?: boolean
82
  rain?: boolean
83
+ /** Per-case theming: 'full' = tint + weather, 'tint' = tint only (indoor exhibits),
84
+ * 'none' (default) = exact legacy output. */
85
+ themed?: 'full' | 'tint' | 'none'
86
  }
87
 
88
  /** Procedural painter at low internal res. Static scenes paint once to an offscreen
 
98
  anim = false,
99
  full = false,
100
  rain = true,
101
+ themed = 'none',
102
  }: SceneCanvasProps) {
103
  const ref = useRef<HTMLCanvasElement>(null)
104
  const bufRef = useRef<HTMLCanvasElement | null>(null)
 
109
  const ctx = cv.getContext('2d')!
110
  ctx.imageSmoothingEnabled = false
111
 
112
+ const { seed } = getCaseTheme()
113
  const buf = document.createElement('canvas')
114
  buf.width = w
115
  buf.height = h
116
  const bctx = buf.getContext('2d')!
117
  bctx.imageSmoothingEnabled = false
118
+ paint(bctx, w, h, 0, seed)
119
+ if (themed !== 'none') applySceneTheme(bctx, w, h, themed)
120
  bufRef.current = buf
121
 
122
  let raf = 0
 
130
  last = ts
131
  tRef.current += 1
132
  ctx.clearRect(0, 0, w, h)
133
+ paint(ctx, w, h, tRef.current, seed)
134
+ if (themed !== 'none') applySceneTheme(ctx, w, h, themed)
135
  }
136
  raf = requestAnimationFrame(loop)
137
  }
 
144
  tRef.current += 1
145
  ctx.clearRect(0, 0, w, h)
146
  ctx.drawImage(buf, 0, 0)
 
147
  const t = tRef.current
148
+ if (themed !== 'none') {
149
+ weatherOverlay(ctx, w, h, t)
150
+ } else {
151
+ ctx.fillStyle = 'rgba(176,196,206,0.26)'
152
+ for (let i = 0; i < 36; i++) {
153
+ const x = (i * 41 + t * 5) % w
154
+ const y = (i * 57 + t * 9) % h
155
+ ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3)
156
+ }
157
  }
158
  }
159
  raf = requestAnimationFrame(loop)
web/src/engine/theme.ts ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Per-case visual theme: a tint keyed to the crime kind, weather parsed from the case's
2
+ // own weather line, and a stable seed for painter variation. Held in a module singleton
3
+ // set when a case loads (GameProvider remounts per run, so every canvas repaints fresh).
4
+ // DEFAULT_THEME reproduces the un-themed output exactly, so any canvas painted before a
5
+ // case arrives looks like it always did.
6
+ import { BAYER4 } from './draw'
7
+
8
+ export type WeatherKind = 'rain' | 'fog' | 'sleet' | 'dry'
9
+
10
+ export interface CaseTheme {
11
+ seed: number
12
+ weather: WeatherKind
13
+ tint: string
14
+ tintStrength: number
15
+ }
16
+
17
+ export const DEFAULT_THEME: CaseTheme = { seed: 0, weather: 'rain', tint: '', tintStrength: 0 }
18
+
19
+ let _theme: CaseTheme = DEFAULT_THEME
20
+
21
+ export function setCaseTheme(t: CaseTheme): void {
22
+ _theme = t
23
+ }
24
+ export function getCaseTheme(): CaseTheme {
25
+ return _theme
26
+ }
27
+
28
+ function hashStr(s: string): number {
29
+ let h = 0
30
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
31
+ return h
32
+ }
33
+
34
+ // Two noir-muted shades per crime kind; the seed picks one so two homicides
35
+ // in a row still read slightly differently.
36
+ const TINTS: Record<string, [string, string]> = {
37
+ homicide: ['#87292a', '#5e1c1c'],
38
+ theft: ['#37636b', '#284149'],
39
+ fraud: ['#1d5a2c', '#14401f'],
40
+ blackmail: ['#46506b', '#3a3a5e'],
41
+ arson: ['#b9772f', '#8a4a1e'],
42
+ missing_person: ['#2d3a5e', '#1d2832'],
43
+ con: ['#8a6a1e', '#6b521a'],
44
+ poisoning: ['#3f6b2a', '#2c4a1e'],
45
+ ransom: ['#3a4a5e', '#28323e'],
46
+ sabotage: ['#8a4a2a', '#6b3a1e'],
47
+ }
48
+
49
+ export function weatherFromText(text: string): WeatherKind {
50
+ const t = (text || '').toLowerCase()
51
+ if (/sleet/.test(t)) return 'sleet'
52
+ if (/fog|mist/.test(t)) return 'fog'
53
+ if (/rain|storm|drizzle|downpour/.test(t)) return 'rain'
54
+ return 'dry'
55
+ }
56
+
57
+ export function themeFromCase(c: { id: string; weather?: string; kind?: string }): CaseTheme {
58
+ const seed = hashStr(c.id || '')
59
+ const pair = TINTS[c.kind || 'homicide'] || TINTS.homicide
60
+ return { seed, weather: weatherFromText(c.weather || ''), tint: pair[seed & 1], tintStrength: 0.16 }
61
+ }
62
+
63
+ /** Post-paint pass over a freshly painted (static) scene buffer. Composite ops are
64
+ * per-pixel, so the pixel-art stays crisp. 'tint' skips the weather treatment
65
+ * (exhibits sit on an indoor forensic table). */
66
+ export function applySceneTheme(ctx: CanvasRenderingContext2D, w: number, h: number, mode: 'full' | 'tint'): void {
67
+ const t = _theme
68
+ if (t.tint && t.tintStrength > 0) {
69
+ ctx.globalCompositeOperation = 'overlay'
70
+ ctx.globalAlpha = t.tintStrength
71
+ ctx.fillStyle = t.tint
72
+ ctx.fillRect(0, 0, w, h)
73
+ ctx.globalAlpha = 1
74
+ ctx.globalCompositeOperation = 'source-over'
75
+ }
76
+ if (mode === 'full' && t.weather === 'fog') {
77
+ // Wash the color out, then lay dithered haze bands that thicken toward the ground.
78
+ ctx.globalCompositeOperation = 'saturation'
79
+ ctx.globalAlpha = 0.45
80
+ ctx.fillStyle = '#8a949c'
81
+ ctx.fillRect(0, 0, w, h)
82
+ ctx.globalAlpha = 1
83
+ ctx.globalCompositeOperation = 'source-over'
84
+ ctx.fillStyle = 'rgba(170,185,195,0.32)'
85
+ for (let b = 0; b < 4; b++) {
86
+ const y0 = Math.floor(h * (0.34 + b * 0.17))
87
+ const bh = 3 + b * 2
88
+ const cover = 0.22 + b * 0.13
89
+ for (let y = y0; y < Math.min(h, y0 + bh); y++) {
90
+ for (let x = 0; x < w; x++) {
91
+ const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
92
+ if (thr < cover) ctx.fillRect(x, y, 1, 1)
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ /** Per-frame precipitation for animated scenes. Rain matches the legacy overlay
100
+ * pixel-for-pixel; sleet falls in slanted two-pixel steps; fog and dry add nothing. */
101
+ export function weatherOverlay(ctx: CanvasRenderingContext2D, w: number, h: number, t: number): void {
102
+ const wk = _theme.weather
103
+ if (wk === 'rain') {
104
+ ctx.fillStyle = 'rgba(176,196,206,0.26)'
105
+ for (let i = 0; i < 36; i++) {
106
+ const x = (i * 41 + t * 5) % w
107
+ const y = (i * 57 + t * 9) % h
108
+ ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3)
109
+ }
110
+ } else if (wk === 'sleet') {
111
+ ctx.fillStyle = 'rgba(200,210,220,0.30)'
112
+ for (let i = 0; i < 28; i++) {
113
+ const x = (i * 47 + t * 6) % w
114
+ const y = (i * 53 + t * 8) % h
115
+ ctx.fillRect(Math.floor(x), Math.floor(y), 1, 2)
116
+ ctx.fillRect(Math.floor(x) + 1, Math.floor(y) + 2, 1, 2)
117
+ }
118
+ }
119
+ }
web/src/played.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Played-case memory: ids of every case this browser has STARTED, oldest first.
2
+ // Sent with New Case so the server never deals this player a mystery they've seen.
3
+ const KEY = 'cz-played'
4
+ const CAP = 200
5
+
6
+ interface Stored {
7
+ v: 1
8
+ ids: string[]
9
+ }
10
+
11
+ export function getPlayed(): string[] {
12
+ try {
13
+ const raw = localStorage.getItem(KEY)
14
+ if (!raw) return []
15
+ const data = JSON.parse(raw) as Stored
16
+ if (data?.v !== 1 || !Array.isArray(data.ids)) return []
17
+ return data.ids.filter((x) => typeof x === 'string')
18
+ } catch {
19
+ return []
20
+ }
21
+ }
22
+
23
+ export function markPlayed(id: string): void {
24
+ if (!id) return
25
+ try {
26
+ const ids = getPlayed().filter((x) => x !== id)
27
+ ids.push(id) // most recent last; the server uses order for its repeat-of-last-resort
28
+ localStorage.setItem(KEY, JSON.stringify({ v: 1, ids: ids.slice(-CAP) }))
29
+ } catch {
30
+ /* private mode etc. — repeats become possible, nothing breaks */
31
+ }
32
+ }
web/src/screens/board.tsx CHANGED
@@ -7,10 +7,12 @@ import { useEffect, useRef, useState } from 'preact/hooks'
7
  import { useGame } from '../store'
8
  import type { PublicCase } from '../types'
9
  import { playSfx } from '../ui/audio'
10
- import { BottomNav, Btn, EvIcon, Hud, Panel, Portrait, Scene, SuspicionBar } from '../ui/components'
 
 
11
  import { ThreadLayer, useThreads } from './board-threads'
12
 
13
- const CARD_H = 92
14
  const CARD_W = 150 // desktop
15
  const CARD_W_M = 132 // mobile — two loose columns at 390px
16
  const SPOTS: [number, number][] = [
@@ -61,13 +63,17 @@ export function Board() {
61
  }
62
  />
63
  {compact ? (
64
- <Corkboard compact connect={connect} setConnect={setConnect} />
 
 
 
65
  ) : (
66
  <div class="board-layout">
67
  <Corkboard compact={false} connect={connect} setConnect={setConnect} />
68
  <SuspectRail />
69
  </div>
70
  )}
 
71
  <BottomNav />
72
  </div>
73
  )
@@ -89,8 +95,8 @@ function Corkboard({ compact, connect, setConnect }: { compact: boolean; connect
89
  const dragCleanup = useRef<(() => void) | null>(null)
90
  const threads = useThreads(c.id, ['victim', ...c.evidence.map((e) => e.id)])
91
 
92
- // Tall virtual wall on mobile so eight cards breathe at ~400px width.
93
- const mobileBoardH = Math.max(900, 200 + c.evidence.length * 115)
94
 
95
  // Mode flip: wipe layout (and the measurement that produced it) so cards re-seat
96
  // on the new wall instead of stranding off-board.
@@ -327,7 +333,7 @@ function BoardDecor({ c, compact }: { c: PublicCase; compact?: boolean }) {
327
  <div class="wall-clip">
328
  <div style={{ fontFamily: 'var(--f-display)', fontSize: 9, lineHeight: 1.25, color: '#231f16' }}>{c.city} UNVEILS<br />ITS NEW CROWN</div>
329
  <div style={{ height: 2, background: '#231f1633', margin: '5px 0' }} />
330
- <div style={{ background: '#0d1117', height: 46, marginBottom: 4 }}><Scene name="skyline" w={170} h={46} style={{ width: '100%', height: '100%' }} /></div>
331
  <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#4a4234', lineHeight: 1.2 }}>{c.victim.name} found dead the night the city came to celebrate.</div>
332
  </div>
333
  </WallItem>
@@ -375,8 +381,8 @@ function BoardDecor({ c, compact }: { c: PublicCase; compact?: boolean }) {
375
  )}
376
  <WallItem x={compact ? '52%' : '84%'} y={compact ? '82%' : '55%'} rot={3} pin="ox" w={compact ? 130 : 138}>
377
  <div class="wall-photo">
378
- <div style={{ background: '#0d1117', height: 72 }}><Scene name="mezzanine" w={126} h={72} style={{ width: '100%', height: '100%' }} /></div>
379
- <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 3 }}>SCENE — RAIL</div>
380
  </div>
381
  </WallItem>
382
  {!compact && <div class="wall-item" style={{ left: '44%', top: '70%', width: 64, height: 64, border: '5px solid rgba(60,40,20,.35)', borderRadius: '50%', boxShadow: 'inset 0 0 8px rgba(60,40,20,.25)' }} />}
@@ -397,7 +403,7 @@ function SuspectRail() {
397
  <aside class="suspect-rail">
398
  <div class="suspect-rail__head">
399
  <div class="t-label" style={{ color: 'var(--amber-2)' }}>PERSONS OF INTEREST</div>
400
- <div class="t-body dim" style={{ fontSize: 12, marginTop: 4 }}>Click a suspect, then interrogate.</div>
401
  </div>
402
  <div class="suspect-rail__list">
403
  {c.suspects.map((s) => {
@@ -409,7 +415,7 @@ function SuspectRail() {
409
  <Panel variant={open ? 'amber' : undefined} style={{ padding: 10 }} onClick={() => setSel(open ? null : s.id)}>
410
  <div class="row" style={{ gap: 10, alignItems: 'center' }}>
411
  <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0, position: 'relative' }}>
412
- <Portrait id={s.sprite} px={4} blink={open} gender={s.gender} />
413
  {grilled && <span style={{ position: 'absolute', top: -4, right: -4, background: 'var(--slate-2)', color: 'var(--ink-0)', fontFamily: 'var(--f-display)', fontSize: 7, padding: '2px 3px' }}>✓</span>}
414
  </div>
415
  <div class="col grow" style={{ gap: 4, minWidth: 0 }}>
@@ -444,12 +450,12 @@ function BoardNode({ node, cardW, selected, onSelect }: { node: Node; cardW: num
444
  const e = c.evidence.find((x) => x.id === node.id)!
445
  return (
446
  <div class="pin-note" style={{ width: cardW, cursor: 'pointer', boxShadow: selected ? '0 0 0 3px var(--amber-2), 3px 4px 0 rgba(0,0,0,.4)' : undefined }} onClick={onSelect}>
447
- <div class="row" style={{ gap: 8, alignItems: 'center' }}>
448
- <div style={{ background: 'var(--ink-1)', padding: 3 }}><EvIcon icon={e.icon} px={2} /></div>
449
- <div class="col" style={{ gap: 2, minWidth: 0 }}>
450
- <span class="t-display" style={{ fontSize: 7, color: 'var(--ink-1)' }}>{e.name}</span>
451
- <span class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: 'var(--ox-2)' }}>{e.time}</span>
452
- </div>
453
  </div>
454
  </div>
455
  )
 
7
  import { useGame } from '../store'
8
  import type { PublicCase } from '../types'
9
  import { playSfx } from '../ui/audio'
10
+ import { CaseBriefOverlay } from '../ui/case-brief'
11
+ import { Checklist } from '../ui/checklist'
12
+ import { BottomNav, Btn, ExhibitArt, Hud, Panel, Portrait, Scene, SuspicionBar } from '../ui/components'
13
  import { ThreadLayer, useThreads } from './board-threads'
14
 
15
+ const CARD_H = 120 // polaroid: exhibit photo + caption
16
  const CARD_W = 150 // desktop
17
  const CARD_W_M = 132 // mobile — two loose columns at 390px
18
  const SPOTS: [number, number][] = [
 
63
  }
64
  />
65
  {compact ? (
66
+ <>
67
+ <Checklist slim />
68
+ <Corkboard compact connect={connect} setConnect={setConnect} />
69
+ </>
70
  ) : (
71
  <div class="board-layout">
72
  <Corkboard compact={false} connect={connect} setConnect={setConnect} />
73
  <SuspectRail />
74
  </div>
75
  )}
76
+ <CaseBriefOverlay />
77
  <BottomNav />
78
  </div>
79
  )
 
95
  const dragCleanup = useRef<(() => void) | null>(null)
96
  const threads = useThreads(c.id, ['victim', ...c.evidence.map((e) => e.id)])
97
 
98
+ // Tall virtual wall on mobile so eight polaroid cards breathe at ~400px width.
99
+ const mobileBoardH = Math.max(980, 220 + c.evidence.length * 145)
100
 
101
  // Mode flip: wipe layout (and the measurement that produced it) so cards re-seat
102
  // on the new wall instead of stranding off-board.
 
333
  <div class="wall-clip">
334
  <div style={{ fontFamily: 'var(--f-display)', fontSize: 9, lineHeight: 1.25, color: '#231f16' }}>{c.city} UNVEILS<br />ITS NEW CROWN</div>
335
  <div style={{ height: 2, background: '#231f1633', margin: '5px 0' }} />
336
+ <div style={{ background: '#0d1117', height: 46, marginBottom: 4 }}><Scene name={c.district} establishing w={170} h={46} style={{ width: '100%', height: '100%' }} /></div>
337
  <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#4a4234', lineHeight: 1.2 }}>{c.victim.name} found dead the night the city came to celebrate.</div>
338
  </div>
339
  </WallItem>
 
381
  )}
382
  <WallItem x={compact ? '52%' : '84%'} y={compact ? '82%' : '55%'} rot={3} pin="ox" w={compact ? 130 : 138}>
383
  <div class="wall-photo">
384
+ <div style={{ background: '#0d1117', height: 72 }}><Scene name={c.scene} w={126} h={72} style={{ width: '100%', height: '100%' }} /></div>
385
+ <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 3 }}>SCENE — {(c.scene.split(/[—–]/).pop() || c.scene).trim().toUpperCase().slice(0, 14)}</div>
386
  </div>
387
  </WallItem>
388
  {!compact && <div class="wall-item" style={{ left: '44%', top: '70%', width: 64, height: 64, border: '5px solid rgba(60,40,20,.35)', borderRadius: '50%', boxShadow: 'inset 0 0 8px rgba(60,40,20,.25)' }} />}
 
403
  <aside class="suspect-rail">
404
  <div class="suspect-rail__head">
405
  <div class="t-label" style={{ color: 'var(--amber-2)' }}>PERSONS OF INTEREST</div>
406
+ <Checklist />
407
  </div>
408
  <div class="suspect-rail__list">
409
  {c.suspects.map((s) => {
 
415
  <Panel variant={open ? 'amber' : undefined} style={{ padding: 10 }} onClick={() => setSel(open ? null : s.id)}>
416
  <div class="row" style={{ gap: 10, alignItems: 'center' }}>
417
  <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0, position: 'relative' }}>
418
+ <Portrait id={s.sprite} px={4} blink={open} gender={s.gender} accent={s.accentColor} />
419
  {grilled && <span style={{ position: 'absolute', top: -4, right: -4, background: 'var(--slate-2)', color: 'var(--ink-0)', fontFamily: 'var(--f-display)', fontSize: 7, padding: '2px 3px' }}>✓</span>}
420
  </div>
421
  <div class="col grow" style={{ gap: 4, minWidth: 0 }}>
 
450
  const e = c.evidence.find((x) => x.id === node.id)!
451
  return (
452
  <div class="pin-note" style={{ width: cardW, cursor: 'pointer', boxShadow: selected ? '0 0 0 3px var(--amber-2), 3px 4px 0 rgba(0,0,0,.4)' : undefined }} onClick={onSelect}>
453
+ <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', height: 52, marginBottom: 5, overflow: 'hidden' }}>
454
+ <ExhibitArt e={e} w={96} h={64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
455
+ </div>
456
+ <div class="col" style={{ gap: 2, minWidth: 0 }}>
457
+ <span class="t-display" style={{ fontSize: 7, color: 'var(--ink-1)' }}>{e.name}</span>
458
+ <span class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: 'var(--ox-2)' }}>{e.time}</span>
459
  </div>
460
  </div>
461
  )
web/src/screens/cold.tsx CHANGED
@@ -65,14 +65,24 @@ export function StoryScreen() {
65
  const [body, done] = useTypewriter(beat.text, (g.state.tweaks.typeSpeed || 18) + 2, true)
66
  return (
67
  <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
68
- <Scene name={beat.scene} w={320} h={200} cover deps={[i]} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.5 }} />
 
 
 
 
 
 
 
 
 
 
69
  <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,11,16,.7) 0%, rgba(8,11,16,.35) 38%, rgba(8,11,16,.82) 100%)' }} />
70
  <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column' }}>
71
  <div class="between" style={{ padding: '14px 18px' }}>
72
  <span class="t-label" style={{ letterSpacing: '.2em', color: 'var(--amber-2)' }}>{beat.kicker}</span>
73
  <div class="row" style={{ gap: 8 }}>
74
  <HintButton />
75
- <Btn sm variant="ghost" onClick={() => g.nav('briefing')}>Skip ▸</Btn>
76
  </div>
77
  </div>
78
  <div class="grow center" style={{ padding: '12px 22px' }}>
@@ -95,7 +105,7 @@ export function StoryScreen() {
95
  {!last ? (
96
  <Btn variant="amber" onClick={() => setI(i + 1)}>Next ▸</Btn>
97
  ) : (
98
- <Btn variant="amber" style={{ padding: '13px 24px' }} onClick={() => g.nav('briefing')}>Open the Case File ▸▸</Btn>
99
  )}
100
  </div>
101
  </div>
@@ -143,7 +153,7 @@ export function BootScreen() {
143
 
144
  return (
145
  <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
146
- <Scene name="seawall" w={320} h={200} cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.28 }} />
147
  <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 80% at 30% 40%, transparent 40%, rgba(8,11,16,.7) 100%)' }} />
148
  <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
149
  <div class="maxw" style={{ maxWidth: 760, display: 'flex', flexDirection: 'column', gap: 22, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
@@ -293,7 +303,7 @@ function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChil
293
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
294
  {c.suspects.map((s) => (
295
  <div key={s.id} class="row" style={{ gap: 10, alignItems: 'center', background: '#00000010', padding: 8, boxShadow: 'inset 0 0 0 1px #211d1522' }}>
296
- <div style={{ background: '#0d1117', padding: 3, flexShrink: 0 }}><Portrait id={s.sprite} px={3} gender={s.gender} /></div>
297
  <div class="col" style={{ gap: 2, minWidth: 0 }}>
298
  <span class="dh" style={{ fontSize: 10 }}>{s.name}</span>
299
  <span class="dlabel" style={{ color: '#8a3a2c' }}>{s.tag}</span>
 
65
  const [body, done] = useTypewriter(beat.text, (g.state.tweaks.typeSpeed || 18) + 2, true)
66
  return (
67
  <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
68
+ <Scene
69
+ name={beat.scene}
70
+ establishing={i === 0}
71
+ incident={beat.incident ? g.case.kind || 'homicide' : undefined}
72
+ anim
73
+ w={320}
74
+ h={200}
75
+ cover
76
+ deps={[i]}
77
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: beat.incident ? 0.75 : 0.5 }}
78
+ />
79
  <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,11,16,.7) 0%, rgba(8,11,16,.35) 38%, rgba(8,11,16,.82) 100%)' }} />
80
  <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column' }}>
81
  <div class="between" style={{ padding: '14px 18px' }}>
82
  <span class="t-label" style={{ letterSpacing: '.2em', color: 'var(--amber-2)' }}>{beat.kicker}</span>
83
  <div class="row" style={{ gap: 8 }}>
84
  <HintButton />
85
+ <Btn sm variant="ghost" onClick={() => g.nav('board')}>Skip ▸</Btn>
86
  </div>
87
  </div>
88
  <div class="grow center" style={{ padding: '12px 22px' }}>
 
105
  {!last ? (
106
  <Btn variant="amber" onClick={() => setI(i + 1)}>Next ▸</Btn>
107
  ) : (
108
+ <Btn variant="amber" style={{ padding: '13px 24px' }} onClick={() => g.nav('board')}>Start Investigating ▸▸</Btn>
109
  )}
110
  </div>
111
  </div>
 
153
 
154
  return (
155
  <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
156
+ <Scene name={g.case.district} establishing w={320} h={200} cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.28 }} />
157
  <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 80% at 30% 40%, transparent 40%, rgba(8,11,16,.7) 100%)' }} />
158
  <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
159
  <div class="maxw" style={{ maxWidth: 760, display: 'flex', flexDirection: 'column', gap: 22, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
 
303
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
304
  {c.suspects.map((s) => (
305
  <div key={s.id} class="row" style={{ gap: 10, alignItems: 'center', background: '#00000010', padding: 8, boxShadow: 'inset 0 0 0 1px #211d1522' }}>
306
+ <div style={{ background: '#0d1117', padding: 3, flexShrink: 0 }}><Portrait id={s.sprite} px={3} gender={s.gender} accent={s.accentColor} /></div>
307
  <div class="col" style={{ gap: 2, minWidth: 0 }}>
308
  <span class="dh" style={{ fontSize: 10 }}>{s.name}</span>
309
  <span class="dlabel" style={{ color: '#8a3a2c' }}>{s.tag}</span>
web/src/screens/endgame.tsx CHANGED
@@ -1,244 +1,270 @@
1
- // Accusation builder · Verdict reveal · Share card. The verdict is computed server-side
2
- // (the sealed solution is read for the first time at /accuse); the client only displays it.
3
- import { useEffect, useState } from 'preact/hooks'
4
-
5
- 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, perpNoun } from '../ui/components'
10
-
11
-
12
- export function AccusationScreen() {
13
- const g = useGame()
14
- const c = g.case
15
- const a = g.state.accuse
16
- const [submitting, setSubmitting] = useState(false)
17
- const [err, setErr] = useState<string | null>(null)
18
- const set = (field: 'suspect' | 'motive' | 'evidence', value: unknown) => g.dispatch({ type: 'ACCUSE', field, value })
19
- const toggleEv = (id: string) => set('evidence', a.evidence.includes(id) ? a.evidence.filter((x) => x !== id) : [...a.evidence, id])
20
- const ready = !!a.suspect && !!a.motive && a.evidence.length >= 1
21
-
22
- const submit = async () => {
23
- if (!ready || submitting) return
24
- playSfx('accuse')
25
- setSubmitting(true)
26
- setErr(null)
27
- try {
28
- const result = await accuse(g.runId, { suspectId: a.suspect!, motiveId: a.motive!, evidenceIds: a.evidence })
29
- g.nav('verdict', { result })
30
- } catch {
31
- setErr('The line dropped. Try closing the case again.')
32
- setSubmitting(false)
33
- }
34
- }
35
-
36
- return (
37
- <div class="app__view" style={{ background: 'var(--ink-0)' }}>
38
- <Hud title="THE ACCUSATION" sub="THIS CANNOT BE UNDONE" right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Back</Btn>} />
39
- <div style={{ position: 'absolute', inset: 0, top: 56, background: 'radial-gradient(70% 60% at 50% 30%, rgba(135,41,42,.10), transparent 70%)', pointerEvents: 'none' }} />
40
- <div class="screen-pad">
41
- <div class="maxw" style={{ maxWidth: 1000 }}>
42
- <div class="col" style={{ gap: 20 }}>
43
- <section>
44
- <div class="t-display ox" style={{ fontSize: 13, marginBottom: 10 }}>1 — NAME THE KILLER</div>
45
- <div class="grid-4">
46
- {c.suspects.map((s) => (
47
- <Panel key={s.id} variant={a.suspect === s.id ? 'ox' : undefined} style={{ padding: 10, cursor: 'pointer', textAlign: 'center' }} onClick={() => set('suspect', s.id)}>
48
- <div class="center" style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 5, marginBottom: 8 }}><Portrait id={s.sprite} px={4} blink={a.suspect === s.id} gender={s.gender} /></div>
49
- <div class="t-display" style={{ fontSize: 9, color: a.suspect === s.id ? 'var(--ox-3)' : 'var(--bone-3)' }}>{s.name}</div>
50
- <div class="t-label" style={{ marginTop: 3 }}>{s.tag}</div>
51
- </Panel>
52
- ))}
53
- </div>
54
- </section>
55
-
56
- <section>
57
- <div class="t-display ox" style={{ fontSize: 13, marginBottom: 10 }}>2 — THE MOTIVE</div>
58
- <div class="grid-2">
59
- {c.motives.map((m) => (
60
- <Panel key={m.id} variant={a.motive === m.id ? 'amber' : undefined} style={{ padding: 12, cursor: 'pointer' }} onClick={() => set('motive', m.id)}>
61
- <div class="row" style={{ gap: 10, alignItems: 'center' }}>
62
- <span style={{ width: 16, height: 16, flexShrink: 0, background: a.motive === m.id ? 'var(--amber-2)' : 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)' }} />
63
- <span class="t-body" style={{ fontSize: 14 }}>{m.text}</span>
64
- </div>
65
- </Panel>
66
- ))}
67
- </div>
68
- </section>
69
-
70
- <section>
71
- <div class="t-display ox" style={{ fontSize: 13, marginBottom: 10 }}>3 ATTACH SUPPORTING EVIDENCE</div>
72
- <div class="row wrap" style={{ gap: 10 }}>
73
- {c.evidence.map((e) => {
74
- const on = a.evidence.includes(e.id)
75
- return (
76
- <button key={e.id} onClick={() => toggleEv(e.id)} style={{ border: 0, background: 'transparent', padding: 0, cursor: 'pointer' }}>
77
- <Panel variant={on ? 'amber' : undefined} style={{ padding: 8 }}>
78
- <div class="row" style={{ gap: 8, alignItems: 'center' }}>
79
- <div style={{ background: 'var(--ink-1)', padding: 3 }}><EvIcon icon={e.icon} px={2} /></div>
80
- <span class="t-display" style={{ fontSize: 8, color: on ? 'var(--amber-2)' : 'var(--bone-2)' }}>{e.name}</span>
81
- {on && <span class="amber">✓</span>}
82
- </div>
83
- </Panel>
84
- </button>
85
- )
86
- })}
87
- </div>
88
- </section>
89
-
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}>
99
- {submitting ? 'Closing…' : 'Close the Case ▸▸'}
100
- </Btn>
101
- </Panel>
102
- </div>
103
- </div>
104
- </div>
105
- </div>
106
- )
107
- }
108
-
109
- export function VerdictScreen() {
110
- const g = useGame()
111
- const c = g.case
112
- const result = g.state.payload.result as VerdictResult | undefined
113
- const instant = g.state.payload.done === true // returning from Share - show it finished, no replay
114
- const [phase, setPhase] = useState(instant ? 2 : 0)
115
- useEffect(() => {
116
- if (instant) return
117
- if (result) playSfx(result.correct ? 'success' : 'fail')
118
- const t1 = setTimeout(() => setPhase(1), 700)
119
- const t2 = setTimeout(() => setPhase(2), 2200)
120
- return () => {
121
- clearTimeout(t1)
122
- clearTimeout(t2)
123
- }
124
- }, [])
125
-
126
- if (!result) {
127
- return (
128
- <div class="app__view screen-center">
129
- <Panel className="col center" style={{ gap: 14, padding: 24 }}>
130
- <span class="t-display amber">NO ACCUSATION ON FILE</span>
131
- <Btn variant="amber" onClick={() => g.nav('accuse')}>Build the accusation ▸</Btn>
132
- </Panel>
133
- </div>
134
- )
135
- }
136
-
137
- const correct = result.correct
138
- const killer = c.suspects.find((s) => s.id === result.verdict.killerId)
139
- const killerSprite = killer?.sprite || c.victim.sprite
140
- const killerGender = killer?.gender
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 }}>
148
- <div class="col center" style={{ gap: 20, maxWidth: 720, textAlign: 'center' }}>
149
- {phase >= 0 && (
150
- <Stamp slam={!instant} style={{ fontSize: 'clamp(16px,4vw,26px)', borderColor: correct ? 'var(--slate-3)' : 'var(--ox-3)', color: correct ? 'var(--slate-3)' : 'var(--ox-3)' }}>
151
- {result.verdict.stamp}
152
- </Stamp>
153
- )}
154
- {phase >= 1 && (
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 && (
162
- <>
163
- <Panel style={{ maxWidth: 640 }}>
164
- <DialoguePanel who={correct ? 'THE TRUTH' : 'THE TRUTH ESCAPES'} text={result.verdict.truth} speed={g.state.tweaks.typeSpeed || 16} tag={correct ? null : 'WRONG'} />
165
- </Panel>
166
- {result.score && (
167
- <div class="t-mono amber" style={{ fontSize: 'calc(18px*var(--mono-scale))' }}>SCORE {result.score.points}/{result.score.max}</div>
168
- )}
169
- <div class="row wrap center" style={{ gap: 10 }}>
170
- {stats.map(([k, v], i) => (
171
- <Panel key={i} style={{ padding: '10px 16px', textAlign: 'center' }}>
172
- <div class="t-mono amber" style={{ fontSize: 'calc(20px*var(--mono-scale))' }}>{v}</div>
173
- <div class="t-label" style={{ marginTop: 3 }}>{k}</div>
174
- </Panel>
175
- ))}
176
- </div>
177
- <div class="row wrap center" style={{ gap: 10 }}>
178
- <Btn variant="amber" onClick={() => g.nav('share', { result })}>Share Case Card ▸</Btn>
179
- <Btn variant="ghost" onClick={() => g.newCase()}>New Case</Btn>
180
- </div>
181
- </>
182
- )}
183
- </div>
184
- </div>
185
- </div>
186
- )
187
- }
188
-
189
- export function ShareScreen() {
190
- const g = useGame()
191
- const c = g.case
192
- const result = g.state.payload.result as VerdictResult | undefined
193
- const correct = !!result?.correct
194
- const stats = result?.stats?.length ? result.stats : g.runStats()
195
- const [copied, setCopied] = useState(false)
196
- const copy = () => {
197
- try {
198
- navigator.clipboard.writeText(c.id)
199
- } catch {
200
- /* ignore */
201
- }
202
- setCopied(true)
203
- setTimeout(() => setCopied(false), 1600)
204
- }
205
- return (
206
- <div class="app__view screen-center" style={{ background: 'var(--ink-0)' }}>
207
- <div class="col center" style={{ gap: 16, width: '100%' }}>
208
- <Panel variant="amber" style={{ width: 'min(440px,92vw)', padding: 0, overflow: 'hidden' }}>
209
- <div style={{ position: 'relative', height: 140 }}>
210
- <Scene name="skyline" w={240} h={140} anim style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
211
- <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(transparent, var(--ink-2))' }} />
212
- <div style={{ position: 'absolute', top: 10, left: 12 }}><span class="t-label" style={{ color: 'var(--amber-2)', letterSpacing: '.2em' }}>CASE ZERO</span></div>
213
- <div style={{ position: 'absolute', bottom: 8, right: 10 }}><Stamp style={{ fontSize: 12, borderColor: correct ? 'var(--slate-3)' : 'var(--ox-3)', color: correct ? 'var(--slate-3)' : 'var(--ox-3)' }}>{correct ? 'SOLVED' : 'MISTRIAL'}</Stamp></div>
214
- </div>
215
- <div class="col" style={{ gap: 12, padding: 18 }}>
216
- <div class="between">
217
- <div class="col" style={{ gap: 3 }}>
218
- <span class="t-label">CASE FILE</span>
219
- <span class="t-mono" style={{ fontSize: 'calc(24px*var(--mono-scale))', color: 'var(--amber-2)' }}>{c.id}</span>
220
- </div>
221
- <div style={{ background: 'var(--ink-1)', padding: 4, boxShadow: 'inset 0 0 0 2px var(--ink-0)' }}><Portrait id={c.victim.sprite} px={3} /></div>
222
- </div>
223
- <hr class="hr-pixel" />
224
- <div class="t-body" style={{ fontSize: 14 }}><span class="dim">{c.title}</span> — {c.victim.name}, {c.victim.role.split('—')[0].trim()}.</div>
225
- <div class="row" style={{ gap: 8 }}>
226
- {stats.map(([k, v], i) => (
227
- <div key={i} class="grow" style={{ background: 'var(--ink-1)', padding: '8px 6px', textAlign: 'center', boxShadow: 'inset 0 0 0 2px var(--ink-0)' }}>
228
- <div class="t-mono amber" style={{ fontSize: 'calc(17px*var(--mono-scale))' }}>{v}</div>
229
- <div class="t-label" style={{ fontSize: 7 }}>{k}</div>
230
- </div>
231
- ))}
232
- </div>
233
- <div class="t-body amber" style={{ fontSize: 14, textAlign: 'center', fontStyle: 'italic' }}>Same city. Same body. Can you close it faster?</div>
234
- <div class="row" style={{ gap: 8 }}>
235
- <Btn variant="amber" className="grow" onClick={copy}>{copied ? '✓ ID Copied' : '⧉ Copy Case ID'}</Btn>
236
- <Btn variant="ghost" onClick={() => g.nav('verdict', { result, done: true })}>Back</Btn>
237
- </div>
238
- </div>
239
- </Panel>
240
- <Btn variant="ghost" sm onClick={() => g.newCase()}>▸ Generate a New Case</Btn>
241
- </div>
242
- </div>
243
- )
244
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Accusation builder · Verdict reveal · Share card. The verdict is computed server-side
2
+ // (the sealed solution is read for the first time at /accuse); the client only displays it.
3
+ import { useEffect, useState } from 'preact/hooks'
4
+
5
+ import { accuse } from '../api'
6
+ import { useGame } from '../store'
7
+ import type { VerdictResult } from '../types'
8
+ import { playSfx } from '../ui/audio'
9
+ import { Btn, Chip, DialoguePanel, EvIcon, Hud, Panel, Portrait, Scene, Stamp, perpNoun } from '../ui/components'
10
+
11
+
12
+ export function AccusationScreen() {
13
+ const g = useGame()
14
+ const c = g.case
15
+ const a = g.state.accuse
16
+ const [submitting, setSubmitting] = useState(false)
17
+ const [err, setErr] = useState<string | null>(null)
18
+ const set = (field: 'suspect' | 'motive' | 'evidence', value: unknown) => g.dispatch({ type: 'ACCUSE', field, value })
19
+ const toggleEv = (id: string) => set('evidence', a.evidence.includes(id) ? a.evidence.filter((x) => x !== id) : [...a.evidence, id])
20
+ const ready = !!a.suspect && !!a.motive && a.evidence.length >= 1
21
+
22
+ // Smart defaults, filled ONCE on mount and only into empty fields: the suspect you
23
+ // came from (CORNERED chip) and the evidence that actually rattled someone.
24
+ useEffect(() => {
25
+ const pre = g.state.payload.suspect as string | undefined
26
+ if (pre && !a.suspect && c.suspects.some((s) => s.id === pre)) set('suspect', pre)
27
+ if (!a.evidence.length) {
28
+ const hits = c.evidence.filter((e) => (g.state.evHits[e.id] || []).length > 0).map((e) => e.id)
29
+ if (hits.length) set('evidence', hits)
30
+ }
31
+ // eslint-disable-next-line react-hooks/exhaustive-deps
32
+ }, [])
33
+
34
+ const rattledBy = (evId: string): string => {
35
+ const sids = g.state.evHits[evId] || []
36
+ if (!sids.length) return ''
37
+ const first = c.suspects.find((s) => s.id === sids[0])
38
+ if (!first) return ''
39
+ const name = first.name.split(' ')[0].toUpperCase()
40
+ return sids.length > 1 ? `★ RATTLED ${name} +${sids.length - 1}` : `★ RATTLED ${name}`
41
+ }
42
+
43
+ const submit = async () => {
44
+ if (!ready || submitting) return
45
+ playSfx('accuse')
46
+ setSubmitting(true)
47
+ setErr(null)
48
+ try {
49
+ const result = await accuse(g.runId, { suspectId: a.suspect!, motiveId: a.motive!, evidenceIds: a.evidence })
50
+ g.nav('verdict', { result })
51
+ } catch {
52
+ setErr('The line dropped. Try closing the case again.')
53
+ setSubmitting(false)
54
+ }
55
+ }
56
+
57
+ return (
58
+ <div class="app__view" style={{ background: 'var(--ink-0)' }}>
59
+ <Hud title="THE ACCUSATION" sub="THIS CANNOT BE UNDONE" right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Back</Btn>} />
60
+ <div style={{ position: 'absolute', inset: 0, top: 56, background: 'radial-gradient(70% 60% at 50% 30%, rgba(135,41,42,.10), transparent 70%)', pointerEvents: 'none' }} />
61
+ <div class="screen-pad">
62
+ <div class="maxw" style={{ maxWidth: 1000 }}>
63
+ <div class="col" style={{ gap: 20 }}>
64
+ <section>
65
+ <div class="t-display ox" style={{ fontSize: 13, marginBottom: 4 }}>1 — NAME THE {perpNoun(c.kind).toUpperCase()}</div>
66
+ <div class="t-body dim" style={{ fontSize: 12, marginBottom: 10 }}>Who did it?</div>
67
+ <div class="grid-4">
68
+ {c.suspects.map((s) => (
69
+ <Panel key={s.id} variant={a.suspect === s.id ? 'ox' : undefined} style={{ padding: 10, cursor: 'pointer', textAlign: 'center' }} onClick={() => set('suspect', s.id)}>
70
+ <div class="center" style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 5, marginBottom: 8 }}><Portrait id={s.sprite} px={4} blink={a.suspect === s.id} gender={s.gender} accent={s.accentColor} /></div>
71
+ <div class="t-display" style={{ fontSize: 9, color: a.suspect === s.id ? 'var(--ox-3)' : 'var(--bone-3)' }}>{s.name}</div>
72
+ <div class="t-label" style={{ marginTop: 3 }}>{s.tag}</div>
73
+ </Panel>
74
+ ))}
75
+ </div>
76
+ </section>
77
+
78
+ <section>
79
+ <div class="t-display ox" style={{ fontSize: 13, marginBottom: 4 }}>2 THE MOTIVE</div>
80
+ <div class="t-body dim" style={{ fontSize: 12, marginBottom: 10 }}>Why? Pick the motive that fits what you heard.</div>
81
+ <div class="grid-2">
82
+ {c.motives.map((m) => (
83
+ <Panel key={m.id} variant={a.motive === m.id ? 'amber' : undefined} style={{ padding: 12, cursor: 'pointer' }} onClick={() => set('motive', m.id)}>
84
+ <div class="row" style={{ gap: 10, alignItems: 'center' }}>
85
+ <span style={{ width: 16, height: 16, flexShrink: 0, background: a.motive === m.id ? 'var(--amber-2)' : 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)' }} />
86
+ <span class="t-body" style={{ fontSize: 14 }}>{m.text}</span>
87
+ </div>
88
+ </Panel>
89
+ ))}
90
+ </div>
91
+ </section>
92
+
93
+ <section>
94
+ <div class="t-display ox" style={{ fontSize: 13, marginBottom: 4 }}>3 — ATTACH SUPPORTING EVIDENCE</div>
95
+ <div class="t-body dim" style={{ fontSize: 12, marginBottom: 10 }}>What proves it? The exhibits that made them crack are pre-checked.</div>
96
+ <div class="row wrap" style={{ gap: 10 }}>
97
+ {c.evidence.map((e) => {
98
+ const on = a.evidence.includes(e.id)
99
+ const badge = rattledBy(e.id)
100
+ return (
101
+ <button key={e.id} onClick={() => toggleEv(e.id)} style={{ border: 0, background: 'transparent', padding: 0, cursor: 'pointer' }}>
102
+ <Panel variant={on ? 'amber' : undefined} style={{ padding: 8 }}>
103
+ <div class="row" style={{ gap: 8, alignItems: 'center' }}>
104
+ <div style={{ background: 'var(--ink-1)', padding: 3 }}><EvIcon icon={e.icon} px={2} /></div>
105
+ <span class="t-display" style={{ fontSize: 8, color: on ? 'var(--amber-2)' : 'var(--bone-2)' }}>{e.name}</span>
106
+ {badge && <Chip variant="ox">{badge}</Chip>}
107
+ {on && <span class="amber">✓</span>}
108
+ </div>
109
+ </Panel>
110
+ </button>
111
+ )
112
+ })}
113
+ </div>
114
+ </section>
115
+
116
+ <Panel variant="ox" className="between wrap" style={{ gap: 12, padding: 16 }}>
117
+ <div class="t-body" style={{ fontSize: 14, maxWidth: 460 }}>
118
+ {err ? <span class="ox">{err}</span> : ready ? (
119
+ <>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}.</>
120
+ ) : (
121
+ `Select the ${perpNoun(c.kind)}, a motive, and at least one exhibit to proceed.`
122
+ )}
123
+ </div>
124
+ <Btn variant="ox" disabled={!ready || submitting} style={{ fontSize: 13, padding: '15px 26px' }} onClick={submit}>
125
+ {submitting ? 'Closing…' : 'Close the Case ▸▸'}
126
+ </Btn>
127
+ </Panel>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ export function VerdictScreen() {
136
+ const g = useGame()
137
+ const c = g.case
138
+ const result = g.state.payload.result as VerdictResult | undefined
139
+ const instant = g.state.payload.done === true // returning from Share - show it finished, no replay
140
+ const [phase, setPhase] = useState(instant ? 2 : 0)
141
+ useEffect(() => {
142
+ if (instant) return
143
+ if (result) playSfx(result.correct ? 'success' : 'fail')
144
+ const t1 = setTimeout(() => setPhase(1), 700)
145
+ const t2 = setTimeout(() => setPhase(2), 2200)
146
+ return () => {
147
+ clearTimeout(t1)
148
+ clearTimeout(t2)
149
+ }
150
+ }, [])
151
+
152
+ if (!result) {
153
+ return (
154
+ <div class="app__view screen-center">
155
+ <Panel className="col center" style={{ gap: 14, padding: 24 }}>
156
+ <span class="t-display amber">NO ACCUSATION ON FILE</span>
157
+ <Btn variant="amber" onClick={() => g.nav('accuse')}>Build the accusation ▸</Btn>
158
+ </Panel>
159
+ </div>
160
+ )
161
+ }
162
+
163
+ const correct = result.correct
164
+ const killer = c.suspects.find((s) => s.id === result.verdict.killerId)
165
+ const killerSprite = killer?.sprite || c.victim.sprite
166
+ const killerGender = killer?.gender
167
+ const stats = result.stats?.length ? result.stats : g.runStats()
168
+ return (
169
+ <div class="app__view" style={{ background: 'var(--ink-0)', position: 'relative' }}>
170
+ <Scene name={c.scene} w={320} h={200} anim cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.5 }} />
171
+ <div class="fx-scanlines" style={{ position: 'absolute', inset: 0, opacity: 0.4 }} />
172
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(80% 70% at 50% 40%, transparent 20%, rgba(8,11,16,.85) 80%)' }} />
173
+ <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
174
+ <div class="col center" style={{ gap: 20, maxWidth: 720, textAlign: 'center' }}>
175
+ {phase >= 0 && (
176
+ <Stamp slam={!instant} style={{ fontSize: 'clamp(16px,4vw,26px)', borderColor: correct ? 'var(--slate-3)' : 'var(--ox-3)', color: correct ? 'var(--slate-3)' : 'var(--ox-3)' }}>
177
+ {result.verdict.stamp}
178
+ </Stamp>
179
+ )}
180
+ {phase >= 1 && (
181
+ <div class="col center" style={{ gap: 6 }}>
182
+ <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} accent={killer?.accentColor} /></div>
183
+ <span class="t-display amber" style={{ fontSize: 11 }}>{result.verdict.killerName}</span>
184
+ <span class="t-label">THE {perpNoun(c.kind).toUpperCase()}</span>
185
+ </div>
186
+ )}
187
+ {phase >= 2 && (
188
+ <>
189
+ <Panel style={{ maxWidth: 640 }}>
190
+ <DialoguePanel who={correct ? 'THE TRUTH' : 'THE TRUTH ESCAPES'} text={result.verdict.truth} speed={g.state.tweaks.typeSpeed || 16} tag={correct ? null : 'WRONG'} />
191
+ </Panel>
192
+ {result.score && (
193
+ <div class="t-mono amber" style={{ fontSize: 'calc(18px*var(--mono-scale))' }}>SCORE {result.score.points}/{result.score.max}</div>
194
+ )}
195
+ <div class="row wrap center" style={{ gap: 10 }}>
196
+ {stats.map(([k, v], i) => (
197
+ <Panel key={i} style={{ padding: '10px 16px', textAlign: 'center' }}>
198
+ <div class="t-mono amber" style={{ fontSize: 'calc(20px*var(--mono-scale))' }}>{v}</div>
199
+ <div class="t-label" style={{ marginTop: 3 }}>{k}</div>
200
+ </Panel>
201
+ ))}
202
+ </div>
203
+ <div class="row wrap center" style={{ gap: 10 }}>
204
+ <Btn variant="amber" onClick={() => g.nav('share', { result })}>Share Case Card ▸</Btn>
205
+ <Btn variant="ghost" onClick={() => g.newCase()}>New Case</Btn>
206
+ </div>
207
+ </>
208
+ )}
209
+ </div>
210
+ </div>
211
+ </div>
212
+ )
213
+ }
214
+
215
+ export function ShareScreen() {
216
+ const g = useGame()
217
+ const c = g.case
218
+ const result = g.state.payload.result as VerdictResult | undefined
219
+ const correct = !!result?.correct
220
+ const stats = result?.stats?.length ? result.stats : g.runStats()
221
+ const [copied, setCopied] = useState(false)
222
+ const copy = () => {
223
+ try {
224
+ navigator.clipboard.writeText(c.id)
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ setCopied(true)
229
+ setTimeout(() => setCopied(false), 1600)
230
+ }
231
+ return (
232
+ <div class="app__view screen-center" style={{ background: 'var(--ink-0)' }}>
233
+ <div class="col center" style={{ gap: 16, width: '100%' }}>
234
+ <Panel variant="amber" style={{ width: 'min(440px,92vw)', padding: 0, overflow: 'hidden' }}>
235
+ <div style={{ position: 'relative', height: 140 }}>
236
+ <Scene name={c.scene} w={240} h={140} anim style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
237
+ <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(transparent, var(--ink-2))' }} />
238
+ <div style={{ position: 'absolute', top: 10, left: 12 }}><span class="t-label" style={{ color: 'var(--amber-2)', letterSpacing: '.2em' }}>CASE ZERO</span></div>
239
+ <div style={{ position: 'absolute', bottom: 8, right: 10 }}><Stamp style={{ fontSize: 12, borderColor: correct ? 'var(--slate-3)' : 'var(--ox-3)', color: correct ? 'var(--slate-3)' : 'var(--ox-3)' }}>{correct ? 'SOLVED' : 'MISTRIAL'}</Stamp></div>
240
+ </div>
241
+ <div class="col" style={{ gap: 12, padding: 18 }}>
242
+ <div class="between">
243
+ <div class="col" style={{ gap: 3 }}>
244
+ <span class="t-label">CASE FILE</span>
245
+ <span class="t-mono" style={{ fontSize: 'calc(24px*var(--mono-scale))', color: 'var(--amber-2)' }}>{c.id}</span>
246
+ </div>
247
+ <div style={{ background: 'var(--ink-1)', padding: 4, boxShadow: 'inset 0 0 0 2px var(--ink-0)' }}><Portrait id={c.victim.sprite} px={3} /></div>
248
+ </div>
249
+ <hr class="hr-pixel" />
250
+ <div class="t-body" style={{ fontSize: 14 }}><span class="dim">{c.title}</span> — {c.victim.name}, {c.victim.role.split('—')[0].trim()}.</div>
251
+ <div class="row" style={{ gap: 8 }}>
252
+ {stats.map(([k, v], i) => (
253
+ <div key={i} class="grow" style={{ background: 'var(--ink-1)', padding: '8px 6px', textAlign: 'center', boxShadow: 'inset 0 0 0 2px var(--ink-0)' }}>
254
+ <div class="t-mono amber" style={{ fontSize: 'calc(17px*var(--mono-scale))' }}>{v}</div>
255
+ <div class="t-label" style={{ fontSize: 7 }}>{k}</div>
256
+ </div>
257
+ ))}
258
+ </div>
259
+ <div class="t-body amber" style={{ fontSize: 14, textAlign: 'center', fontStyle: 'italic' }}>Same city. Same body. Can you close it faster?</div>
260
+ <div class="row" style={{ gap: 8 }}>
261
+ <Btn variant="amber" className="grow" onClick={copy}>{copied ? '✓ ID Copied' : '⧉ Copy Case ID'}</Btn>
262
+ <Btn variant="ghost" onClick={() => g.nav('verdict', { result, done: true })}>Back</Btn>
263
+ </div>
264
+ </div>
265
+ </Panel>
266
+ <Btn variant="ghost" sm onClick={() => g.newCase()}>▸ Generate a New Case</Btn>
267
+ </div>
268
+ </div>
269
+ )
270
+ }
web/src/screens/evidence.tsx CHANGED
@@ -1,156 +1,158 @@
1
- // Evidence inspector — per-type layouts. Type is chosen from the payload shape so it
2
- // generalizes to any generated exhibit.
3
- 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, ExhibitArt, Hud, Panel, Scene } from '../ui/components'
8
-
9
- function PhoneThread({ e }: { e: Evidence }) {
10
- return (
11
- <Panel tab="DEVICE — MESSAGES" style={{ maxWidth: 420 }}>
12
- <div style={{ background: 'var(--ink-0)', boxShadow: 'inset 0 0 0 3px var(--ink-1)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
13
- <div class="center t-label" style={{ marginBottom: 4 }}>● ENCRYPTED ●</div>
14
- {(e.thread || []).map((m, i) => (
15
- <div key={i} style={{ alignSelf: m.from === 'me' ? 'flex-end' : 'flex-start', maxWidth: '82%' }}>
16
- <div class="t-mono dim" style={{ fontSize: 'calc(11px*var(--mono-scale))', textAlign: m.from === 'me' ? 'right' : 'left', marginBottom: 2 }}>{m.who} · {m.t}</div>
17
- <div class="t-body" style={{ fontSize: 14, padding: '8px 10px', lineHeight: 1.4, background: m.from === 'me' ? 'var(--slate-1)' : 'var(--ink-3)', color: m.from === 'me' ? 'var(--bone-3)' : 'var(--bone-2)', boxShadow: 'inset 0 0 0 2px var(--ink-0)' }}>{m.m}</div>
18
- </div>
19
- ))}
20
- </div>
21
- </Panel>
22
- )
23
- }
24
-
25
- function Voicemail({ e }: { e: Evidence }) {
26
- const [playing, setPlaying] = useState(false)
27
- const [t, setT] = useState(0)
28
- useEffect(() => {
29
- if (!playing) return
30
- const id = setInterval(() => setT((v) => { if (v >= 100) { setPlaying(false); return 100 } return v + 2.5 }), 90)
31
- return () => clearInterval(id)
32
- }, [playing])
33
- const bars = 48
34
- return (
35
- <Panel tab="AUDIO — VOICEMAIL" variant="amber">
36
- <div class="row" style={{ gap: 14, alignItems: 'center' }}>
37
- <Btn variant="amber" onClick={() => { if (t >= 100) setT(0); setPlaying((p) => !p) }} style={{ fontSize: 16, width: 52, height: 52 }}>{playing ? '❚❚' : '▶'}</Btn>
38
- <div class="grow">
39
- <div style={{ display: 'flex', alignItems: 'center', gap: 2, height: 44 }}>
40
- {Array.from({ length: bars }).map((_, i) => {
41
- const h = 8 + Math.abs(Math.sin(i * 0.7) * 30) + (i % 3) * 5
42
- const on = (i / bars) * 100 <= t
43
- const live = playing && Math.abs((i / bars) * 100 - t) < 6
44
- return <span key={i} style={{ flex: 1, height: live ? h + 8 : h, background: on ? 'var(--amber-2)' : 'var(--slate-1)', transition: 'height .08s' }} />
45
- })}
46
- </div>
47
- <div class="between" style={{ marginTop: 6 }}>
48
- <span class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{playing || t > 0 ? '0:' + String(Math.floor((t / 100) * 19)).padStart(2, '0') : '0:00'}</span>
49
- <span class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{e.dur}</span>
50
- </div>
51
- </div>
52
- </div>
53
- <hr class="hr-pixel" style={{ margin: '12px 0' }} />
54
- <p class="t-body" style={{ fontSize: 14, fontStyle: 'italic', color: t > 30 ? 'var(--bone-3)' : 'var(--bone-1)' }}>{e.transcript}</p>
55
- </Panel>
56
- )
57
- }
58
-
59
- function KeycardTable({ e }: { e: Evidence }) {
60
- return (
61
- <Panel tab="ACCESS LOG — RFID" style={{ maxWidth: 560 }}>
62
- <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto auto', gap: 0, fontFamily: 'var(--f-mono)', fontSize: 'calc(15px*var(--mono-scale))' }}>
63
- {['TIME', 'DOOR', 'CARD', ''].map((h, i) => <div key={i} class="t-label" style={{ padding: '6px 8px', background: 'var(--ink-1)' }}>{h}</div>)}
64
- {(e.rows || []).map((r, i) => (
65
- <>
66
- {r.slice(0, 3).map((cell, j) => <div key={`${i}-${j}`} style={{ padding: '8px', color: r[3] === 'flag' ? 'var(--ox-3)' : 'var(--bone-2)', boxShadow: 'inset 0 2px 0 var(--ink-1)' }}>{cell}</div>)}
67
- <div key={`${i}-s`} style={{ padding: '8px', boxShadow: 'inset 0 2px 0 var(--ink-1)' }}>{r[3] === 'flag' ? <Chip variant="ox">⚑</Chip> : <span class="dim">ok</span>}</div>
68
- </>
69
- ))}
70
- </div>
71
- </Panel>
72
- )
73
- }
74
-
75
- function ImageExhibit({ e, scene, tab, rec }: { e: Evidence; scene: string; tab: string; rec?: boolean }) {
76
- return (
77
- <Panel tab={tab} variant={rec ? 'ox' : undefined} style={{ maxWidth: 520 }}>
78
- <div style={{ position: 'relative', aspectRatio: rec ? '4/3' : '16/10', boxShadow: 'inset 0 0 0 3px var(--ink-0)', border: rec ? undefined : '6px solid var(--bone-2)' }}>
79
- <Scene name={scene} w={240} h={180} style={{ width: '100%', height: '100%' }} />
80
- <div class="fx-scanlines" style={{ position: 'absolute', inset: 0, opacity: rec ? 0.5 : 0 }} />
81
- {rec && <div class="t-mono ox" style={{ position: 'absolute', top: 6, left: 8 }}>● REC {e.time.replace(/[^\d:]/g, '')}</div>}
82
- <div class="t-mono" style={{ position: 'absolute', bottom: 8, right: 10, color: 'var(--ox-3)', fontSize: 'calc(13px*var(--mono-scale))' }}>▣ EVIDENCE</div>
83
- </div>
84
- {e.detail && <p class="t-body" style={{ fontSize: 14, marginTop: 10, whiteSpace: 'pre-line' }}>{e.detail}</p>}
85
- </Panel>
86
- )
87
- }
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
- )
98
- }
99
-
100
- function EvidenceDetail({ e }: { e: Evidence }) {
101
- const g = useGame()
102
- if (e.thread) return <PhoneThread e={e} />
103
- if (e.transcript) return <Voicemail e={e} />
104
- if (e.rows) return <KeycardTable e={e} />
105
- if (e.type === 'IMAGE') {
106
- const cam = e.icon === 'cctv'
107
- return <ImageExhibit e={e} scene={cam ? 'mezzanine' : g.case.scene} tab={cam ? 'CAM STILL' : 'FORENSIC — SCENE'} rec={cam} />
108
- }
109
- return <PaperItem e={e} />
110
- }
111
-
112
- export function EvidenceScreen() {
113
- const g = useGame()
114
- const c = g.case
115
- const [focus, setFocus] = useState<string>((g.state.payload.focus as string) || c.evidence[0].id)
116
- const e = c.evidence.find((x) => x.id === focus)!
117
- const pinned = g.state.pinned.includes(focus)
118
- return (
119
- <div class="app__view">
120
- <Hud title="EVIDENCE LOCKER" sub={`EXHIBIT — ${e.name}`} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} />
121
- <div class="interro" style={{ gridTemplateColumns: g.mode === 'mobile' ? '1fr' : '260px 1fr' }}>
122
- <div class="interro__right scroll-y" style={{ padding: 12, gap: 8, display: g.mode === 'mobile' ? 'none' : 'flex', flexDirection: 'column' }}>
123
- <span class="t-label" style={{ marginBottom: 4 }}>{c.evidence.length} EXHIBITS RECOVERED</span>
124
- {c.evidence.map((x) => <EvidenceCard key={x.id} e={x} small active={x.id === focus} onClick={() => setFocus(x.id)} />)}
125
- </div>
126
- <div class="screen-pad" style={{ background: 'var(--ink-0)' }}>
127
- <div class="maxw" style={{ maxWidth: 820 }}>
128
- {g.mode === 'mobile' && (
129
- <div class="row" style={{ gap: 8, overflowX: 'auto', marginBottom: 12, paddingBottom: 4 }}>
130
- {c.evidence.map((x) => <Btn key={x.id} sm variant={x.id === focus ? 'amber' : 'ghost'} onClick={() => setFocus(x.id)}>{x.name.split(' ')[0]}</Btn>)}
131
- </div>
132
- )}
133
- <div class="row between wrap" style={{ marginBottom: 14, gap: 10 }}>
134
- <div class="row" style={{ gap: 12, alignItems: 'center' }}>
135
- <div style={{ background: 'var(--ink-2)', boxShadow: 'inset 0 0 0 3px var(--ink-0)', padding: 6 }}><EvIcon icon={e.icon} px={4} /></div>
136
- <div class="col" style={{ gap: 4 }}>
137
- <h2 class="t-display" style={{ fontSize: 18, color: 'var(--bone-3)' }}>{e.name}</h2>
138
- <div class="row" style={{ gap: 8 }}><Chip variant="amber">{e.type}</Chip><span class="t-mono dim" style={{ fontSize: 'calc(14px*var(--mono-scale))' }}>{e.time}</span></div>
139
- </div>
140
- </div>
141
- </div>
142
- <p class="t-body" style={{ marginBottom: 14, color: 'var(--bone-2)' }}>{e.summary}</p>
143
- <EvidenceDetail e={e} key={e.id} />
144
- <div class="t-label" style={{ margin: '12px 2px' }}>{e.found}</div>
145
- <div class="row wrap" style={{ gap: 10, marginTop: 6 }}>
146
- <Btn variant={pinned ? 'ghost' : 'amber'} onClick={() => g.dispatch({ type: 'PIN', ev: focus })}>{pinned ? '✓ Pinned to Board' : '▸ Pin to Board'}</Btn>
147
- <Btn variant="ox" onClick={() => g.nav('interro', { suspect: c.suspects[0].id })}>Use in Interrogation</Btn>
148
- <Btn variant="ghost" onClick={() => g.nav('flashback')}>Reconstruct ▸</Btn>
149
- </div>
150
- </div>
151
- </div>
152
- </div>
153
- <BottomNav />
154
- </div>
155
- )
156
- }
 
 
 
1
+ // Evidence inspector — per-type layouts. Type is chosen from the payload shape so it
2
+ // generalizes to any generated exhibit.
3
+ 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, ExhibitArt, Hud, Panel, Scene } from '../ui/components'
8
+
9
+ function PhoneThread({ e }: { e: Evidence }) {
10
+ return (
11
+ <Panel tab="DEVICE — MESSAGES" style={{ maxWidth: 420 }}>
12
+ <div style={{ background: 'var(--ink-0)', boxShadow: 'inset 0 0 0 3px var(--ink-1)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
13
+ <div class="center t-label" style={{ marginBottom: 4 }}>● ENCRYPTED ●</div>
14
+ {(e.thread || []).map((m, i) => (
15
+ <div key={i} style={{ alignSelf: m.from === 'me' ? 'flex-end' : 'flex-start', maxWidth: '82%' }}>
16
+ <div class="t-mono dim" style={{ fontSize: 'calc(11px*var(--mono-scale))', textAlign: m.from === 'me' ? 'right' : 'left', marginBottom: 2 }}>{m.who} · {m.t}</div>
17
+ <div class="t-body" style={{ fontSize: 14, padding: '8px 10px', lineHeight: 1.4, background: m.from === 'me' ? 'var(--slate-1)' : 'var(--ink-3)', color: m.from === 'me' ? 'var(--bone-3)' : 'var(--bone-2)', boxShadow: 'inset 0 0 0 2px var(--ink-0)' }}>{m.m}</div>
18
+ </div>
19
+ ))}
20
+ </div>
21
+ </Panel>
22
+ )
23
+ }
24
+
25
+ function Voicemail({ e }: { e: Evidence }) {
26
+ const [playing, setPlaying] = useState(false)
27
+ const [t, setT] = useState(0)
28
+ useEffect(() => {
29
+ if (!playing) return
30
+ const id = setInterval(() => setT((v) => { if (v >= 100) { setPlaying(false); return 100 } return v + 2.5 }), 90)
31
+ return () => clearInterval(id)
32
+ }, [playing])
33
+ const bars = 48
34
+ return (
35
+ <Panel tab="AUDIO — VOICEMAIL" variant="amber">
36
+ <div class="row" style={{ gap: 14, alignItems: 'center' }}>
37
+ <Btn variant="amber" onClick={() => { if (t >= 100) setT(0); setPlaying((p) => !p) }} style={{ fontSize: 16, width: 52, height: 52 }}>{playing ? '❚❚' : '▶'}</Btn>
38
+ <div class="grow">
39
+ <div style={{ display: 'flex', alignItems: 'center', gap: 2, height: 44 }}>
40
+ {Array.from({ length: bars }).map((_, i) => {
41
+ const h = 8 + Math.abs(Math.sin(i * 0.7) * 30) + (i % 3) * 5
42
+ const on = (i / bars) * 100 <= t
43
+ const live = playing && Math.abs((i / bars) * 100 - t) < 6
44
+ return <span key={i} style={{ flex: 1, height: live ? h + 8 : h, background: on ? 'var(--amber-2)' : 'var(--slate-1)', transition: 'height .08s' }} />
45
+ })}
46
+ </div>
47
+ <div class="between" style={{ marginTop: 6 }}>
48
+ <span class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{playing || t > 0 ? '0:' + String(Math.floor((t / 100) * 19)).padStart(2, '0') : '0:00'}</span>
49
+ <span class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{e.dur}</span>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ <hr class="hr-pixel" style={{ margin: '12px 0' }} />
54
+ <p class="t-body" style={{ fontSize: 14, fontStyle: 'italic', color: t > 30 ? 'var(--bone-3)' : 'var(--bone-1)' }}>{e.transcript}</p>
55
+ </Panel>
56
+ )
57
+ }
58
+
59
+ function KeycardTable({ e }: { e: Evidence }) {
60
+ return (
61
+ <Panel tab="ACCESS LOG — RFID" style={{ maxWidth: 560 }}>
62
+ <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto auto', gap: 0, fontFamily: 'var(--f-mono)', fontSize: 'calc(15px*var(--mono-scale))' }}>
63
+ {['TIME', 'DOOR', 'CARD', ''].map((h, i) => <div key={i} class="t-label" style={{ padding: '6px 8px', background: 'var(--ink-1)' }}>{h}</div>)}
64
+ {(e.rows || []).map((r, i) => (
65
+ <>
66
+ {r.slice(0, 3).map((cell, j) => <div key={`${i}-${j}`} style={{ padding: '8px', color: r[3] === 'flag' ? 'var(--ox-3)' : 'var(--bone-2)', boxShadow: 'inset 0 2px 0 var(--ink-1)' }}>{cell}</div>)}
67
+ <div key={`${i}-s`} style={{ padding: '8px', boxShadow: 'inset 0 2px 0 var(--ink-1)' }}>{r[3] === 'flag' ? <Chip variant="ox">⚑</Chip> : <span class="dim">ok</span>}</div>
68
+ </>
69
+ ))}
70
+ </div>
71
+ </Panel>
72
+ )
73
+ }
74
+
75
+ function ImageExhibit({ e, scene, tab, rec }: { e: Evidence; scene: string; tab: string; rec?: boolean }) {
76
+ return (
77
+ <Panel tab={tab} variant={rec ? 'ox' : undefined} style={{ maxWidth: 520 }}>
78
+ <div style={{ position: 'relative', aspectRatio: rec ? '4/3' : '16/10', boxShadow: 'inset 0 0 0 3px var(--ink-0)', border: rec ? undefined : '6px solid var(--bone-2)' }}>
79
+ <Scene name={scene} w={240} h={180} style={{ width: '100%', height: '100%' }} />
80
+ <div class="fx-scanlines" style={{ position: 'absolute', inset: 0, opacity: rec ? 0.5 : 0 }} />
81
+ {rec && <div class="t-mono ox" style={{ position: 'absolute', top: 6, left: 8 }}>● REC {e.time.replace(/[^\d:]/g, '')}</div>}
82
+ <div class="t-mono" style={{ position: 'absolute', bottom: 8, right: 10, color: 'var(--ox-3)', fontSize: 'calc(13px*var(--mono-scale))' }}>▣ EVIDENCE</div>
83
+ </div>
84
+ {e.detail && <p class="t-body" style={{ fontSize: 14, marginTop: 10, whiteSpace: 'pre-line' }}>{e.detail}</p>}
85
+ </Panel>
86
+ )
87
+ }
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
+ )
98
+ }
99
+
100
+ function EvidenceDetail({ e }: { e: Evidence }) {
101
+ const g = useGame()
102
+ if (e.thread) return <PhoneThread e={e} />
103
+ if (e.transcript) return <Voicemail e={e} />
104
+ if (e.rows) return <KeycardTable e={e} />
105
+ if (e.type === 'IMAGE') {
106
+ const cam = e.icon === 'cctv'
107
+ // CCTV frames show the place the exhibit was recovered from ("Recovered from X.").
108
+ const loc = (e.found || '').replace(/^Recovered from /i, '').replace(/\.$/, '').trim()
109
+ return <ImageExhibit e={e} scene={cam ? (loc || g.case.scene) : g.case.scene} tab={cam ? 'CAM — STILL' : 'FORENSIC — SCENE'} rec={cam} />
110
+ }
111
+ return <PaperItem e={e} />
112
+ }
113
+
114
+ export function EvidenceScreen() {
115
+ const g = useGame()
116
+ const c = g.case
117
+ const [focus, setFocus] = useState<string>((g.state.payload.focus as string) || c.evidence[0].id)
118
+ const e = c.evidence.find((x) => x.id === focus)!
119
+ const pinned = g.state.pinned.includes(focus)
120
+ return (
121
+ <div class="app__view">
122
+ <Hud title="EVIDENCE LOCKER" sub={`EXHIBIT ${e.name}`} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} />
123
+ <div class="interro" style={{ gridTemplateColumns: g.mode === 'mobile' ? '1fr' : '260px 1fr' }}>
124
+ <div class="interro__right scroll-y" style={{ padding: 12, gap: 8, display: g.mode === 'mobile' ? 'none' : 'flex', flexDirection: 'column' }}>
125
+ <span class="t-label" style={{ marginBottom: 4 }}>{c.evidence.length} EXHIBITS RECOVERED</span>
126
+ {c.evidence.map((x) => <EvidenceCard key={x.id} e={x} small active={x.id === focus} onClick={() => setFocus(x.id)} />)}
127
+ </div>
128
+ <div class="screen-pad" style={{ background: 'var(--ink-0)' }}>
129
+ <div class="maxw" style={{ maxWidth: 820 }}>
130
+ {g.mode === 'mobile' && (
131
+ <div class="row" style={{ gap: 8, overflowX: 'auto', marginBottom: 12, paddingBottom: 4 }}>
132
+ {c.evidence.map((x) => <Btn key={x.id} sm variant={x.id === focus ? 'amber' : 'ghost'} onClick={() => setFocus(x.id)}>{x.name.split(' ')[0]}</Btn>)}
133
+ </div>
134
+ )}
135
+ <div class="row between wrap" style={{ marginBottom: 14, gap: 10 }}>
136
+ <div class="row" style={{ gap: 12, alignItems: 'center' }}>
137
+ <div style={{ background: 'var(--ink-2)', boxShadow: 'inset 0 0 0 3px var(--ink-0)', padding: 6 }}><EvIcon icon={e.icon} px={4} /></div>
138
+ <div class="col" style={{ gap: 4 }}>
139
+ <h2 class="t-display" style={{ fontSize: 18, color: 'var(--bone-3)' }}>{e.name}</h2>
140
+ <div class="row" style={{ gap: 8 }}><Chip variant="amber">{e.type}</Chip><span class="t-mono dim" style={{ fontSize: 'calc(14px*var(--mono-scale))' }}>{e.time}</span></div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ <p class="t-body" style={{ marginBottom: 14, color: 'var(--bone-2)' }}>{e.summary}</p>
145
+ <EvidenceDetail e={e} key={e.id} />
146
+ <div class="t-label" style={{ margin: '12px 2px' }}> {e.found}</div>
147
+ <div class="row wrap" style={{ gap: 10, marginTop: 6 }}>
148
+ <Btn variant={pinned ? 'ghost' : 'amber'} onClick={() => g.dispatch({ type: 'PIN', ev: focus })}>{pinned ? '✓ Pinned to Board' : ' Pin to Board'}</Btn>
149
+ <Btn variant="ox" onClick={() => g.nav('interro', { suspect: c.suspects[0].id })}>Use in Interrogation</Btn>
150
+ <Btn variant="ghost" onClick={() => g.nav('flashback')}>Reconstruct ▸</Btn>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ <BottomNav />
156
+ </div>
157
+ )
158
+ }
web/src/screens/interro.tsx CHANGED
@@ -3,10 +3,11 @@
3
  import { useEffect, useRef, useState } from 'preact/hooks'
4
 
5
  import { interrogate } from '../api'
6
- import { useGame } from '../store'
7
  import type { Evidence, SuggestedQuestion } from '../types'
8
  import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio'
9
  import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components'
 
10
 
11
  interface Pending {
12
  text: string
@@ -29,6 +30,8 @@ export function Interrogation() {
29
  const [talking, setTalking] = useState(false)
30
  const [tray, setTray] = useState(false)
31
  const [input, setInput] = useState('')
 
 
32
  const scroller = useRef<HTMLDivElement>(null)
33
  const busy = thinking || !!pending
34
 
@@ -59,6 +62,25 @@ export function Interrogation() {
59
  try {
60
  const r = await interrogate(g.runId, sid, body)
61
  const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  // Synthesize the voice during the "thinking" beat so text + speech start together.
63
  const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false))
64
  // Pace the typewriter to the audio so words land in step with the spoken line.
@@ -103,7 +125,13 @@ export function Interrogation() {
103
  sub={s.role}
104
  right={
105
  <>
106
- <div style={{ width: g.mode === 'mobile' ? 96 : 180 }}><SuspicionBar value={susp} /></div>
 
 
 
 
 
 
107
  <Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>
108
  </>
109
  }
@@ -112,7 +140,8 @@ export function Interrogation() {
112
  <div class="interro__stage">
113
  <Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
114
  <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} />
115
- <div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={sid} px={px} gender={s.gender} talking={talking} /></div>
 
116
  <div style={{ position: 'absolute', top: 12, left: 12, right: 12, zIndex: 4 }}>
117
  <Panel style={{ padding: 8, width: 'fit-content', maxWidth: '100%' }}>
118
  <span class="t-label">{s.tag}</span>
 
3
  import { useEffect, useRef, useState } from 'preact/hooks'
4
 
5
  import { interrogate } from '../api'
6
+ import { HOT_SUSPICION, useGame } from '../store'
7
  import type { Evidence, SuggestedQuestion } from '../types'
8
  import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio'
9
  import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components'
10
+ import { CrackBanner, DeltaFloater } from '../ui/juice'
11
 
12
  interface Pending {
13
  text: string
 
30
  const [talking, setTalking] = useState(false)
31
  const [tray, setTray] = useState(false)
32
  const [input, setInput] = useState('')
33
+ const [floater, setFloater] = useState<{ delta: number; id: number } | null>(null)
34
+ const [crack, setCrack] = useState(false)
35
  const scroller = useRef<HTMLDivElement>(null)
36
  const busy = thinking || !!pending
37
 
 
62
  try {
63
  const r = await interrogate(g.runId, sid, body)
64
  const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null
65
+ // Feedback moments: record what rattled whom, float the suspicion delta, and
66
+ // slam the banner the FIRST time this suspect's lie cracks.
67
+ const flags = r.flags || {}
68
+ const crackedNow = !!(flags.cornered || flags.contradictionExposed)
69
+ const firstCrack = crackedNow && !g.state.cracked[sid]
70
+ if (crackedNow || (flags.rattled && body.presentEvidenceId)) {
71
+ g.dispatch({
72
+ type: 'FLAG',
73
+ sid,
74
+ evId: body.presentEvidenceId,
75
+ kind: flags.contradictionExposed ? 'contradiction' : flags.cornered ? 'cornered' : 'rattled',
76
+ })
77
+ }
78
+ if (typeof r.suspicionDelta === 'number' && r.suspicionDelta !== 0) setFloater({ delta: r.suspicionDelta, id: Date.now() })
79
+ if (firstCrack) {
80
+ setCrack(true)
81
+ playSfx('success')
82
+ setTimeout(() => setCrack(false), 1700)
83
+ }
84
  // Synthesize the voice during the "thinking" beat so text + speech start together.
85
  const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false))
86
  // Pace the typewriter to the audio so words land in step with the spoken line.
 
125
  sub={s.role}
126
  right={
127
  <>
128
+ <div class="col" style={{ width: g.mode === 'mobile' ? 96 : 180, position: 'relative' }}>
129
+ <SuspicionBar value={susp} />
130
+ {floater && <DeltaFloater key={floater.id} delta={floater.delta} />}
131
+ {susp >= HOT_SUSPICION && (
132
+ <button class="accuse-chip" onClick={() => g.nav('accuse', { suspect: sid })}>CORNERED — ACCUSE ▸</button>
133
+ )}
134
+ </div>
135
  <Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>
136
  </>
137
  }
 
140
  <div class="interro__stage">
141
  <Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
142
  <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} />
143
+ <div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={s.sprite} px={px} gender={s.gender} accent={s.accentColor} talking={talking} /></div>
144
+ {crack && <CrackBanner />}
145
  <div style={{ position: 'absolute', top: 12, left: 12, right: 12, zIndex: 4 }}>
146
  <Panel style={{ padding: 8, width: 'fit-content', maxWidth: '100%' }}>
147
  <span class="t-label">{s.tag}</span>
web/src/screens/suspects.tsx CHANGED
@@ -4,6 +4,7 @@ import { useState } from 'preact/hooks'
4
 
5
  import { useGame } from '../store'
6
  import type { Suspect } from '../types'
 
7
  import { BottomNav, Btn, Hud, Panel, SuspectCard } from '../ui/components'
8
  import { Board } from './board'
9
 
@@ -19,6 +20,7 @@ export function SuspectsScreen() {
19
  sub={`${c.suspects.length} SUSPECTS · TAP, THEN INTERROGATE`}
20
  right={<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>}
21
  />
 
22
  <div class="screen-pad">
23
  <div class="col" style={{ gap: 10 }}>
24
  {c.suspects.map((s: Suspect) => {
 
4
 
5
  import { useGame } from '../store'
6
  import type { Suspect } from '../types'
7
+ import { Checklist } from '../ui/checklist'
8
  import { BottomNav, Btn, Hud, Panel, SuspectCard } from '../ui/components'
9
  import { Board } from './board'
10
 
 
20
  sub={`${c.suspects.length} SUSPECTS · TAP, THEN INTERROGATE`}
21
  right={<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>}
22
  />
23
+ <Checklist slim />
24
  <div class="screen-pad">
25
  <div class="col" style={{ gap: 10 }}>
26
  {c.suspects.map((s: Suspect) => {
web/src/store.tsx CHANGED
@@ -31,10 +31,17 @@ export interface GameState {
31
  usedQ: Record<string, string[]>
32
  usedEv: Record<string, string[]>
33
  pinned: string[]
 
 
 
 
34
  accuse: AccuseState
35
  startedAt: number
36
  }
37
 
 
 
 
38
  type Action =
39
  | { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
40
  | { type: 'SUSP_SET'; sid: string; value: number }
@@ -42,6 +49,7 @@ type Action =
42
  | { type: 'USEQ'; sid: string; qid: string }
43
  | { type: 'USEEV'; sid: string; ev: string }
44
  | { type: 'PIN'; ev: string }
 
45
  | { type: 'ACCUSE'; field: keyof AccuseState; value: unknown }
46
 
47
  function reducer(s: GameState, a: Action): GameState {
@@ -58,6 +66,15 @@ function reducer(s: GameState, a: Action): GameState {
58
  return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } }
59
  case 'PIN':
60
  return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] }
 
 
 
 
 
 
 
 
 
61
  case 'ACCUSE':
62
  return { ...s, accuse: { ...s.accuse, [a.field]: a.value } }
63
  default:
@@ -78,6 +95,8 @@ function initialState(c: PublicCase, screen: Screen = 'title'): GameState {
78
  usedQ: {},
79
  usedEv: {},
80
  pinned: [],
 
 
81
  accuse: { suspect: null, motive: null, evidence: [] },
82
  startedAt: Date.now(),
83
  }
 
31
  usedQ: Record<string, string[]>
32
  usedEv: Record<string, string[]>
33
  pinned: string[]
34
+ /** Suspects whose lie cracked (cornered / contradiction exposed) at least once. */
35
+ cracked: Record<string, boolean>
36
+ /** evidenceId -> suspect ids it rattled or cracked when presented. */
37
+ evHits: Record<string, string[]>
38
  accuse: AccuseState
39
  startedAt: number
40
  }
41
 
42
+ /** A suspect at or above this suspicion is "cornered" — ready to accuse. */
43
+ export const HOT_SUSPICION = 75
44
+
45
  type Action =
46
  | { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
47
  | { type: 'SUSP_SET'; sid: string; value: number }
 
49
  | { type: 'USEQ'; sid: string; qid: string }
50
  | { type: 'USEEV'; sid: string; ev: string }
51
  | { type: 'PIN'; ev: string }
52
+ | { type: 'FLAG'; sid: string; evId?: string; kind: 'rattled' | 'cornered' | 'contradiction' }
53
  | { type: 'ACCUSE'; field: keyof AccuseState; value: unknown }
54
 
55
  function reducer(s: GameState, a: Action): GameState {
 
66
  return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } }
67
  case 'PIN':
68
  return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] }
69
+ case 'FLAG': {
70
+ const cracked = a.kind !== 'rattled' ? { ...s.cracked, [a.sid]: true } : s.cracked
71
+ let { evHits } = s
72
+ if (a.evId) {
73
+ const cur = s.evHits[a.evId] || []
74
+ if (!cur.includes(a.sid)) evHits = { ...s.evHits, [a.evId]: [...cur, a.sid] }
75
+ }
76
+ return { ...s, cracked, evHits }
77
+ }
78
  case 'ACCUSE':
79
  return { ...s, accuse: { ...s.accuse, [a.field]: a.value } }
80
  default:
 
95
  usedQ: {},
96
  usedEv: {},
97
  pinned: [],
98
+ cracked: {},
99
+ evHits: {},
100
  accuse: { suspect: null, motive: null, evidence: [] },
101
  startedAt: Date.now(),
102
  }
web/src/styles/layout.css CHANGED
@@ -356,3 +356,60 @@
356
  display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
357
  }
358
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
357
  }
358
  }
359
+
360
+ /* ============================================================
361
+ GUIDANCE & JUICE — checklist, case brief, feedback moments
362
+ ============================================================ */
363
+ /* suspicion delta floater (interro hud) */
364
+ .delta-float{
365
+ position:absolute; right:0; top:-16px; font-family:var(--f-display); font-size:11px;
366
+ color:var(--amber-2); text-shadow:2px 2px 0 var(--ink-0); pointer-events:none; z-index:30;
367
+ animation:deltaRise 1.1s steps(7) both;
368
+ }
369
+ .delta-float--big{ font-size:14px; color:var(--ox-3); }
370
+ .delta-float--drop{ color:var(--slate-3); }
371
+ @keyframes deltaRise{
372
+ 0%{ transform:translateY(6px); opacity:0; }
373
+ 15%,70%{ opacity:1; }
374
+ 100%{ transform:translateY(-16px); opacity:0; }
375
+ }
376
+
377
+ /* "THE LIE CRACKS" banner over the interro stage */
378
+ .crack-banner{ position:absolute; inset:0; z-index:6; display:flex; align-items:center;
379
+ justify-content:center; pointer-events:none; }
380
+ .crack-banner__flash{ position:absolute; inset:0; background:var(--ox-2); animation:crackFlash .5s steps(3) both; }
381
+ @keyframes crackFlash{ 0%{ opacity:.4; } 100%{ opacity:0; } }
382
+
383
+ /* cornered CTA chip (under the interro suspicion bar) + pulsing accuse buttons */
384
+ .accuse-chip{
385
+ font-family:var(--f-display); font-size:8px; letter-spacing:.08em; border:0; margin-top:4px;
386
+ background:var(--ox-2); color:var(--bone-3); padding:5px 8px; box-shadow:2px 2px 0 var(--ink-0);
387
+ animation:ctaPulse 1s steps(2) infinite; cursor:pointer; white-space:nowrap;
388
+ }
389
+ .accuse-cta--pulse{ animation:ctaPulse 1s steps(2) infinite; }
390
+ @keyframes ctaPulse{ 50%{ filter:brightness(1.35); } }
391
+
392
+ /* detective's checklist */
393
+ .checklist{ display:flex; flex-direction:column; gap:4px; margin-top:8px; }
394
+ .checklist__step{
395
+ display:flex; gap:6px; align-items:baseline;
396
+ font-family:var(--f-display); font-size:8px; letter-spacing:.06em; color:var(--bone-2);
397
+ }
398
+ .checklist__step--done{ color:var(--slate-3); text-decoration:line-through; }
399
+ .checklist__box{ color:var(--amber-2); flex-shrink:0; }
400
+ .checklist__step--done .checklist__box{ color:var(--slate-3); }
401
+ .checklist--slim{
402
+ flex-direction:column; margin:0; padding:8px 12px; background:var(--ink-2);
403
+ box-shadow:inset 0 -3px 0 var(--ink-0); flex-shrink:0; z-index:15;
404
+ }
405
+ .checklist__toggle{
406
+ display:flex; justify-content:space-between; align-items:center; gap:8px;
407
+ background:transparent; border:0; padding:0; width:100%;
408
+ }
409
+
410
+ /* first-visit case brief overlay (board) */
411
+ .brief-veil{
412
+ position:absolute; inset:0; z-index:25; background:rgba(8,11,16,.78);
413
+ display:flex; align-items:center; justify-content:center; padding:18px;
414
+ }
415
+ .brief-card{ width:min(380px,92%); animation:slideup .25s steps(6); }
web/src/tips.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // One-time guidance memory: which partner nudges and per-case brief overlays this
2
+ // browser has already seen. Falls back to an in-memory set in private mode, so
3
+ // everything still fires exactly once per session there.
4
+ const KEY = 'cz-tips'
5
+ const CAP = 100
6
+
7
+ const mem = new Set<string>()
8
+
9
+ function stored(): string[] {
10
+ try {
11
+ const raw = localStorage.getItem(KEY)
12
+ if (!raw) return []
13
+ const data = JSON.parse(raw) as { v: 1; seen: string[] }
14
+ if (data?.v !== 1 || !Array.isArray(data.seen)) return []
15
+ return data.seen.filter((x) => typeof x === 'string')
16
+ } catch {
17
+ return []
18
+ }
19
+ }
20
+
21
+ export function hasSeen(key: string): boolean {
22
+ return mem.has(key) || stored().includes(key)
23
+ }
24
+
25
+ export function markSeen(key: string): void {
26
+ mem.add(key)
27
+ try {
28
+ const seen = stored().filter((x) => x !== key)
29
+ seen.push(key)
30
+ localStorage.setItem(KEY, JSON.stringify({ v: 1, seen: seen.slice(-CAP) }))
31
+ } catch {
32
+ /* private mode — the in-memory set covers this session */
33
+ }
34
+ }
web/src/types.ts CHANGED
@@ -1,129 +1,132 @@
1
- // TS mirror of the server's PUBLIC wire contract (src/case_zero/api/public_view.py).
2
-
3
- export interface Victim {
4
- name: string
5
- role: string
6
- age: number
7
- sprite: string
8
- bio: string
9
- }
10
-
11
- export interface SuggestedQuestion {
12
- id: string
13
- q: string
14
- }
15
-
16
- export interface Suspect {
17
- id: string
18
- name: string
19
- role: string
20
- age: number
21
- sprite: string
22
- gender: string
23
- tag: string
24
- baselineSuspicion: number
25
- motive: string
26
- alibi: string
27
- quote: string
28
- greet: string
29
- suggestedQuestions: SuggestedQuestion[]
30
- }
31
-
32
- export interface ThreadMessage {
33
- from: 'me' | 'them'
34
- who: string
35
- t: string
36
- m: string
37
- }
38
-
39
- export interface Evidence {
40
- id: string
41
- name: string
42
- type: string
43
- icon: string
44
- time: string
45
- found: string
46
- summary: string
47
- thread?: ThreadMessage[]
48
- detail?: string
49
- transcript?: string
50
- dur?: string
51
- rows?: string[][]
52
- }
53
-
54
- export interface TimelineBeat {
55
- time: string
56
- label: string
57
- locked: boolean
58
- ev?: string | null
59
- conflict: boolean
60
- }
61
-
62
- export interface FlashbackAccount {
63
- who: string
64
- scene: string
65
- lines: string[]
66
- flags: number[]
67
- }
68
-
69
- export interface Flashback {
70
- title: string
71
- a: FlashbackAccount
72
- b: FlashbackAccount
73
- }
74
-
75
- export interface Motive {
76
- id: string
77
- text: string
78
- }
79
-
80
- export interface StoryBeat {
81
- scene: string
82
- kicker: string
83
- title: string
84
- text: string
85
- }
86
-
87
- export interface PublicCase {
88
- id: string
89
- city: string
90
- district: string
91
- title: string
92
- tagline: string
93
- weather: string
94
- victim: Victim
95
- scene: string
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[]
109
- suspects: Suspect[]
110
- evidence: Evidence[]
111
- timeline: TimelineBeat[]
112
- flashback: Flashback
113
- motives: Motive[]
114
- }
115
-
116
- // --- interrogation / verdict wire shapes ---
117
- export interface InterrogateResult {
118
- reply: string
119
- suspicionDelta: number
120
- suspicion: number
121
- flags: { rattled?: boolean; contradictionExposed?: boolean; cornered?: boolean }
122
- }
123
-
124
- export interface VerdictResult {
125
- correct: boolean
126
- verdict: { stamp: string; killerId: string; killerName: string; truth: string }
127
- score: { points: number; max: number; killerCorrect: boolean; motiveCorrect: boolean; evidenceHits: number }
128
- stats: [string, string][]
129
- }
 
 
 
 
1
+ // TS mirror of the server's PUBLIC wire contract (src/case_zero/api/public_view.py).
2
+
3
+ export interface Victim {
4
+ name: string
5
+ role: string
6
+ age: number
7
+ sprite: string
8
+ bio: string
9
+ }
10
+
11
+ export interface SuggestedQuestion {
12
+ id: string
13
+ q: string
14
+ }
15
+
16
+ export interface Suspect {
17
+ id: string
18
+ name: string
19
+ role: string
20
+ age: number
21
+ sprite: string
22
+ gender: string
23
+ accentColor?: string
24
+ tag: string
25
+ baselineSuspicion: number
26
+ motive: string
27
+ alibi: string
28
+ quote: string
29
+ greet: string
30
+ suggestedQuestions: SuggestedQuestion[]
31
+ }
32
+
33
+ export interface ThreadMessage {
34
+ from: 'me' | 'them'
35
+ who: string
36
+ t: string
37
+ m: string
38
+ }
39
+
40
+ export interface Evidence {
41
+ id: string
42
+ name: string
43
+ type: string
44
+ icon: string
45
+ time: string
46
+ found: string
47
+ summary: string
48
+ thread?: ThreadMessage[]
49
+ detail?: string
50
+ transcript?: string
51
+ dur?: string
52
+ rows?: string[][]
53
+ }
54
+
55
+ export interface TimelineBeat {
56
+ time: string
57
+ label: string
58
+ locked: boolean
59
+ ev?: string | null
60
+ conflict: boolean
61
+ }
62
+
63
+ export interface FlashbackAccount {
64
+ who: string
65
+ scene: string
66
+ lines: string[]
67
+ flags: number[]
68
+ }
69
+
70
+ export interface Flashback {
71
+ title: string
72
+ a: FlashbackAccount
73
+ b: FlashbackAccount
74
+ }
75
+
76
+ export interface Motive {
77
+ id: string
78
+ text: string
79
+ }
80
+
81
+ export interface StoryBeat {
82
+ scene: string
83
+ kicker: string
84
+ title: string
85
+ text: string
86
+ /** This beat narrates the main action: draw the crime-kind vignette over the scene. */
87
+ incident?: boolean
88
+ }
89
+
90
+ export interface PublicCase {
91
+ id: string
92
+ city: string
93
+ district: string
94
+ title: string
95
+ tagline: string
96
+ weather: string
97
+ victim: Victim
98
+ scene: string
99
+ tod: string
100
+ found: string
101
+ cause: string
102
+ // Case-kind display labels (optional on old stored cases; default to homicide wording).
103
+ kind?: string
104
+ kindLabel?: string
105
+ division?: string
106
+ victimStatus?: string
107
+ todLabel?: string
108
+ verdict?: string
109
+ facts: [string, string][]
110
+ bootLines: string[]
111
+ storyBeats: StoryBeat[]
112
+ suspects: Suspect[]
113
+ evidence: Evidence[]
114
+ timeline: TimelineBeat[]
115
+ flashback: Flashback
116
+ motives: Motive[]
117
+ }
118
+
119
+ // --- interrogation / verdict wire shapes ---
120
+ export interface InterrogateResult {
121
+ reply: string
122
+ suspicionDelta: number
123
+ suspicion: number
124
+ flags: { rattled?: boolean; contradictionExposed?: boolean; cornered?: boolean }
125
+ }
126
+
127
+ export interface VerdictResult {
128
+ correct: boolean
129
+ verdict: { stamp: string; killerId: string; killerName: string; truth: string }
130
+ score: { points: number; max: number; killerCorrect: boolean; motiveCorrect: boolean; evidenceHits: number }
131
+ stats: [string, string][]
132
+ }
web/src/ui/assistant.tsx CHANGED
@@ -1,22 +1,35 @@
1
- // Det. Hale — your partner on the wire. Contextual, spoiler-safe hints from the server.
 
2
  import { useEffect, useState } from 'preact/hooks'
3
 
4
  import { getHint } from '../api'
5
  import { useTypewriter } from '../engine/pixel'
6
- import { useGame } from '../store'
 
7
  import { Portrait } from './components'
8
 
9
  const HIDDEN = new Set(['title', 'verdict', 'share', 'boot'])
10
  const FALLBACK = 'Work the evidence, detective. Find where a statement and a fact disagree, and press there.'
11
 
 
 
 
 
 
 
 
12
  export function Assistant() {
13
  const g = useGame()
14
  const [open, setOpen] = useState(false)
15
  const [hint, setHint] = useState('')
 
16
  const screen = g.state.screen
17
 
18
  useEffect(() => {
19
- const t = () => setOpen((o) => !o)
 
 
 
20
  const c = () => setOpen(false)
21
  window.addEventListener('toggle-hint', t)
22
  window.addEventListener('close-hint', c)
@@ -26,12 +39,35 @@ export function Assistant() {
26
  }
27
  }, [])
28
 
 
 
 
29
  useEffect(() => {
30
  setOpen(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }, [screen])
32
 
33
  useEffect(() => {
34
- if (!open) return
35
  let alive = true
36
  setHint('')
37
  getHint(g.runId, screen)
@@ -44,9 +80,9 @@ export function Assistant() {
44
  return () => {
45
  alive = false
46
  }
47
- }, [open, screen, g.runId])
48
 
49
- const [typed, done] = useTypewriter(open ? hint : '', g.state.tweaks.typeSpeed || 18, open)
50
 
51
  if (HIDDEN.has(screen) || !open) return null
52
  return (
 
1
+ // Det. Hale — your partner on the wire. Contextual, spoiler-safe hints from the server,
2
+ // plus one-time local nudges that introduce each screen to a new player.
3
  import { useEffect, useState } from 'preact/hooks'
4
 
5
  import { getHint } from '../api'
6
  import { useTypewriter } from '../engine/pixel'
7
+ import { type Screen, useGame } from '../store'
8
+ import { hasSeen, markSeen } from '../tips'
9
  import { Portrait } from './components'
10
 
11
  const HIDDEN = new Set(['title', 'verdict', 'share', 'boot'])
12
  const FALLBACK = 'Work the evidence, detective. Find where a statement and a fact disagree, and press there.'
13
 
14
+ // First-visit nudges (client-local, no server call). Shown once per browser.
15
+ const TIPS: Partial<Record<Screen, string>> = {
16
+ board: 'Pick a suspect and lean on them. Work the checklist on the rail — question everyone, show them the exhibits.',
17
+ interro: 'Ask your questions, then hit them with evidence from the tray. Watch that bar climb.',
18
+ accuse: 'Name them, give me the why, and attach what made them crack.',
19
+ }
20
+
21
  export function Assistant() {
22
  const g = useGame()
23
  const [open, setOpen] = useState(false)
24
  const [hint, setHint] = useState('')
25
+ const [localTip, setLocalTip] = useState<string | null>(null)
26
  const screen = g.state.screen
27
 
28
  useEffect(() => {
29
+ const t = () => {
30
+ setLocalTip(null) // HINT button always fetches the live server hint
31
+ setOpen((o) => !o)
32
+ }
33
  const c = () => setOpen(false)
34
  window.addEventListener('toggle-hint', t)
35
  window.addEventListener('close-hint', c)
 
39
  }
40
  }, [])
41
 
42
+ // Screen change: close whatever was open, then fire the one-time nudge for this
43
+ // screen kind. On the board it waits for the case-brief overlay to be dismissed
44
+ // so the two never stack.
45
  useEffect(() => {
46
  setOpen(false)
47
+ setLocalTip(null)
48
+ const tip = TIPS[screen]
49
+ if (!tip || hasSeen(`tip:${screen}`)) return
50
+ let timer: ReturnType<typeof setTimeout> | null = null
51
+ const show = () => {
52
+ markSeen(`tip:${screen}`)
53
+ setLocalTip(tip)
54
+ setOpen(true)
55
+ }
56
+ const briefPending = screen === 'board' && !hasSeen(`brief:${g.case.id}`)
57
+ const onBrief = () => {
58
+ timer = setTimeout(show, 400)
59
+ }
60
+ if (briefPending) window.addEventListener('cz-brief-done', onBrief, { once: true })
61
+ else timer = setTimeout(show, 700)
62
+ return () => {
63
+ if (timer) clearTimeout(timer)
64
+ window.removeEventListener('cz-brief-done', onBrief)
65
+ }
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
  }, [screen])
68
 
69
  useEffect(() => {
70
+ if (!open || localTip) return
71
  let alive = true
72
  setHint('')
73
  getHint(g.runId, screen)
 
80
  return () => {
81
  alive = false
82
  }
83
+ }, [open, localTip, screen, g.runId])
84
 
85
+ const [typed, done] = useTypewriter(open ? localTip ?? hint : '', g.state.tweaks.typeSpeed || 18, open)
86
 
87
  if (HIDDEN.has(screen) || !open) return null
88
  return (
web/src/ui/case-brief.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // First-visit case brief: a one-card overlay on the board that tells a new player
2
+ // everything they need — who's dead/wronged, and what to do about it. Shown once per
3
+ // case per browser; the full dossier stays one tap away.
4
+ import { useState } from 'preact/hooks'
5
+
6
+ import { useGame } from '../store'
7
+ import { hasSeen, markSeen } from '../tips'
8
+ import { Btn, Chip, Panel } from './components'
9
+
10
+ export function CaseBriefOverlay() {
11
+ const g = useGame()
12
+ const c = g.case
13
+ const key = `brief:${c.id}`
14
+ const [show, setShow] = useState(() => !hasSeen(key))
15
+ if (!show) return null
16
+ const done = () => {
17
+ markSeen(key)
18
+ setShow(false)
19
+ window.dispatchEvent(new Event('cz-brief-done'))
20
+ }
21
+ return (
22
+ <div class="brief-veil" onClick={done}>
23
+ <Panel variant="amber" className="brief-card col" style={{ gap: 12, padding: 18 }} onClick={(e: MouseEvent) => e.stopPropagation()}>
24
+ <div class="row between" style={{ alignItems: 'center', gap: 8 }}>
25
+ <span class="t-label" style={{ color: 'var(--amber-2)', letterSpacing: '.2em' }}>CASE {c.id}</span>
26
+ <Chip variant="ox">{c.kindLabel || 'HOMICIDE'}</Chip>
27
+ </div>
28
+ <div class="col" style={{ gap: 4 }}>
29
+ <span class="t-display" style={{ fontSize: 16, color: 'var(--bone-3)' }}>{c.victim.name}</span>
30
+ <span class="t-label">{c.victimStatus || 'DECEASED'} · {c.scene}</span>
31
+ </div>
32
+ <p class="t-body" style={{ fontSize: 14, lineHeight: 1.5, color: 'var(--bone-2)', margin: 0 }}>
33
+ One of them is lying. Question the suspects, present evidence, and make the accusation.
34
+ </p>
35
+ <div class="col" style={{ gap: 8 }}>
36
+ <Btn variant="amber" onClick={done}>▸ Start investigating</Btn>
37
+ <Btn variant="ghost" sm onClick={() => { markSeen(key); setShow(false); g.nav('briefing') }}>Read full case file ▸</Btn>
38
+ </div>
39
+ </Panel>
40
+ </div>
41
+ )
42
+ }
web/src/ui/checklist.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Detective's checklist: the "what do I do next" rail. Four objectives derived
2
+ // entirely from game state; the ACCUSE step pulses when the player is ready.
3
+ import { useState } from 'preact/hooks'
4
+
5
+ import { HOT_SUSPICION, useGame } from '../store'
6
+ import { Btn } from './components'
7
+
8
+ function useObjectives() {
9
+ const g = useGame()
10
+ const c = g.case
11
+ const questioned = c.suspects.filter((s) => (g.state.interrogations[s.id] || []).length > 1).length
12
+ const presented = Object.values(g.state.usedEv).some((l) => l.length > 0)
13
+ const crackedAny = Object.values(g.state.cracked).some(Boolean)
14
+ const hot = Object.values(g.state.suspicion).some((v) => v >= HOT_SUSPICION)
15
+ const ready = (questioned >= c.suspects.length && presented && crackedAny) || hot
16
+ return { g, total: c.suspects.length, questioned, presented, crackedAny, ready }
17
+ }
18
+
19
+ function Step({ done, label }: { done: boolean; label: string }) {
20
+ return (
21
+ <div class={'checklist__step' + (done ? ' checklist__step--done' : '')}>
22
+ <span class="checklist__box">{done ? '■' : '□'}</span>
23
+ <span>{label}</span>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ export function Checklist({ slim = false }: { slim?: boolean }) {
29
+ const { g, total, questioned, presented, crackedAny, ready } = useObjectives()
30
+ const doneCount = (questioned >= total ? 1 : 0) + (presented ? 1 : 0) + (crackedAny ? 1 : 0)
31
+ const [open, setOpen] = useState(questioned === 0) // collapse once the player gets going
32
+ const steps = (
33
+ <>
34
+ <Step done={questioned >= total} label={`QUESTION EVERY SUSPECT ${questioned}/${total}`} />
35
+ <Step done={presented} label="PRESENT EVIDENCE" />
36
+ <Step done={crackedAny} label="CRACK A LIE" />
37
+ <Btn
38
+ sm
39
+ variant={ready ? 'ox' : 'ghost'}
40
+ className={ready ? 'accuse-cta--pulse' : undefined}
41
+ style={{ marginTop: 6 }}
42
+ onClick={() => g.nav('accuse')}
43
+ >
44
+ {ready ? '▸ MAKE THE ACCUSATION' : 'Accuse ▸'}
45
+ </Btn>
46
+ </>
47
+ )
48
+ if (!slim) return <div class="checklist">{steps}</div>
49
+ return (
50
+ <div class="checklist checklist--slim">
51
+ <button class="checklist__toggle" onClick={() => setOpen(!open)}>
52
+ <span class="t-label" style={{ color: 'var(--amber-2)' }}>OBJECTIVES {doneCount}/3</span>
53
+ <span class="t-mono dim">{open ? '▾' : '▸'}</span>
54
+ </button>
55
+ {open && <div class="col" style={{ gap: 4, paddingTop: 6 }}>{steps}</div>}
56
+ {!open && ready && (
57
+ <Btn sm variant="ox" className="accuse-cta--pulse" onClick={() => g.nav('accuse')}>▸ ACCUSE</Btn>
58
+ )}
59
+ </div>
60
+ )
61
+ }
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, 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'
@@ -68,8 +68,8 @@ function useMouth(active: boolean): boolean {
68
  }, [active])
69
  return open
70
  }
71
- export function Portrait({ id, px = 6, blink = true, talking = false, style, className, gender }: SpriteProps & { blink?: boolean; talking?: boolean; gender?: string }) {
72
- const p = portraitFor(id, gender)
73
  const b = useBlink()
74
  const mouth = useMouth(talking)
75
  let frame = p.frames[0]
@@ -77,8 +77,8 @@ export function Portrait({ id, px = 6, blink = true, talking = false, style, cla
77
  else if (blink && b) frame = p.frames[1]
78
  return <PixelCanvas map={frame} pal={p.pal} px={pxScaled(px)} style={style} className={className} />
79
  }
80
- export function Body({ id, px = 6, playing = true, style, className, gender }: SpriteProps & { playing?: boolean; gender?: string }) {
81
- const p = bodyFor(id, gender)
82
  return <PixelCanvas frames={p.frames} pal={p.pal} px={pxScaled(px)} fps={1.1} playing={playing} style={style} className={className} />
83
  }
84
  export function EvIcon({ icon, px = 4, style }: { icon: string; px?: number; style?: JSX.CSSProperties }) {
@@ -92,22 +92,29 @@ interface SceneProps {
92
  anim?: boolean
93
  full?: boolean
94
  cover?: boolean
 
 
 
 
 
95
  style?: JSX.CSSProperties
96
  className?: string
97
  deps?: unknown[]
98
  }
99
- export function Scene({ name, w = 240, h = 135, anim = false, full = false, cover = false, style, className, deps = [] }: SceneProps) {
100
  const st = cover ? { objectFit: 'cover' as const, ...style } : style
 
101
  return (
102
  <SceneCanvas
103
- paint={sceneFor(name)}
104
  w={w}
105
  h={h}
106
  anim={anim}
107
  full={full}
 
108
  style={st}
109
  className={className}
110
- deps={[name, anim, full, ...deps]}
111
  />
112
  )
113
  }
@@ -127,6 +134,7 @@ export function ExhibitArt({ e, w = 96, h = 72, style, className }: ExhibitArtPr
127
  paint={exhibitPainter(e.name, e.summary || '', e.id)}
128
  w={w}
129
  h={h}
 
130
  style={style}
131
  className={className}
132
  deps={[e.id, e.name]}
@@ -215,7 +223,7 @@ export function SuspectCard({ s, onClick, active, mini }: { s: Suspect; onClick?
215
  >
216
  <div class="row" style={{ gap: 10, alignItems: 'flex-start' }}>
217
  <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0 }}>
218
- <Portrait id={s.sprite} px={mini ? 3 : 4} gender={s.gender} />
219
  </div>
220
  <div class="col grow" style={{ gap: 5, minWidth: 0 }}>
221
  <div>
 
3
  import type { ComponentChildren, JSX } from 'preact'
4
  import { useEffect, useState } from 'preact/hooks'
5
 
6
+ import { EV_ICONS, IPAL, bodyFor, exhibitPainter, incidentPainter, portraitFor, sceneFor, sceneForPlace } from '../engine/art'
7
  import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
8
  import { useGame } from '../store'
9
  import type { Evidence, Suspect } from '../types'
 
68
  }, [active])
69
  return open
70
  }
71
+ export function Portrait({ id, px = 6, blink = true, talking = false, style, className, gender, accent }: SpriteProps & { blink?: boolean; talking?: boolean; gender?: string; accent?: string }) {
72
+ const p = portraitFor(id, gender, accent)
73
  const b = useBlink()
74
  const mouth = useMouth(talking)
75
  let frame = p.frames[0]
 
77
  else if (blink && b) frame = p.frames[1]
78
  return <PixelCanvas map={frame} pal={p.pal} px={pxScaled(px)} style={style} className={className} />
79
  }
80
+ export function Body({ id, px = 6, playing = true, style, className, gender, accent }: SpriteProps & { playing?: boolean; gender?: string; accent?: string }) {
81
+ const p = bodyFor(id, gender, accent)
82
  return <PixelCanvas frames={p.frames} pal={p.pal} px={pxScaled(px)} fps={1.1} playing={playing} style={style} className={className} />
83
  }
84
  export function EvIcon({ icon, px = 4, style }: { icon: string; px?: number; style?: JSX.CSSProperties }) {
 
92
  anim?: boolean
93
  full?: boolean
94
  cover?: boolean
95
+ /** Resolve the name as a PLACE (building/venue) for an exterior establishing shot. */
96
+ establishing?: boolean
97
+ /** Crime kind: draw the main-action vignette (the body, the spill, the flames)
98
+ * spotlit over the dimmed scene. */
99
+ incident?: string
100
  style?: JSX.CSSProperties
101
  className?: string
102
  deps?: unknown[]
103
  }
104
+ export function Scene({ name, w = 240, h = 135, anim = false, full = false, cover = false, establishing = false, incident, style, className, deps = [] }: SceneProps) {
105
  const st = cover ? { objectFit: 'cover' as const, ...style } : style
106
+ const base = establishing ? sceneForPlace(name) : sceneFor(name)
107
  return (
108
  <SceneCanvas
109
+ paint={incident ? incidentPainter(incident, base) : base}
110
  w={w}
111
  h={h}
112
  anim={anim}
113
  full={full}
114
+ themed="full"
115
  style={st}
116
  className={className}
117
+ deps={[name, anim, full, establishing, incident, ...deps]}
118
  />
119
  )
120
  }
 
134
  paint={exhibitPainter(e.name, e.summary || '', e.id)}
135
  w={w}
136
  h={h}
137
+ themed="tint"
138
  style={style}
139
  className={className}
140
  deps={[e.id, e.name]}
 
223
  >
224
  <div class="row" style={{ gap: 10, alignItems: 'flex-start' }}>
225
  <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0 }}>
226
+ <Portrait id={s.sprite} px={mini ? 3 : 4} gender={s.gender} accent={s.accentColor} />
227
  </div>
228
  <div class="col grow" style={{ gap: 5, minWidth: 0 }}>
229
  <div>
web/src/ui/juice.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Interrogation feedback moments: the floating suspicion delta and the slam-in
2
+ // banner when a suspect's lie cracks for the first time.
3
+ import { Stamp } from './components'
4
+
5
+ export function DeltaFloater({ delta }: { delta: number }) {
6
+ const big = Math.abs(delta) >= 12
7
+ const cls = 'delta-float' + (delta < 0 ? ' delta-float--drop' : big ? ' delta-float--big' : '')
8
+ return <span class={cls}>{delta > 0 ? `+${delta}` : `${delta}`}</span>
9
+ }
10
+
11
+ export function CrackBanner() {
12
+ return (
13
+ <div class="crack-banner">
14
+ <div class="crack-banner__flash" />
15
+ <Stamp slam style={{ fontSize: 'clamp(16px,4vw,26px)' }}>THE LIE CRACKS</Stamp>
16
+ </div>
17
+ )
18
+ }