Spaces:
Running
Running
Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds
#4
by aliabdelwahab - opened
- COMPLIANCE.md +2 -1
- scripts/check_exhibits.py +54 -0
- scripts/prebake_cases.py +163 -161
- src/case_zero/api/case_adapter.py +18 -6
- src/case_zero/api/dto.py +34 -31
- src/case_zero/api/exhibits.py +186 -0
- src/case_zero/api/public_view.py +192 -187
- src/case_zero/api/routes_case.py +57 -55
- src/case_zero/api/runtime.py +353 -319
- src/case_zero/generator/crime_profiles.py +372 -247
- src/case_zero/schemas/enums.py +4 -0
- tests/test_exhibits.py +103 -0
- tests/test_runtime_pool.py +86 -0
- web/src/api.ts +73 -71
- web/src/app.tsx +172 -161
- web/src/engine/art.ts +550 -51
- web/src/engine/pixel.tsx +20 -7
- web/src/engine/theme.ts +119 -0
- web/src/played.ts +32 -0
- web/src/screens/board.tsx +22 -16
- web/src/screens/cold.tsx +15 -5
- web/src/screens/endgame.tsx +270 -244
- web/src/screens/evidence.tsx +158 -156
- web/src/screens/interro.tsx +32 -3
- web/src/screens/suspects.tsx +2 -0
- web/src/store.tsx +19 -0
- web/src/styles/layout.css +57 -0
- web/src/tips.ts +34 -0
- web/src/types.ts +132 -129
- web/src/ui/assistant.tsx +42 -6
- web/src/ui/case-brief.tsx +42 -0
- web/src/ui/checklist.tsx +61 -0
- web/src/ui/components.tsx +17 -9
- web/src/ui/juice.tsx +18 -0
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,
|
|
|
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
CrimeKind.
|
| 39 |
-
CrimeKind.
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
"john
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
if
|
| 95 |
-
return False, "suspect
|
| 96 |
-
if any(
|
| 97 |
-
return False,
|
| 98 |
-
if any(
|
| 99 |
-
return False, f"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
if not any((s.visual.gender or "").lower().startswith("
|
| 109 |
-
return False, "no
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
id=clue.clue_id,
|
| 168 |
name=clue.name.upper(),
|
| 169 |
type=clue.discovery_method.value.upper(),
|
| 170 |
-
icon=
|
| 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 |
-
|
| 243 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
"""
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
),
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
return None
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
self
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
self.
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
)
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
"
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Crime profiles: one frozen table that tells every layer how to talk about a case.
|
| 2 |
+
|
| 3 |
+
The solver and engine are crime-agnostic - they only check structure (one culprit, a
|
| 4 |
+
false alibi contradicted by discoverable evidence, innocents cleared). A profile maps a
|
| 5 |
+
``CrimeKind`` to the words each layer needs: how the generator prompts the model, how
|
| 6 |
+
the suspect brief states the culprit's deed, and how the dossier/verdict label the case.
|
| 7 |
+
|
| 8 |
+
Field semantics are REUSED rather than renamed, so no schema churn:
|
| 9 |
+
- ``victim`` = the wronged party (the deceased / the one robbed / the missing person);
|
| 10 |
+
- ``weapon`` = the instrument or object of the crime (knife / stolen jewel / forged deed);
|
| 11 |
+
- ``time_of_death`` = the moment of the incident;
|
| 12 |
+
- ``cause_of_death`` = one line describing what happened to the victim.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from ..schemas.enums import CrimeKind
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class CrimeProfile:
|
| 24 |
+
kind: CrimeKind
|
| 25 |
+
# --- generator prompts ---
|
| 26 |
+
author_noun: str # "murder mystery"
|
| 27 |
+
incident_noun: str # "murder" - used in "during the {incident_noun}"
|
| 28 |
+
incident_line: str # world prompt: what happened that evening
|
| 29 |
+
victim_ask: str # world prompt: who the victim is + what cause_of_death holds
|
| 30 |
+
instrument_ask: str # world prompt: what weapon_name/kind hold
|
| 31 |
+
deed_line: str # mystery prompt: "{culprit} ... {victim} ... {room} ... {instrument}"
|
| 32 |
+
trace_ask: str # mystery prompt: what the two breaker clues prove
|
| 33 |
+
# --- engine ---
|
| 34 |
+
brief_deed: str # suspect brief, second person: "You killed {victim}."
|
| 35 |
+
confession_verbs: str # extra regex alternatives for the confession scrubber
|
| 36 |
+
# --- display ---
|
| 37 |
+
perp_noun: str # "killer"
|
| 38 |
+
division: str # "HOMICIDE DIVISION"
|
| 39 |
+
victim_status: str # dossier stamp: "DECEASED"
|
| 40 |
+
verdict: str # KEY FACTS verdict line: "Homicide"
|
| 41 |
+
kind_label: str # title screen: "A PROCEDURAL {kind_label}"
|
| 42 |
+
tod_label: str # "T.O.D." vs "LAST SEEN" vs "INCIDENT"
|
| 43 |
+
found_verb: str # "Found in" vs "Last seen in"
|
| 44 |
+
timeline_line: str # "{name} is killed. Time of death."
|
| 45 |
+
boot_line: str # boot screen: "A death no one saw coming."
|
| 46 |
+
fallback_titles: tuple[str, ...]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
_P = CrimeProfile
|
| 50 |
+
|
| 51 |
+
PROFILES: dict[CrimeKind, CrimeProfile] = {
|
| 52 |
+
CrimeKind.HOMICIDE: _P(
|
| 53 |
+
kind=CrimeKind.HOMICIDE,
|
| 54 |
+
author_noun="murder mystery",
|
| 55 |
+
incident_noun="murder",
|
| 56 |
+
incident_line="The murder happened between {start} and {end} one evening.",
|
| 57 |
+
victim_ask="the victim (name, role, cause of death) and which room index (0-based) "
|
| 58 |
+
"they were found in",
|
| 59 |
+
instrument_ask="the murder weapon (name, kind)",
|
| 60 |
+
deed_line="They murdered {victim} in the {room} with the {instrument} between "
|
| 61 |
+
"{start} and {end}, then lied that they never left the {claimed}.",
|
| 62 |
+
trace_ask="quietly place {culprit} in the {room} during the murder",
|
| 63 |
+
brief_deed="You killed {victim}.",
|
| 64 |
+
confession_verbs=r"killed|murdered|stabbed|poisoned|strangled|shot|drowned|smothered",
|
| 65 |
+
perp_noun="killer",
|
| 66 |
+
division="HOMICIDE DIVISION",
|
| 67 |
+
victim_status="DECEASED",
|
| 68 |
+
verdict="Homicide",
|
| 69 |
+
kind_label="HOMICIDE",
|
| 70 |
+
tod_label="T.O.D.",
|
| 71 |
+
found_verb="Found in",
|
| 72 |
+
timeline_line="{name} is killed. Time of death.",
|
| 73 |
+
boot_line="A death no one saw coming.",
|
| 74 |
+
fallback_titles=(
|
| 75 |
+
"A Death in the {room}", "The {room} Affair", "Murder at {setting}",
|
| 76 |
+
"The {last} File", "Blood in the {room}", "Last Call at {setting}",
|
| 77 |
+
"The {setting} Killing", "The {room} Verdict",
|
| 78 |
+
),
|
| 79 |
+
),
|
| 80 |
+
CrimeKind.THEFT: _P(
|
| 81 |
+
kind=CrimeKind.THEFT,
|
| 82 |
+
author_noun="heist mystery - a prized object stolen from under everyone's nose",
|
| 83 |
+
incident_noun="theft",
|
| 84 |
+
incident_line="The theft happened between {start} and {end} one evening.",
|
| 85 |
+
victim_ask="the victim - the OWNER of what was stolen (name, role, and in "
|
| 86 |
+
"cause_of_death one line on how they discovered the loss) and which room "
|
| 87 |
+
"index (0-based) the theft happened in",
|
| 88 |
+
instrument_ask="the STOLEN OBJECT itself (name, kind) - one specific prized thing: "
|
| 89 |
+
"a jewel, a painting, a rare manuscript, a strongbox",
|
| 90 |
+
deed_line="They stole the {instrument} from {victim}'s {room} between {start} and "
|
| 91 |
+
"{end}, then lied that they never left the {claimed}.",
|
| 92 |
+
trace_ask="quietly place {culprit} in the {room} during the theft",
|
| 93 |
+
brief_deed="You stole the {instrument} from {victim}.",
|
| 94 |
+
confession_verbs=r"stole|robbed|took\s+the|lifted\s+the|pocketed",
|
| 95 |
+
perp_noun="thief",
|
| 96 |
+
division="ROBBERY DIVISION",
|
| 97 |
+
victim_status="ROBBED",
|
| 98 |
+
verdict="Grand Larceny",
|
| 99 |
+
kind_label="HEIST",
|
| 100 |
+
tod_label="TAKEN AT",
|
| 101 |
+
found_verb="Taken from",
|
| 102 |
+
timeline_line="The {instrument} vanishes from the {room}.",
|
| 103 |
+
boot_line="A theft no one saw happen.",
|
| 104 |
+
fallback_titles=(
|
| 105 |
+
"The Empty Case in the {room}", "The {setting} Job", "The {last} Collection",
|
| 106 |
+
"What Left the {room}", "The {setting} Take", "A Hole in the {room}",
|
| 107 |
+
),
|
| 108 |
+
),
|
| 109 |
+
CrimeKind.FRAUD: _P(
|
| 110 |
+
kind=CrimeKind.FRAUD,
|
| 111 |
+
author_noun="financial-fraud mystery - a swindle unravelling among people who "
|
| 112 |
+
"trusted each other",
|
| 113 |
+
incident_noun="fraud",
|
| 114 |
+
incident_line="The decisive forged transaction was pushed through between {start} "
|
| 115 |
+
"and {end} one evening.",
|
| 116 |
+
victim_ask="the victim - the person DEFRAUDED (name, role, and in cause_of_death "
|
| 117 |
+
"one line on what they lost and how they found out) and which room index "
|
| 118 |
+
"(0-based) the scheme was run from",
|
| 119 |
+
instrument_ask="the INSTRUMENT of the fraud (name, kind) - a forged deed, a doctored "
|
| 120 |
+
"ledger, a fake certificate, a rigged contract",
|
| 121 |
+
deed_line="They defrauded {victim} using the {instrument}, working from the {room} "
|
| 122 |
+
"between {start} and {end}, then lied that they never left the {claimed}.",
|
| 123 |
+
trace_ask="quietly place {culprit} in the {room} when the {instrument} was made",
|
| 124 |
+
brief_deed="You defrauded {victim} using the {instrument}.",
|
| 125 |
+
confession_verbs=r"forged|defrauded|swindled|scammed|embezzled|doctored\s+the",
|
| 126 |
+
perp_noun="fraudster",
|
| 127 |
+
division="FRAUD DIVISION",
|
| 128 |
+
victim_status="DEFRAUDED",
|
| 129 |
+
verdict="Fraud",
|
| 130 |
+
kind_label="SWINDLE",
|
| 131 |
+
tod_label="SIGNED AT",
|
| 132 |
+
found_verb="Uncovered in",
|
| 133 |
+
timeline_line="The {instrument} is signed in the {room}.",
|
| 134 |
+
boot_line="A fortune signed away in one evening.",
|
| 135 |
+
fallback_titles=(
|
| 136 |
+
"The {room} Signature", "The {setting} Accounts", "The {last} Trust",
|
| 137 |
+
"Ink in the {room}", "The Paper Fortune", "The {setting} Ledger",
|
| 138 |
+
),
|
| 139 |
+
),
|
| 140 |
+
CrimeKind.BLACKMAIL: _P(
|
| 141 |
+
kind=CrimeKind.BLACKMAIL,
|
| 142 |
+
author_noun="blackmail mystery - someone is being bled, and the extortionist sat "
|
| 143 |
+
"at the same table",
|
| 144 |
+
incident_noun="blackmail drop",
|
| 145 |
+
incident_line="The latest blackmail demand was planted between {start} and {end} "
|
| 146 |
+
"one evening.",
|
| 147 |
+
victim_ask="the victim - the person BLACKMAILED (name, role, and in cause_of_death "
|
| 148 |
+
"one line on the demand and what it is costing them) and which room index "
|
| 149 |
+
"(0-based) the demand was planted in",
|
| 150 |
+
instrument_ask="the LEVERAGE (name, kind) - compromising photographs, stolen letters, "
|
| 151 |
+
"an incriminating ledger page",
|
| 152 |
+
deed_line="They are blackmailing {victim} with the {instrument}, and planted the "
|
| 153 |
+
"demand in the {room} between {start} and {end}, then lied that they "
|
| 154 |
+
"never left the {claimed}.",
|
| 155 |
+
trace_ask="quietly place {culprit} in the {room} when the demand was planted",
|
| 156 |
+
brief_deed="You are blackmailing {victim} with the {instrument}.",
|
| 157 |
+
confession_verbs=r"blackmailed|blackmailing|extorted|bled\s+(him|her|them)",
|
| 158 |
+
perp_noun="blackmailer",
|
| 159 |
+
division="EXTORTION UNIT",
|
| 160 |
+
victim_status="EXTORTED",
|
| 161 |
+
verdict="Extortion",
|
| 162 |
+
kind_label="EXTORTION",
|
| 163 |
+
tod_label="PLANTED AT",
|
| 164 |
+
found_verb="Planted in",
|
| 165 |
+
timeline_line="The demand is planted in the {room}.",
|
| 166 |
+
boot_line="An envelope no one will admit to leaving.",
|
| 167 |
+
fallback_titles=(
|
| 168 |
+
"The {room} Envelope", "The Price of {last}", "The {setting} Demand",
|
| 169 |
+
"Postage Due at {setting}", "The {room} Letters", "What {last} Pays For",
|
| 170 |
+
),
|
| 171 |
+
),
|
| 172 |
+
CrimeKind.ARSON: _P(
|
| 173 |
+
kind=CrimeKind.ARSON,
|
| 174 |
+
author_noun="arson mystery - a fire set by a familiar hand",
|
| 175 |
+
incident_noun="fire",
|
| 176 |
+
incident_line="The fire was set between {start} and {end} one evening.",
|
| 177 |
+
victim_ask="the victim - the person whose property BURNED (name, role, and in "
|
| 178 |
+
"cause_of_death one line on what the fire took from them) and which room "
|
| 179 |
+
"index (0-based) the fire started in",
|
| 180 |
+
instrument_ask="the MEANS of the fire (name, kind) - a kerosene tin, an oil lamp, "
|
| 181 |
+
"a candle and curtains",
|
| 182 |
+
deed_line="They set the fire in {victim}'s {room} using the {instrument} between "
|
| 183 |
+
"{start} and {end}, then lied that they never left the {claimed}.",
|
| 184 |
+
trace_ask="quietly place {culprit} in the {room} just before the fire started",
|
| 185 |
+
brief_deed="You set the fire in {victim}'s {room} using the {instrument}.",
|
| 186 |
+
confession_verbs=r"torched|set\s+the\s+fire|burned\s+it|lit\s+the|started\s+the\s+fire",
|
| 187 |
+
perp_noun="arsonist",
|
| 188 |
+
division="ARSON UNIT",
|
| 189 |
+
victim_status="BURNED OUT",
|
| 190 |
+
verdict="Arson",
|
| 191 |
+
kind_label="ARSON",
|
| 192 |
+
tod_label="IGNITED AT",
|
| 193 |
+
found_verb="Started in",
|
| 194 |
+
timeline_line="The fire starts in the {room}.",
|
| 195 |
+
boot_line="A fire that started exactly where it hurt most.",
|
| 196 |
+
fallback_titles=(
|
| 197 |
+
"The {room} Burn", "Ashes at {setting}", "The {last} Fire",
|
| 198 |
+
"What the {room} Kept", "Kindling at {setting}", "The {setting} Blaze",
|
| 199 |
+
),
|
| 200 |
+
),
|
| 201 |
+
CrimeKind.MISSING: _P(
|
| 202 |
+
kind=CrimeKind.MISSING,
|
| 203 |
+
author_noun="missing-person mystery - someone walked into a room and never walked out",
|
| 204 |
+
incident_noun="disappearance",
|
| 205 |
+
incident_line="The disappearance happened between {start} and {end} one evening.",
|
| 206 |
+
victim_ask="the victim - the person who VANISHED (name, role, and in cause_of_death "
|
| 207 |
+
"one line on how their absence was noticed) and which room index (0-based) "
|
| 208 |
+
"they were last seen in",
|
| 209 |
+
instrument_ask="the LURE (name, kind) - the thing used to draw them away: a forged "
|
| 210 |
+
"note, a false telegram, a promised meeting",
|
| 211 |
+
deed_line="They are behind {victim}'s disappearance - they lured them from the {room} "
|
| 212 |
+
"using the {instrument} between {start} and {end}, then lied that they "
|
| 213 |
+
"never left the {claimed}.",
|
| 214 |
+
trace_ask="quietly place {culprit} in the {room} when {victim} was last seen",
|
| 215 |
+
brief_deed="You are behind {victim}'s disappearance; you lured them away with the "
|
| 216 |
+
"{instrument}. {victim} is alive, but you must hide what you did.",
|
| 217 |
+
confession_verbs=r"abducted|kidnapped|lured\s+(him|her|them)|took\s+(him|her|them)",
|
| 218 |
+
perp_noun="abductor",
|
| 219 |
+
division="MISSING PERSONS",
|
| 220 |
+
victim_status="MISSING",
|
| 221 |
+
verdict="Missing Person",
|
| 222 |
+
kind_label="DISAPPEARANCE",
|
| 223 |
+
tod_label="LAST SEEN",
|
| 224 |
+
found_verb="Last seen in",
|
| 225 |
+
timeline_line="{name} is seen for the last time.",
|
| 226 |
+
boot_line="A chair still warm, and nobody in it.",
|
| 227 |
+
fallback_titles=(
|
| 228 |
+
"The Empty Chair in the {room}", "Where {last} Went", "The {setting} Absence",
|
| 229 |
+
"Last Seen in the {room}", "The {last} Vanishing", "Gone from {setting}",
|
| 230 |
+
),
|
| 231 |
+
),
|
| 232 |
+
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 |
-
|
| 33 |
-
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 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 {
|
| 7 |
-
import
|
| 8 |
-
import {
|
| 9 |
-
import {
|
| 10 |
-
import {
|
| 11 |
-
import {
|
| 12 |
-
import {
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
const
|
| 20 |
-
const
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
<
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
const
|
| 85 |
-
const [
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
r
|
| 92 |
-
r.setAttribute('data-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
const
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
| 245 |
]
|
| 246 |
-
const
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
| 251 |
]
|
| 252 |
-
const
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
| 256 |
]
|
| 257 |
-
const
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
if (PORTRAITS[id]) return PORTRAITS[id]
|
| 272 |
-
const
|
| 273 |
-
|
|
|
|
|
|
|
| 274 |
}
|
| 275 |
-
export function bodyFor(id: string, gender?: string): Sprite {
|
| 276 |
if (BODIES[id]) return BODIES[id]
|
| 277 |
-
const
|
| 278 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 562 |
const sy = h - 30
|
| 563 |
const sofX = Math.floor(w * 0.34)
|
| 564 |
const sofW = Math.floor(w * 0.4)
|
| 565 |
-
|
|
|
|
| 566 |
ctx.fillStyle = C.slateL; ctx.fillRect(sofX, sy, sofW, 4)
|
| 567 |
-
ctx.fillStyle =
|
| 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 |
-
|
| 616 |
-
ctx.fillStyle =
|
|
|
|
| 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 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
| 11 |
import { ThreadLayer, useThreads } from './board-threads'
|
| 12 |
|
| 13 |
-
const CARD_H =
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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=
|
| 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=
|
| 379 |
-
<div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 3 }}>SCENE —
|
| 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 |
-
<
|
| 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
|
| 448 |
-
<
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
</
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('
|
| 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('
|
| 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=
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
const
|
| 29 |
-
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
<
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
<
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
<
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
const
|
| 138 |
-
const
|
| 139 |
-
const
|
| 140 |
-
const
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
<
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
<
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
const
|
| 116 |
-
const
|
| 117 |
-
const
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
<
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
<
|
| 144 |
-
<
|
| 145 |
-
<
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
<Btn variant=
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 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 }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = () =>
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 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 |
+
}
|