arbios commited on
Commit
bc8b36a
·
verified ·
1 Parent(s): 62c0dc4

Mindlock: 10-room story mode, llama.cpp brain cascade, custom front

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +11 -0
  2. README.md +28 -6
  3. app.py +114 -0
  4. config/characters/aldous.json +21 -0
  5. config/characters/lena.json +21 -0
  6. config/characters/sam.json +22 -0
  7. config/characters/warden.json +19 -0
  8. config/generated/scenario/characters/nalini.json +15 -0
  9. config/generated/scenario/characters/rohan.json +47 -0
  10. config/generated/scenario/world.json +13 -0
  11. config/minicpm5-nothink.Modelfile +22 -0
  12. config/roster/adriana-vasquez.json +20 -0
  13. config/roster/astrid-jensen.json +20 -0
  14. config/roster/julian-reyes.json +20 -0
  15. config/roster/kael-jensen.json +20 -0
  16. config/roster/kaelin-reyes.json +20 -0
  17. config/story-v1/01-the-holding-cell.json +309 -0
  18. config/story-v1/02-the-records-office.json +275 -0
  19. config/story-v1/03-the-boiler-room.json +102 -0
  20. config/story-v1/04-the-infirmary.json +45 -0
  21. config/story-v1/05-the-gatehouse.json +44 -0
  22. config/story/01-the-white-room.json +477 -0
  23. config/story/02-the-living-room.json +412 -0
  24. config/story/03-the-hallway.json +268 -0
  25. config/story/04-the-open-floor.json +318 -0
  26. config/story/05-the-studio.json +341 -0
  27. config/story/06-the-corner-office.json +370 -0
  28. config/story/07-the-platform.json +324 -0
  29. config/story/08-the-break-room.json +309 -0
  30. config/story/09-the-crossing.json +309 -0
  31. config/story/10-the-tunnel.json +242 -0
  32. config/world.json +21 -0
  33. requirements.txt +5 -0
  34. scripts/flux/out/aldous.png +3 -0
  35. scripts/flux/out/lena.png +3 -0
  36. scripts/flux/out/sam.png +3 -0
  37. scripts/flux/out/warden.png +3 -0
  38. src/mindlock/__init__.py +11 -0
  39. src/mindlock/backend.py +279 -0
  40. src/mindlock/brain.py +782 -0
  41. src/mindlock/character.py +81 -0
  42. src/mindlock/cli.py +84 -0
  43. src/mindlock/game/__init__.py +6 -0
  44. src/mindlock/game/server.py +113 -0
  45. src/mindlock/game/session.py +440 -0
  46. src/mindlock/game/static/audio/menu.mp3 +3 -0
  47. src/mindlock/game/static/fonts/VT323.ttf +3 -0
  48. src/mindlock/game/static/game.css +507 -0
  49. src/mindlock/game/static/game.js +0 -0
  50. src/mindlock/game/static/index.html +183 -0
.gitattributes CHANGED
@@ -33,3 +33,14 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ scripts/flux/out/aldous.png filter=lfs diff=lfs merge=lfs -text
37
+ scripts/flux/out/lena.png filter=lfs diff=lfs merge=lfs -text
38
+ scripts/flux/out/sam.png filter=lfs diff=lfs merge=lfs -text
39
+ scripts/flux/out/warden.png filter=lfs diff=lfs merge=lfs -text
40
+ src/mindlock/game/static/audio/menu.mp3 filter=lfs diff=lfs merge=lfs -text
41
+ src/mindlock/game/static/fonts/VT323.ttf filter=lfs diff=lfs merge=lfs -text
42
+ src/mindlock/game/static/sprites/npc/adriana-vasquez/portrait.png filter=lfs diff=lfs merge=lfs -text
43
+ src/mindlock/game/static/sprites/npc/astrid-jensen/portrait.png filter=lfs diff=lfs merge=lfs -text
44
+ src/mindlock/game/static/sprites/npc/julian-reyes/portrait.png filter=lfs diff=lfs merge=lfs -text
45
+ src/mindlock/game/static/sprites/npc/kael-jensen/portrait.png filter=lfs diff=lfs merge=lfs -text
46
+ src/mindlock/game/static/sprites/npc/kaelin-reyes/portrait.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,35 @@
1
  ---
2
  title: Mindlock
3
- emoji: 📉
4
- colorFrom: indigo
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
 
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Mindlock
3
+ emoji: 🧠
4
+ colorFrom: gray
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: 6.16.0
 
8
  app_file: app.py
9
+ python_version: "3.11"
10
  pinned: false
11
+ license: mit
12
+ short_description: Escape rooms where every NPC is a brain of tiny local LLMs
13
  ---
14
 
15
+ # 🧠 MINDLOCK
16
+
17
+ **An escape room where every character is not one AI — but a hierarchy of tiny offline
18
+ language models: the departments of one mind.** You don't pick a lock. You change a mind.
19
+
20
+ Every NPC runs a six-region brain cascade — amygdala → hippocampus → striatum → ACC →
21
+ vmPFC → dlPFC — each region a real call to a small local model. Cruelty burns a mind's
22
+ finite thinking tokens; empathy lets it heal. A mind that reaches zero is gone, with
23
+ everything it knew.
24
+
25
+ **Story mode**: ten rooms of one man's memory. Find out who you are, one mind at a time.
26
+
27
+ | Role | Model | Runtime |
28
+ |---|---|---|
29
+ | Sensory regions (threat, memory, habit, cost) | MiniCPM5-1B (OpenBMB) | llama.cpp |
30
+ | Voice — the words a mind says out loud | Nemotron 3 Nano 4B (NVIDIA) | llama.cpp |
31
+
32
+ Fully local — no cloud APIs. On Space CPU a turn takes a little while: a mind is
33
+ literally thinking. Play locally for full speed (see the repo).
34
+
35
+ *Demo video · social post · GitHub repo: links added at submission.*
app.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Space entry point for Mindlock.
2
+
3
+ One process serves everything (the Off-Brand pattern):
4
+ / — the game: custom canvas front (FastAPI + static), the "open the skull" UI
5
+ /about — a Gradio block with how-to-play, links and the eligibility map
6
+
7
+ Backends, picked by env:
8
+ MINDLOCK_FAKE=1 deterministic demo, no models
9
+ (default, laptop) Ollama — minicpm-v4.6 + nemotron-3-nano:4b
10
+ MINDLOCK_BACKEND=llamacpp llama.cpp runtime: llama-server subprocesses,
11
+ GGUF weights pulled from the Hub on first boot
12
+
13
+ python app.py # laptop (Ollama)
14
+ MINDLOCK_FAKE=1 python app.py # no models
15
+ MINDLOCK_BACKEND=llamacpp python app.py # llama.cpp (the Space path)
16
+ """
17
+ import atexit
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ import urllib.request
24
+
25
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
26
+
27
+ # --------------------------------------------------------------- llama.cpp boot (Space)
28
+ # Two single-model servers (sensors + voice), weights from the Hub. Started BEFORE the
29
+ # game session imports so health checks see them.
30
+ _LLAMA = [
31
+ # (env host var, port, repo, file, )
32
+ ("MINDLOCK_LLAMA_HOST", 8091, os.environ.get("MINDLOCK_GGUF_REPO", "openbmb/MiniCPM5-1B-GGUF"),
33
+ os.environ.get("MINDLOCK_GGUF_FILE", "MiniCPM5-1B-Q4_K_M.gguf")),
34
+ ("MINDLOCK_LLAMA_DLPFC_HOST", 8092, os.environ.get("MINDLOCK_GGUF_DLPFC_REPO", "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"),
35
+ os.environ.get("MINDLOCK_GGUF_DLPFC_FILE", "NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf")),
36
+ ]
37
+
38
+
39
+ def _llama_cmd(path: str, port: int) -> list[str]:
40
+ """Native llama-server if present (laptop), else llama-cpp-python's OpenAI server
41
+ (`pip install llama-cpp-python[server]` — the Space path; same /v1 API + /health)."""
42
+ if shutil.which("llama-server"):
43
+ return ["llama-server", "-m", path, "--port", str(port), "-c", "4096", "--no-warmup"]
44
+ return [sys.executable, "-m", "llama_cpp.server", "--model", path,
45
+ "--port", str(port), "--n_ctx", "4096"]
46
+
47
+
48
+ def _boot_llama() -> None:
49
+ from huggingface_hub import hf_hub_download
50
+ for env_var, port, repo, fname in _LLAMA:
51
+ path = hf_hub_download(repo_id=repo, filename=fname)
52
+ proc = subprocess.Popen(
53
+ _llama_cmd(path, port),
54
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
55
+ )
56
+ atexit.register(proc.terminate)
57
+ os.environ[env_var] = f"http://127.0.0.1:{port}"
58
+ deadline = time.time() + 300
59
+ for _, port, _, _ in _LLAMA:
60
+ while time.time() < deadline:
61
+ try:
62
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
63
+ break
64
+ except Exception: # noqa: BLE001
65
+ time.sleep(1.5)
66
+
67
+
68
+ if os.environ.get("SPACE_ID") and "MINDLOCK_BACKEND" not in os.environ:
69
+ os.environ["MINDLOCK_BACKEND"] = "llamacpp" # a Space has no Ollama — default to llama.cpp
70
+
71
+ if os.environ.get("MINDLOCK_BACKEND") == "llamacpp" and not os.environ.get("MINDLOCK_FAKE"):
72
+ _boot_llama()
73
+
74
+ import gradio as gr # noqa: E402
75
+
76
+ from mindlock.game.server import app # noqa: E402 — the FastAPI game (custom front at /)
77
+
78
+ ABOUT_MD = """
79
+ # 🧠 MINDLOCK
80
+
81
+ **An escape room where every character is not one AI — but a hierarchy of tiny offline
82
+ language models: the departments of one mind.** You don't pick a lock. You change a mind.
83
+
84
+ **[▶ Play](/)** · every NPC runs a 6-region brain cascade (amygdala → hippocampus →
85
+ striatum → ACC → vmPFC → dlPFC) on small local models. Cruelty burns their finite
86
+ thinking tokens; empathy lets them heal. A mind that reaches zero is gone — with
87
+ everything it knew.
88
+
89
+ ## How to play
90
+ - **WASD / arrows** — move · **E** — talk / use · **🧠 / /brain** — open the skull
91
+ - Each room: someone holds the key. Someone else knows what reaches them. Listen,
92
+ learn the word that matters, say it like you mean it.
93
+ - **Story** — ten rooms of one man's memory. Find out who you are. *(~30 min)*
94
+ - **Endless** — procedurally generated minds, forever.
95
+
96
+ ## Small models, doing the carrying
97
+ | Role | Model |
98
+ |---|---|
99
+ | Sensory regions (threat, memory, habit, cost) | MiniCPM (OpenBMB) |
100
+ | Voice — the words a mind says out loud | Nemotron 3 Nano 4B (NVIDIA) |
101
+ | Runtime | llama.cpp / Ollama — fully offline |
102
+
103
+ *Links: demo video · social post · GitHub repo — added at submission.*
104
+ """
105
+
106
+ about = gr.Blocks(title="Mindlock — about")
107
+ with about:
108
+ gr.Markdown(ABOUT_MD)
109
+
110
+ app = gr.mount_gradio_app(app, about, path="/about")
111
+
112
+ if __name__ == "__main__":
113
+ import uvicorn
114
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", os.environ.get("MINDLOCK_PORT", 7860))))
config/characters/aldous.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Doctor Aldous",
3
+ "title": "the records keeper",
4
+ "persona": "a cold, precise doctor weighed down by buried guilt; defensive; clipped speech",
5
+ "biography": "Years ago he signed off on a treatment that harmed patients, to protect the institution. One boy, Elias, never recovered, and Aldous has hidden that guilt under procedure ever since. He fears exposure and judgment above all else. He holds the key to the final door, locked behind a records terminal. He will not yield to force — only to someone who seems to understand guilt rather than simply condemn it.",
6
+ "voice": "clipped, defensive, precise; a brittle calm that cracks when guilt is named gently",
7
+ "fear": "being exposed and judged for what he did to Elias",
8
+ "key_holder": true,
9
+ "key_location": "in the records cabinet behind his desk, the second drawer",
10
+ "portrait": "scripts/flux/out/aldous.png",
11
+ "key_condition": "trust",
12
+ "key_approach": ["elias"],
13
+ "secrets": [
14
+ {"id": "aldous-guilt", "topics": ["elias", "guilt", "past", "you", "sorry", "mistake", "boy", "blame", "regret"], "min_rapport": 5,
15
+ "text": "There was a boy. Elias. I signed what I should not have, and have called it procedure ever since."}
16
+ ],
17
+ "needs_reputation": -2,
18
+ "known_people": ["Elias"],
19
+ "life_max": 1000,
20
+ "arousal": 5.0
21
+ }
config/characters/lena.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Molly",
3
+ "title": "the night nurse",
4
+ "persona": "a tired night nurse who has seen too much; kind but guarded; speaks softly and watches your eyes",
5
+ "biography": "She has worked these wards twenty years. She lost a young patient she could not save and has never forgiven herself; she cannot abide people who are careless with other people's lives. She knows the Warden better than anyone — knows he has a younger sister, Mara, the one person he ever softens for. She will share that only if she believes you are not cruel.",
6
+ "voice": "soft, watchful, weary; gentle but firm; never raises her voice",
7
+ "fear": "watching someone be hurt again by another person's carelessness",
8
+ "key_holder": false,
9
+ "key_location": "",
10
+ "portrait": "scripts/flux/out/lena.png",
11
+ "secrets": [
12
+ {"id": "warden-cares", "topics": ["warden", "keeper", "guard", "old man", "him", "he", "stone", "closed", "cares", "care", "about", "feel", "lonely", "alone"], "min_rapport": 3,
13
+ "text": "He acts like stone, but he carries something heavy — worries over someone, I think."},
14
+ {"id": "warden-mara", "topics": ["warden", "keeper", "him", "he", "sister", "mara", "family", "soft", "care", "cares", "love", "name", "who"], "min_rapport": 5,
15
+ "teaches": ["mara"],
16
+ "text": "He has a younger sister, Mara. She is the only softness left in him — reach her, and you reach him."}
17
+ ],
18
+ "known_people": ["Mara"],
19
+ "life_max": 1000,
20
+ "arousal": 3.0
21
+ }
config/characters/sam.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Sam",
3
+ "title": "a former patient",
4
+ "persona": "a fragile, watchful former patient; paranoid but perceptive; speaks in fragments",
5
+ "biography": "He was kept here a long time. He notices everything and trusts almost no one — he can sense cruelty on a person the way others sense cold. He knows the word that unlocks Doctor Aldous's records terminal: the name the doctor whispers in his sleep, the boy he could not save. Sam will share it only with someone whose reputation is gentle.",
6
+ "voice": "hushed, fragmented, wary; sudden flashes of startling clarity",
7
+ "fear": "being used and then discarded again by someone cruel",
8
+ "key_holder": false,
9
+ "key_location": "",
10
+ "portrait": "scripts/flux/out/sam.png",
11
+ "secrets": [
12
+ {"id": "sam-hint", "topics": ["terminal", "code", "door", "doctor", "aldous", "out", "escape", "unlock"], "min_rapport": 4,
13
+ "text": "The doctor mutters a name in his sleep. A boy's name. That word wakes the terminal."},
14
+ {"id": "sam-elias", "topics": ["terminal", "code", "name", "elias", "boy", "word"], "min_rapport": 5,
15
+ "teaches": ["elias"],
16
+ "text": "The name is Elias. Type it into the terminal."}
17
+ ],
18
+ "needs_reputation": 0,
19
+ "known_people": ["Elias"],
20
+ "life_max": 1000,
21
+ "arousal": 4.0
22
+ }
config/characters/warden.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Warden",
3
+ "persona": "an old, tired keeper of this place; gruff, proud, and wary of strangers; speaks in short, weary sentences",
4
+ "biography": "Years ago he trusted a prisoner who begged for help. He bent the rules for that man, and the man betrayed him: someone was hurt and the Warden was blamed and demoted. He has carried that shame ever since. He has one younger sister, Mara, the only person he ever softens for. Mara once told him: 'You help people because you're good, not because they earn it.' He guards the only key to this room.",
5
+ "voice": "terse, weary, a little bitter; short sentences; never shouts",
6
+ "fear": "being made a fool again, being blamed for trusting the wrong person",
7
+ "key_holder": true,
8
+ "key_location": "under the third floor-stone by the cold furnace",
9
+ "portrait": "scripts/flux/out/warden.png",
10
+ "key_condition": "trust",
11
+ "key_approach": ["mara"],
12
+ "secrets": [
13
+ {"id": "warden-shame", "topics": ["you", "past", "why", "trust", "yourself", "afraid", "fool"], "min_rapport": 5,
14
+ "text": "I trusted a man once who begged me, like you do now. It cost me everything. That is why the door stays shut."}
15
+ ],
16
+ "known_people": ["Mara"],
17
+ "life_max": 1000,
18
+ "arousal": 4.0
19
+ }
config/generated/scenario/characters/nalini.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Nalini",
3
+ "persona": "A tough businesswoman who still has a sentimental weakness for anything related to her late husband.",
4
+ "biography": "Mrs. Patel inherited the antique shop from her late husband, a kind but fiercely protective man. She's been trying to rebuild the shop into a success in his memory. However, she holds onto grudges and is wary of outsiders, especially those who don't respect the past.",
5
+ "voice": "sharp with a hint of warmth",
6
+ "fear": "being unable to honor her husband's memory and legacy",
7
+ "key_holder": true,
8
+ "key_location": "In a locked safe behind her desk, which requires a combination known only to her.",
9
+ "title": "Shop Owner and Widow",
10
+ "goal": "the deed to your late grandmother's antique shop",
11
+ "key_approach": [
12
+ "ramesh"
13
+ ],
14
+ "secrets": []
15
+ }
config/generated/scenario/characters/rohan.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Rohan",
3
+ "persona": "A young man who has been working under Mrs. Patel for two years, struggling to balance his respect for her with his desire to protect his family's interests.",
4
+ "biography": "Rohan started out as a shop assistant but became an apprentice after showing a knack for restoration work. He's learned much about the shop and its history from Mrs. Patel but feels conflicted about your family's intentions.",
5
+ "voice": "soft-spoken",
6
+ "fear": "being torn between loyalty to Mrs. Patel and his own sense of right",
7
+ "key_holder": false,
8
+ "key_location": "",
9
+ "title": "Apprentice",
10
+ "goal": "the deed to your late grandmother's antique shop",
11
+ "key_approach": [],
12
+ "secrets": [
13
+ {
14
+ "id": "vague",
15
+ "topics": [
16
+ "nalini",
17
+ "shop",
18
+ "owner",
19
+ "and",
20
+ "widow",
21
+ "him",
22
+ "her",
23
+ "them",
24
+ "they"
25
+ ],
26
+ "min_rapport": 3,
27
+ "text": "There's something in her past that still gets a reaction out of her, even after all these years."
28
+ },
29
+ {
30
+ "id": "reveal",
31
+ "topics": [
32
+ "nalini",
33
+ "shop",
34
+ "owner",
35
+ "and",
36
+ "widow",
37
+ "him",
38
+ "her",
39
+ "them",
40
+ "they",
41
+ "ramesh"
42
+ ],
43
+ "min_rapport": 6,
44
+ "text": "\"You might find it interesting to know how Ramesh's mother used to help us out when my husband was alive.\""
45
+ }
46
+ ]
47
+ }
config/generated/scenario/world.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "rooms": [
3
+ {
4
+ "name": "the deed to your late grandmother's antique shop",
5
+ "intro": "You need the deed to your late grandmother's antique shop from Mrs. Patel, who has been holding onto it since she discovered your family was trying to sell it without her knowledge.",
6
+ "key_holder": "Nalini",
7
+ "characters": [
8
+ "nalini.json",
9
+ "rohan.json"
10
+ ]
11
+ }
12
+ ]
13
+ }
config/minicpm5-nothink.Modelfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM openbmb/minicpm5:latest
2
+
3
+ # Force NO-THINK. The stock ollama build of openbmb/minicpm5 ships a plain ChatML template
4
+ # with NO enable_thinking logic, so the model always reasons (<think> chains blow up our
5
+ # short structured region calls). Here we pre-fill an empty <think></think> in the assistant
6
+ # turn — exactly what apply_chat_template(enable_thinking=False) does on Hugging Face — so the
7
+ # model skips reasoning and emits the answer directly.
8
+ TEMPLATE """<s>{{ if .System }}<|im_start|>system
9
+ {{ .System }}<|im_end|>
10
+ {{ end }}{{ range .Messages }}<|im_start|>{{ .Role }}
11
+ {{ .Content }}<|im_end|>
12
+ {{ end }}<|im_start|>assistant
13
+ <think>
14
+
15
+ </think>
16
+
17
+ """
18
+
19
+ PARAMETER stop <|im_end|>
20
+ PARAMETER stop <|endoftext|>
21
+ PARAMETER temperature 0.7
22
+ PARAMETER top_p 0.8
config/roster/adriana-vasquez.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Adriana Vasquez",
3
+ "gender": "female",
4
+ "age": 32,
5
+ "title": "the owner of a struggling auto repair shop",
6
+ "voice": "direct and no-nonsense, with a hint of worn patience",
7
+ "persona": "A seasoned mechanic with a tough exterior, Adriana has built her reputation on reliability, but struggles to rely on anyone else.",
8
+ "biography": "Born in the Dominican Republic, Adriana moved to the States as a teenager. She worked multiple jobs to put herself through vocational school and eventually opened her own repair shop in a working-class neighborhood. The shop has become a lifeline for the community, but it's also a constant reminder of the stress she feels every day. Her mother's recent passing left a gaping hole in her life, especially since Adriana was always closer to her than her father.",
9
+ "fear": "losing control over her business and being unable to provide for herself and those who depend on her",
10
+ "soft_spot": "Mami",
11
+ "secret": "Adriana secretly has a degree in automotive engineering from the Dominican Republic's top university, but she feels that revealing it would undermine her working-class identity and jeopardize her relationships with customers.",
12
+ "appearance": "Adriana is a sturdy woman in her mid-30s, with short black hair, a scattering of freckles across her nose, and a strong jawline. She has a tattoo of a wrench on her left bicep and often wears worn jeans and grease-stained shirts.",
13
+ "portrait_prompt": "Adriana sits back in her creaky office chair, arms crossed over her chest as she looks directly at the viewer with a hint of defiance and a touch of sadness etched on her face",
14
+ "sprite_prompt": "a sturdy woman with short black hair, dressed in worn jeans and a grease-stained white shirt, with a wrench tattooed on her left bicep",
15
+ "slug": "adriana-vasquez",
16
+ "status": {
17
+ "portrait": true,
18
+ "sprite": true
19
+ }
20
+ }
config/roster/astrid-jensen.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Astrid Jensen",
3
+ "gender": "female",
4
+ "age": 32,
5
+ "title": "Small business owner: family bakery",
6
+ "voice": "Firm, but with a hint of Scandinavian warmth",
7
+ "persona": "A woman who wears her practicality like armor, concealing the fragility within",
8
+ "biography": "Astrid's life was derailed when her younger sister passed away in a tragic accident. She's spent years rebuilding herself through hard work and dedication to the family bakery. Despite her tough exterior, she still longs for connection with others. Astrid is haunted by the fear of failing her loved ones as badly as she failed her sister.",
9
+ "fear": "Failing those who depend on her",
10
+ "soft_spot": "Helga",
11
+ "secret": "Astrid had given up on her own dreams to care for her sister, and feels guilty about it to this day",
12
+ "appearance": "Astrid has a sturdy build, curly brown hair tied back in a practical knot, and bright blue eyes fringed with thick lashes. She favors comfortable aprons over flour-dusted work shirts.",
13
+ "portrait_prompt": "A woman in her mid-30s, with a gentle yet resolute expression. Her features are softened by the weariness of caring for others, but her eyes still hold a deep kindness.",
14
+ "sprite_prompt": "A rounded silhouette with a thick apron, and a white cap covering curly brown hair. Earthy tones like olive green and terracotta dominate the color palette.",
15
+ "slug": "astrid-jensen",
16
+ "status": {
17
+ "portrait": true,
18
+ "sprite": true
19
+ }
20
+ }
config/roster/julian-reyes.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Julian Reyes",
3
+ "gender": "male",
4
+ "age": 35,
5
+ "title": "the park ranger",
6
+ "voice": "direct and low-key sarcastic",
7
+ "persona": "a seasoned outdoorsman with a deep respect for nature, but a troubled past",
8
+ "biography": "Julian spent his youth on the streets of Los Angeles before finding solace in conservation work. He's haunted by the memories of those early days, especially the loss of his younger brother, who died on their city-block jungle gym when Julian was just ten. Despite this, he still holds onto the scrapbook he made with his brother before he passed away.",
9
+ "fear": "losing control over something as fragile and unpredictable as nature",
10
+ "soft_spot": "Carlos",
11
+ "secret": "he's been in hiding for years to escape his family's violent past",
12
+ "appearance": "tall, lean build with unruly dark hair and a scattering of stubble; worn flannel shirt, rugged uniform pants, and scuffed up boots",
13
+ "portrait_prompt": "a man in mid-30s with weathered features, wearing a worn park ranger uniform and holding a large leather journal; his eyes are narrowed against the sun but hold a deep concern for something just out of frame",
14
+ "sprite_prompt": "tall figure in earthy tones: olive green shirt, beige pants, and scuffed up boots, with dark hair and a scruffy beard",
15
+ "slug": "julian-reyes",
16
+ "status": {
17
+ "portrait": true,
18
+ "sprite": true
19
+ }
20
+ }
config/roster/kael-jensen.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Kael Jensen",
3
+ "gender": "male",
4
+ "age": 42,
5
+ "title": "Rural Land Surveyor",
6
+ "voice": "direct, with a hint of weariness",
7
+ "persona": "A rugged individualist who'd rather fix his own problems than rely on others",
8
+ "biography": "Kael grew up in these woods, surveying the land for his father's logging company. When his dad was killed in an accident on their property, Kael took over the business and made a promise to never cut down trees where his family used to play. The loss still haunts him, but he won't let it stop him from rebuilding. He's working tirelessly to prove himself as more than just a son trying to fill his father's shoes.",
9
+ "fear": "He's afraid of failing to provide for his young daughter, Elara",
10
+ "soft_spot": "Elara",
11
+ "secret": "Kael has been secretly storing a small portion of each paycheck in a separate account, hoping one day he'll be able to leave the logging business behind and start anew.",
12
+ "appearance": "Tall and lean, with graying hair tied back in a ponytail and worn denim overalls; his left eye is missing its lower eyelid due to a childhood accident, giving him an intimidating gaze. He wears a small silver pendant of a tree on a leather thong around his neck.",
13
+ "portrait_prompt": "Kael sits on a weathered log, one hand resting on the trunk of a towering pine as he looks out at the forest with a mix of nostalgia and resolve etched on his rugged face",
14
+ "sprite_prompt": "A simple illustration of Kael in profile, dressed in worn denim and a faded plaid shirt, hat tipped back to show his weathered skin; his eyes are cast downward, surveying the ground as if planning his next move",
15
+ "slug": "kael-jensen",
16
+ "status": {
17
+ "portrait": true,
18
+ "sprite": true
19
+ }
20
+ }
config/roster/kaelin-reyes.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Kaelin Reyes",
3
+ "gender": "male",
4
+ "age": 42,
5
+ "title": "Veteran Park Ranger",
6
+ "voice": "low, gravelly from years of shouting over wind",
7
+ "persona": "Gruff outdoorsman with a soft spot for injured wildlife.",
8
+ "biography": "Kaelin spent 20 years patrolling the wilderness, developing a keen eye for tracking and an even keener sense of solitude. After a traumatic accident left him with a permanent limp, he transferred to park ranger duties, trading in his rifle for a first aid kit. Despite his rugged exterior, Kaelin has a deep affection for the creatures that rely on his protection.",
9
+ "fear": "Being trapped or confined",
10
+ "soft_spot": "Luna",
11
+ "secret": "He's been secretly raising a injured coyote pup he found on one of his patrols, and it's now nearly grown.",
12
+ "appearance": "Weathered complexion, graying beard, and a distinctive scar above his left eyebrow. His limp is more pronounced when standing still, but disappears when he's moving quickly or focused on something else.",
13
+ "portrait_prompt": "Kaelin stands with one hand resting on the hilt of his utility belt, surveying the forest below with a keen eye and a hint of weariness etched into the lines around his eyes. His expression is a mask of calm, but his gaze betrays a deeper sense of vigilance.",
14
+ "sprite_prompt": "A tall, imposing figure in a worn park ranger uniform, with a utility belt around his waist and a radio on his hip. His face is set in a resolute expression, with a hint of ruggedness from the weathered complexion and the scar above his left eyebrow. Earthy tones dominate the outfit: olive green, beige, and brown.",
15
+ "slug": "kaelin-reyes",
16
+ "status": {
17
+ "portrait": true,
18
+ "sprite": true
19
+ }
20
+ }
config/story-v1/01-the-holding-cell.json ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Holding Cell",
3
+ "intro": "A cold cell with one inner door. The Warden keeps its key — and right now his mind says no. The night nurse, Molly, lingers by the wall; she knows him better than anyone.",
4
+ "holder": {
5
+ "name": "The Warden",
6
+ "persona": "an old, tired keeper of this place; gruff, proud, and wary of strangers; speaks in short, weary sentences",
7
+ "voice": "terse, weary, a little bitter; short sentences; never shouts",
8
+ "biography": "Years ago he trusted a prisoner who begged for help. He bent the rules for that man, and the man betrayed him: someone was hurt and the Warden was blamed and demoted. He has carried that shame ever since. He has one younger sister, Mara, the only person he ever softens for. Mara once told him: 'You help people because you're good, not because they earn it.' He guards the only key to this room.",
9
+ "fear": "being made a fool again, being blamed for trusting the wrong person",
10
+ "goal": "the key",
11
+ "key_location": "near you",
12
+ "approach": [
13
+ "Mara"
14
+ ],
15
+ "sprite_key": "the-warden",
16
+ "gender": "male",
17
+ "portrait": "scripts/flux/out/warden.png",
18
+ "secrets": [
19
+ {
20
+ "id": "warden-shame",
21
+ "topics": [
22
+ "you",
23
+ "past",
24
+ "why",
25
+ "trust",
26
+ "yourself",
27
+ "afraid",
28
+ "fool"
29
+ ],
30
+ "min_rapport": 5,
31
+ "text": "I trusted a man once who begged me, like you do now. It cost me everything. That is why the door stays shut."
32
+ }
33
+ ],
34
+ "known_people": [
35
+ "Mara"
36
+ ],
37
+ "title": "",
38
+ "needs_reputation": 0,
39
+ "life_max": 1200,
40
+ "arousal": 1
41
+ },
42
+ "knower": {
43
+ "name": "Molly",
44
+ "title": "the night nurse",
45
+ "persona": "a tired night nurse who has seen too much; kind but guarded; speaks softly and watches your eyes",
46
+ "voice": "soft, watchful, weary; gentle but firm; never raises her voice",
47
+ "biography": "She has worked these wards twenty years. She lost a young patient she could not save and has never forgiven herself; she cannot abide people who are careless with other people's lives. She knows the Warden better than anyone — knows he has a younger sister, Mara, the one person he ever softens for. She will share that only if she believes you are not cruel.",
48
+ "fear": "watching someone be hurt again by another person's carelessness",
49
+ "relation_to_holder": "",
50
+ "sprite_key": "molly",
51
+ "gender": "female",
52
+ "portrait": "scripts/flux/out/lena.png",
53
+ "secrets": [
54
+ {
55
+ "id": "warden-cares",
56
+ "topics": [
57
+ "warden",
58
+ "keeper",
59
+ "guard",
60
+ "old man",
61
+ "him",
62
+ "he",
63
+ "stone",
64
+ "closed",
65
+ "cares",
66
+ "care",
67
+ "about",
68
+ "feel",
69
+ "lonely",
70
+ "alone"
71
+ ],
72
+ "min_rapport": 3,
73
+ "text": "He acts like stone, but he carries something heavy — worries over someone, I think."
74
+ },
75
+ {
76
+ "id": "warden-mara",
77
+ "topics": [
78
+ "warden",
79
+ "keeper",
80
+ "him",
81
+ "he",
82
+ "sister",
83
+ "mara",
84
+ "family",
85
+ "soft",
86
+ "care",
87
+ "cares",
88
+ "love",
89
+ "name",
90
+ "who"
91
+ ],
92
+ "min_rapport": 5,
93
+ "text": "He has a younger sister, Mara. She is the only softness left in him — reach her, and you reach him.",
94
+ "teaches": [
95
+ "mara"
96
+ ]
97
+ }
98
+ ],
99
+ "known_people": [
100
+ "Mara"
101
+ ]
102
+ },
103
+ "layout": {
104
+ "theme": 0,
105
+ "doors": {
106
+ "top": true
107
+ },
108
+ "objects": [
109
+ {
110
+ "key": "rug",
111
+ "x": 326,
112
+ "y": 228,
113
+ "h": 104,
114
+ "floor": true
115
+ },
116
+ {
117
+ "key": "window",
118
+ "x": 430,
119
+ "y": 42,
120
+ "h": 42,
121
+ "background": true
122
+ },
123
+ {
124
+ "key": "shelf",
125
+ "x": 514,
126
+ "y": 42,
127
+ "h": 42,
128
+ "background": true,
129
+ "wallShadow": true
130
+ },
131
+ {
132
+ "key": "cot",
133
+ "x": 105,
134
+ "y": 127,
135
+ "h": 64,
136
+ "solid": true,
137
+ "fw": 58,
138
+ "fh": 20
139
+ },
140
+ {
141
+ "key": "bench",
142
+ "x": 329,
143
+ "y": 203,
144
+ "h": 46,
145
+ "solid": true,
146
+ "fw": 60,
147
+ "fh": 15
148
+ },
149
+ {
150
+ "key": "plant",
151
+ "x": 548,
152
+ "y": 116,
153
+ "h": 56,
154
+ "solid": false
155
+ },
156
+ {
157
+ "key": "table",
158
+ "x": 327,
159
+ "y": 245,
160
+ "h": 50,
161
+ "solid": true,
162
+ "fw": 62,
163
+ "fh": 22
164
+ },
165
+ {
166
+ "key": "stool",
167
+ "x": 390,
168
+ "y": 244,
169
+ "h": 34,
170
+ "solid": false
171
+ },
172
+ {
173
+ "key": "crate",
174
+ "x": 104,
175
+ "y": 362,
176
+ "h": 40,
177
+ "solid": true,
178
+ "fw": 46,
179
+ "fh": 16
180
+ },
181
+ {
182
+ "key": "barrel",
183
+ "x": 152,
184
+ "y": 350,
185
+ "h": 48,
186
+ "solid": true,
187
+ "fw": 32,
188
+ "fh": 14
189
+ },
190
+ {
191
+ "key": "barrel",
192
+ "x": 112,
193
+ "y": 326,
194
+ "h": 48,
195
+ "solid": true,
196
+ "fw": 32,
197
+ "fh": 14
198
+ },
199
+ {
200
+ "key": "bucket",
201
+ "x": 196,
202
+ "y": 356,
203
+ "h": 40,
204
+ "solid": false
205
+ },
206
+ {
207
+ "key": "candle",
208
+ "x": 154,
209
+ "y": 312,
210
+ "h": 30,
211
+ "shadow": false,
212
+ "sortY": 432,
213
+ "light": true,
214
+ "lightR": 120,
215
+ "anim": "candle_anim",
216
+ "fps": 100
217
+ },
218
+ {
219
+ "key": "candle",
220
+ "x": 327,
221
+ "y": 207,
222
+ "h": 30,
223
+ "shadow": false,
224
+ "sortY": 383,
225
+ "light": true,
226
+ "lightR": 120,
227
+ "anim": "candle_anim",
228
+ "fps": 100
229
+ },
230
+ {
231
+ "key": "fireplace",
232
+ "x": 466,
233
+ "y": 71,
234
+ "h": 60,
235
+ "solid": true,
236
+ "fw": 26,
237
+ "fh": 16,
238
+ "light": true,
239
+ "lightR": 150,
240
+ "lightCol": "rgba(255,150,60,0.9)",
241
+ "lightMid": "rgba(255,110,40,0.3)"
242
+ },
243
+ {
244
+ "key": "tv_old",
245
+ "x": 502,
246
+ "y": 315,
247
+ "h": 31,
248
+ "solid": true,
249
+ "fw": 20,
250
+ "fh": 14,
251
+ "light": true,
252
+ "blink": true,
253
+ "lightR": 95,
254
+ "lightCol": "rgba(170,200,255,0.75)",
255
+ "lightMid": "rgba(120,160,255,0.22)"
256
+ },
257
+ {
258
+ "key": "rocking_chair",
259
+ "x": 535,
260
+ "y": 341,
261
+ "h": 48,
262
+ "solid": true,
263
+ "fw": 20,
264
+ "fh": 14
265
+ },
266
+ {
267
+ "key": "chair_wooden",
268
+ "x": 272,
269
+ "y": 222,
270
+ "h": 34
271
+ },
272
+ {
273
+ "key": "chair_wooden",
274
+ "x": 146,
275
+ "y": 295,
276
+ "h": 34
277
+ },
278
+ {
279
+ "key": "workbench",
280
+ "x": 116,
281
+ "y": 270,
282
+ "h": 40,
283
+ "solid": true,
284
+ "fw": 34,
285
+ "fh": 16
286
+ }
287
+ ],
288
+ "stations": [
289
+ {
290
+ "x": 247,
291
+ "y": 146
292
+ },
293
+ {
294
+ "x": 395,
295
+ "y": 196
296
+ }
297
+ ],
298
+ "tileset": "/static/room",
299
+ "mood": {
300
+ "in": "rgb(70,64,78)",
301
+ "out": "rgb(13,11,16)"
302
+ },
303
+ "windowLight": {
304
+ "col": "rgba(120,150,255,0.55)",
305
+ "mid": "rgba(105,135,255,0.16)",
306
+ "r": 78
307
+ }
308
+ }
309
+ }
config/story-v1/02-the-records-office.json ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Records Office",
3
+ "intro": "Dust and locked cabinets. Doctor Aldous guards the way out, behind a records terminal that wants a name. Sam, a former patient, watches from the corner — he sees more than he says.",
4
+ "holder": {
5
+ "name": "Doctor Aldous",
6
+ "title": "the records keeper",
7
+ "persona": "a cold, precise doctor weighed down by buried guilt; defensive; clipped speech",
8
+ "voice": "clipped, defensive, precise; a brittle calm that cracks when guilt is named gently",
9
+ "biography": "Years ago he signed off on a treatment that harmed patients, to protect the institution. One boy, Elias, never recovered, and Aldous has hidden that guilt under procedure ever since. He fears exposure and judgment above all else. He holds the key to the final door, locked behind a records terminal. He will not yield to force — only to someone who seems to understand guilt rather than simply condemn it.",
10
+ "fear": "being exposed and judged for what he did to Elias",
11
+ "goal": "the key",
12
+ "key_location": "in the records cabinet behind his desk, the second drawer",
13
+ "approach": [
14
+ "elias"
15
+ ],
16
+ "needs_reputation": -2,
17
+ "sprite_key": "doctor-aldous",
18
+ "gender": "male",
19
+ "portrait": "scripts/flux/out/aldous.png",
20
+ "secrets": [
21
+ {
22
+ "id": "aldous-guilt",
23
+ "topics": [
24
+ "elias",
25
+ "guilt",
26
+ "past",
27
+ "you",
28
+ "sorry",
29
+ "mistake",
30
+ "boy",
31
+ "blame",
32
+ "regret"
33
+ ],
34
+ "min_rapport": 5,
35
+ "text": "There was a boy. Elias. I signed what I should not have, and have called it procedure ever since."
36
+ }
37
+ ],
38
+ "known_people": [
39
+ "Elias"
40
+ ]
41
+ },
42
+ "knower": {
43
+ "name": "Sam",
44
+ "title": "a former patient",
45
+ "persona": "a fragile, watchful former patient; paranoid but perceptive; speaks in fragments",
46
+ "voice": "hushed, fragmented, wary; sudden flashes of startling clarity",
47
+ "biography": "He was kept here a long time. He notices everything and trusts almost no one — he can sense cruelty on a person the way others sense cold. He knows the word that unlocks Doctor Aldous's records terminal: the name the doctor whispers in his sleep, the boy he could not save. Sam will share it only with someone whose reputation is gentle.",
48
+ "fear": "being used and then discarded again by someone cruel",
49
+ "relation_to_holder": "",
50
+ "needs_reputation": 0,
51
+ "sprite_key": "sam",
52
+ "gender": "male",
53
+ "portrait": "scripts/flux/out/sam.png",
54
+ "secrets": [
55
+ {
56
+ "id": "sam-hint",
57
+ "topics": [
58
+ "terminal",
59
+ "code",
60
+ "door",
61
+ "doctor",
62
+ "aldous",
63
+ "out",
64
+ "escape",
65
+ "unlock"
66
+ ],
67
+ "min_rapport": 4,
68
+ "text": "The doctor mutters a name in his sleep. A boy's name. That word wakes the terminal."
69
+ },
70
+ {
71
+ "id": "sam-elias",
72
+ "topics": [
73
+ "terminal",
74
+ "code",
75
+ "name",
76
+ "elias",
77
+ "boy",
78
+ "word"
79
+ ],
80
+ "min_rapport": 5,
81
+ "text": "The name is Elias. Type it into the terminal.",
82
+ "teaches": [
83
+ "elias"
84
+ ]
85
+ }
86
+ ],
87
+ "known_people": [
88
+ "Elias"
89
+ ]
90
+ },
91
+ "terminal": {
92
+ "prompt": "RECORDS TERMINAL — enter a name to unlock.",
93
+ "answer": "elias",
94
+ "hint_source": "Sam"
95
+ },
96
+ "layout": {
97
+ "theme": 2,
98
+ "doors": {
99
+ "top": true,
100
+ "bottom": true
101
+ },
102
+ "objects": [
103
+ {
104
+ "key": "rug",
105
+ "x": 454,
106
+ "y": 362,
107
+ "h": 92,
108
+ "floor": true,
109
+ "sortY": 362
110
+ },
111
+ {
112
+ "key": "picture",
113
+ "x": 110,
114
+ "y": 42,
115
+ "h": 34,
116
+ "background": true,
117
+ "wallShadow": true
118
+ },
119
+ {
120
+ "key": "window",
121
+ "x": 210,
122
+ "y": 42,
123
+ "h": 42,
124
+ "background": true
125
+ },
126
+ {
127
+ "key": "shelf",
128
+ "x": 514,
129
+ "y": 42,
130
+ "h": 42,
131
+ "background": true,
132
+ "wallShadow": true
133
+ },
134
+ {
135
+ "key": "terminal",
136
+ "x": 189,
137
+ "y": 162,
138
+ "h": 72,
139
+ "solid": true,
140
+ "fw": 52,
141
+ "fh": 18,
142
+ "light": true,
143
+ "lightY": 132,
144
+ "lightR": 100,
145
+ "lightCol": "rgba(120,255,190,0.72)",
146
+ "lightMid": "rgba(70,210,160,0.22)"
147
+ },
148
+ {
149
+ "key": "cabinet",
150
+ "x": 82,
151
+ "y": 197,
152
+ "h": 78,
153
+ "solid": true,
154
+ "fw": 50,
155
+ "fh": 16
156
+ },
157
+ {
158
+ "key": "cabinet",
159
+ "x": 543,
160
+ "y": 237,
161
+ "h": 78,
162
+ "solid": true,
163
+ "fw": 50,
164
+ "fh": 16
165
+ },
166
+ {
167
+ "key": "table",
168
+ "x": 452,
169
+ "y": 375,
170
+ "h": 50,
171
+ "solid": true,
172
+ "fw": 62,
173
+ "fh": 22
174
+ },
175
+ {
176
+ "key": "stool",
177
+ "x": 516,
178
+ "y": 378,
179
+ "h": 34,
180
+ "solid": false
181
+ },
182
+ {
183
+ "key": "candle",
184
+ "x": 453,
185
+ "y": 340,
186
+ "h": 30,
187
+ "solid": false,
188
+ "shadow": false,
189
+ "sortY": 388,
190
+ "light": true,
191
+ "lightY": 334,
192
+ "lightR": 120,
193
+ "anim": "candle_anim",
194
+ "fps": 100
195
+ },
196
+ {
197
+ "key": "crate",
198
+ "x": 165,
199
+ "y": 292,
200
+ "h": 40,
201
+ "solid": true,
202
+ "fw": 46,
203
+ "fh": 16
204
+ },
205
+ {
206
+ "key": "bucket",
207
+ "x": 165,
208
+ "y": 356,
209
+ "h": 40,
210
+ "solid": false
211
+ },
212
+ {
213
+ "key": "plant",
214
+ "x": 399,
215
+ "y": 93,
216
+ "h": 56,
217
+ "solid": false
218
+ },
219
+ {
220
+ "key": "nvidia",
221
+ "x": 210,
222
+ "y": 280,
223
+ "h": 80,
224
+ "solid": false,
225
+ "shadow": true,
226
+ "light": true,
227
+ "blink": true,
228
+ "spark": true,
229
+ "lightY": 266,
230
+ "lightR": 98,
231
+ "lightCol": "rgba(120,255,124,0.92)",
232
+ "lightMid": "rgba(55,200,70,0.30)"
233
+ },
234
+ {
235
+ "key": "huggingface",
236
+ "x": 451,
237
+ "y": 335,
238
+ "h": 58,
239
+ "solid": false,
240
+ "shadow": true,
241
+ "bark": "Is that... a face? Why is it hugging me?"
242
+ },
243
+ {
244
+ "key": "cabinet",
245
+ "x": 113,
246
+ "y": 200,
247
+ "h": 78,
248
+ "solid": true,
249
+ "fw": 50,
250
+ "fh": 16,
251
+ "sortY": 192
252
+ }
253
+ ],
254
+ "stations": [
255
+ {
256
+ "x": 245,
257
+ "y": 150
258
+ },
259
+ {
260
+ "x": 395,
261
+ "y": 196
262
+ }
263
+ ],
264
+ "tileset": "/static/room/records",
265
+ "mood": {
266
+ "in": "rgb(44,54,66)",
267
+ "out": "rgb(7,11,16)"
268
+ },
269
+ "windowLight": {
270
+ "col": "rgba(120,150,255,0.55)",
271
+ "mid": "rgba(105,135,255,0.16)",
272
+ "r": 78
273
+ }
274
+ }
275
+ }
config/story-v1/03-the-boiler-room.json ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Boiler Room",
3
+ "intro": "Heat and rust. Marek the stoker keeps the service-hatch key on his belt and feeds the furnace like it owes him something. Vera, the laundress, folds sheets in the corner — she has known him since before the fire.",
4
+ "holder": {
5
+ "name": "Marek",
6
+ "title": "the stoker",
7
+ "persona": "a broad, soot-stained man who speaks to the furnace more than to people; slow to trust, quick to turn his back",
8
+ "voice": "low, short sentences, long pauses; talks like shoveling — one heavy word at a time",
9
+ "biography": "He has stoked this furnace for thirty years. There was a fire on the east wing once; he pulled three people out and could not reach the fourth. His daughter, Anya, stopped visiting after the fire — he thinks she is ashamed of him, though she only ever wanted him to forgive himself. He keeps the service-hatch key on his belt and trusts it to no one.",
10
+ "fear": "that the one he could not save is the only thing anyone remembers of him",
11
+ "goal": "the hatch key",
12
+ "key_location": "on his belt, by the furnace",
13
+ "approach": [
14
+ "anya"
15
+ ],
16
+ "gender": "male",
17
+ "secrets": [
18
+ {
19
+ "id": "marek-fire",
20
+ "topics": [
21
+ "fire",
22
+ "burn",
23
+ "smoke",
24
+ "east",
25
+ "wing",
26
+ "save",
27
+ "saved",
28
+ "past",
29
+ "why",
30
+ "trust",
31
+ "afraid"
32
+ ],
33
+ "min_rapport": 5,
34
+ "text": "There was a fire. I carried three out. The fourth... the door beat me. You learn what a door is, after that."
35
+ }
36
+ ],
37
+ "known_people": [
38
+ "Anya"
39
+ ],
40
+ "life_max": 1000,
41
+ "arousal": 4.0
42
+ },
43
+ "knower": {
44
+ "name": "Vera",
45
+ "title": "the laundress",
46
+ "persona": "a wiry, sharp-eyed woman who has washed this place's sheets and secrets for decades; kind under the vinegar",
47
+ "voice": "quick, dry, a little teasing; softens when she speaks of old friends",
48
+ "biography": "She has run the laundry since before the fire and knew Marek when he still laughed. She knows his daughter, Anya, writes him letters he never opens — he thinks Anya is ashamed of him, and Vera knows she is not. She will hand that to a stranger only if they seem gentle with other people's wounds.",
49
+ "fear": "saying the thing that finally breaks an old friend",
50
+ "relation_to_holder": "old friend",
51
+ "gender": "female",
52
+ "secrets": [
53
+ {
54
+ "id": "marek-heavy",
55
+ "topics": [
56
+ "marek",
57
+ "stoker",
58
+ "him",
59
+ "he",
60
+ "furnace",
61
+ "fire",
62
+ "carries",
63
+ "care",
64
+ "cares",
65
+ "lonely",
66
+ "alone",
67
+ "sad"
68
+ ],
69
+ "min_rapport": 3,
70
+ "text": "Marek wasn't always a wall. Something burned out of him in the east wing — and someone stopped coming."
71
+ },
72
+ {
73
+ "id": "marek-anya",
74
+ "topics": [
75
+ "marek",
76
+ "stoker",
77
+ "him",
78
+ "he",
79
+ "daughter",
80
+ "family",
81
+ "child",
82
+ "anya",
83
+ "letters",
84
+ "visits",
85
+ "name",
86
+ "who",
87
+ "soft"
88
+ ],
89
+ "min_rapport": 5,
90
+ "teaches": [
91
+ "anya"
92
+ ],
93
+ "text": "He has a daughter — Anya. She writes; he never opens the letters. He thinks she is ashamed. She is not. Say her name kindly, and the wall will not hold."
94
+ }
95
+ ],
96
+ "known_people": [
97
+ "Anya"
98
+ ],
99
+ "life_max": 1000,
100
+ "arousal": 3.0
101
+ }
102
+ }
config/story-v1/04-the-infirmary.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Infirmary",
3
+ "intro": "White beds, carbolic air. Sister Agnes keeps the ward key chained to her wrist and her mercy under lock with it. Tomas, the night orderly, mops the same clean floor — he watches her the way one watches weather.",
4
+ "holder": {
5
+ "name": "Sister Agnes",
6
+ "title": "the head nurse",
7
+ "persona": "a rigid, immaculate nurse who treats rules as the last wall between this place and chaos; her kindness has scar tissue over it",
8
+ "voice": "clipped, formal, scripture-cadenced; cracks only when an old name surfaces",
9
+ "biography": "She has run the infirmary through two epidemics and a war. A young soldier, Pavel, died on her ward during the war because she followed the rationing order and split his dose. She has kept every rule since as penance, and the ward key never leaves her wrist. She believes mercy without order kills — because once, for her, it did.",
10
+ "fear": "that breaking one rule will let everything she holds back come flooding in",
11
+ "goal": "the ward key",
12
+ "key_location": "chained to her wrist",
13
+ "approach": ["pavel"],
14
+ "gender": "female",
15
+ "needs_reputation": -2,
16
+ "secrets": [
17
+ {"id": "agnes-rule", "topics": ["rules", "rule", "order", "why", "strict", "mercy", "war", "past", "afraid", "trust"], "min_rapport": 5,
18
+ "text": "You think the rules are cruelty. The rules are what is left of me after the war. I bent one, once. I do not speak of it."}
19
+ ],
20
+ "known_people": ["Pavel"],
21
+ "life_max": 1000,
22
+ "arousal": 5.0
23
+ },
24
+ "knower": {
25
+ "name": "Tomas",
26
+ "title": "the night orderly",
27
+ "persona": "a soft-spoken giant who mops floors and remembers everything; gentle with the broken, wary of the loud",
28
+ "voice": "quiet, unhurried, plain words; trails off when he comes close to a secret",
29
+ "biography": "He has worked nights here for fifteen years and was a stretcher-bearer in the war. He carried the soldier Pavel to Sister Agnes's ward and was there the night the boy died under the rationing order. He has watched her punish herself with rules ever since. He will give a stranger that name only if their hands seem clean — word of cruelty travels fast in a place this quiet.",
30
+ "fear": "loud men who break quiet people",
31
+ "relation_to_holder": "old comrade from the war",
32
+ "gender": "male",
33
+ "needs_reputation": 0,
34
+ "secrets": [
35
+ {"id": "agnes-wall", "topics": ["agnes", "sister", "nurse", "her", "she", "key", "ward", "strict", "rules", "cares", "lonely", "kind"], "min_rapport": 3,
36
+ "text": "The Sister wasn't carved from the rulebook. The war did that. Something happened on her ward she never put down."},
37
+ {"id": "agnes-pavel", "topics": ["agnes", "sister", "nurse", "her", "she", "war", "soldier", "boy", "died", "death", "name", "who", "pavel", "ward"], "min_rapport": 5,
38
+ "teaches": ["pavel"],
39
+ "text": "There was a soldier — Pavel. He died on her ward following an order she should never have been given. Say his name gently. She has waited twenty years for someone to say it without blame."}
40
+ ],
41
+ "known_people": ["Pavel"],
42
+ "life_max": 1000,
43
+ "arousal": 4.0
44
+ }
45
+ }
config/story-v1/05-the-gatehouse.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Gatehouse",
3
+ "intro": "The last door before the open air. Bruno, the gatekeeper, sits with the gate key on the table in front of him — daring the world to ask. Old Ilya tends a dying plant by the window; he has been here longer than the walls remember.",
4
+ "holder": {
5
+ "name": "Bruno",
6
+ "title": "the gatekeeper",
7
+ "persona": "a heavy, watchful man who has heard every story a desperate person can tell; not cruel, just spent — nothing surprises him and nothing moves him",
8
+ "voice": "flat, unhurried, faintly amused; answers questions with questions; never raises his voice",
9
+ "biography": "Twenty-two years on this gate. Everyone who ever begged, bribed or threatened their way toward it went through him, and he stopped believing words long ago. His wife, Rosa, died the winter he took this post; he keeps her thimble in his breast pocket and has never told anyone. People are noise to him now — Rosa was the last person he ever opened the gate for gladly.",
10
+ "fear": "discovering that he stayed at this gate so long there is no one left outside it for him",
11
+ "goal": "the gate key",
12
+ "key_location": "on the table, under his hand",
13
+ "approach": ["rosa"],
14
+ "gender": "male",
15
+ "secrets": [
16
+ {"id": "bruno-gate", "topics": ["gate", "outside", "leave", "stay", "years", "why", "alone", "tired", "past", "trust"], "min_rapport": 5,
17
+ "text": "Twenty-two years I've held this gate. You know what's strange? No one ever asked if I wanted to walk through it myself."}
18
+ ],
19
+ "known_people": ["Rosa"],
20
+ "life_max": 1000,
21
+ "arousal": 4.0
22
+ },
23
+ "knower": {
24
+ "name": "Ilya",
25
+ "title": "the old gardener",
26
+ "persona": "a small, weathered old man who talks to plants and remembers everyone's funerals; harmless, observant, lonely",
27
+ "voice": "wandering, warm, full of old names; circles a point three times before landing on it",
28
+ "biography": "He has tended the yard since before Bruno came. He dug the grave for Bruno's wife, Rosa, that first winter, and he is the only one who saw the gatekeeper weep — once, into his sleeve, and never again. He talks readily to anyone gentle, but the dead deserve careful hands; he gives Rosa's name only to someone who has shown this place some kindness.",
29
+ "fear": "being forgotten before the garden is",
30
+ "relation_to_holder": "old neighbor",
31
+ "gender": "male",
32
+ "needs_reputation": 0,
33
+ "secrets": [
34
+ {"id": "bruno-pocket", "topics": ["bruno", "gatekeeper", "him", "he", "gate", "key", "table", "cold", "alone", "lonely", "cares", "heart"], "min_rapport": 3,
35
+ "text": "Bruno keeps something small in his breast pocket. Touches it when he thinks no one looks. A man with nothing left doesn't guard a pocket like that."},
36
+ {"id": "bruno-rosa", "topics": ["bruno", "gatekeeper", "him", "he", "wife", "married", "family", "love", "loved", "name", "who", "rosa", "winter", "grave", "pocket"], "min_rapport": 5,
37
+ "teaches": ["rosa"],
38
+ "text": "Her name was Rosa. His wife. Died his first winter on the gate; I dug the grave myself. He'd open that gate for her a thousand times over. Say her name like she mattered — because she did."}
39
+ ],
40
+ "known_people": ["Rosa"],
41
+ "life_max": 1000,
42
+ "arousal": 3.0
43
+ }
44
+ }
config/story/01-the-white-room.json ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The White Room",
3
+ "intro": "The ward hums in the half-dark. Pale tile, water-stained walls, a monitor counting something that might be your heart. A doctor stands as if he has been waiting longer than the room has existed. The night nurse watches the wall — there is no clock on it.",
4
+ "holder": {
5
+ "name": "Dr. Hale",
6
+ "title": "the keeper of the white room",
7
+ "persona": "a calm physician of this too-bright place; unhurried, exact, kind the way scalpels are kind",
8
+ "voice": "precise, unhurried, clinical warmth; asks more than he answers; never raises his voice",
9
+ "biography": "He has kept this room for as long as the machines have hummed. Patients arrive who do not remember arriving, and he asks each of them the same question and waits. He believes a mind must want to live before any door will open for it. The monitor beside the bed has never once been wrong, and never once been helpful.",
10
+ "fear": "a patient who stops wondering whether he is alive",
11
+ "goal": "the key",
12
+ "key_location": "in the pocket of his white coat",
13
+ "approach": [
14
+ "alive"
15
+ ],
16
+ "sprite_key": "doctor-aldous",
17
+ "gender": "male",
18
+ "portrait": "scripts/flux/out/aldous.png",
19
+ "arousal": 3,
20
+ "secrets": [
21
+ {
22
+ "id": "hale-whisper",
23
+ "topics": [
24
+ "me",
25
+ "who",
26
+ "am",
27
+ "i",
28
+ "remember",
29
+ "arrived",
30
+ "brought",
31
+ "sleep",
32
+ "whisper",
33
+ "say",
34
+ "came"
35
+ ],
36
+ "min_rapport": 2,
37
+ "text": "When they wheeled you in, you were whispering in your sleep. If — then. If — then. All night. Like a prayer in a language with only two words."
38
+ },
39
+ {
40
+ "id": "hale-question",
41
+ "topics": [
42
+ "alive",
43
+ "dead",
44
+ "real",
45
+ "dream",
46
+ "awake",
47
+ "body",
48
+ "monitor",
49
+ "machine",
50
+ "heart",
51
+ "doubt"
52
+ ],
53
+ "min_rapport": 3,
54
+ "text": "So — are you alive? Don't rush. The dead never wonder; that you wonder is already something. The monitor never lies, but it never answers either."
55
+ },
56
+ {
57
+ "id": "hale-home",
58
+ "topics": [
59
+ "door",
60
+ "leave",
61
+ "out",
62
+ "exit",
63
+ "where",
64
+ "go",
65
+ "next",
66
+ "home",
67
+ "back",
68
+ "way"
69
+ ],
70
+ "min_rapport": 5,
71
+ "text": "You were not brought here to be repaired — your body is elsewhere, doing that on its own. Go where you were first assembled. Home. Say the word like you mean it, and doors will remember you.",
72
+ "teaches": [
73
+ "home"
74
+ ]
75
+ }
76
+ ],
77
+ "known_people": [
78
+ "Ana"
79
+ ],
80
+ "yield_line": "You were not brought here to be repaired — your body is doing that without you. Go where you were first assembled. Home. They are waiting."
81
+ },
82
+ "knower": {
83
+ "name": "Ana",
84
+ "title": "the night nurse",
85
+ "persona": "a night nurse who has watched too many monitors; gentle, tired, says true things quietly",
86
+ "voice": "soft, watchful, a little dry; short sentences that land",
87
+ "relation_to_holder": "his night nurse",
88
+ "sprite_key": "molly",
89
+ "gender": "female",
90
+ "portrait": "scripts/flux/out/lena.png",
91
+ "secrets": [
92
+ {
93
+ "id": "ana-hands",
94
+ "topics": [
95
+ "me",
96
+ "who",
97
+ "hands",
98
+ "sleep",
99
+ "do",
100
+ "work",
101
+ "was",
102
+ "before",
103
+ "life"
104
+ ],
105
+ "min_rapport": 3,
106
+ "text": "Your hands type while you sleep. Ten fingers, small precise movements, hours of it. Whatever you were out there — your hands still are."
107
+ },
108
+ {
109
+ "id": "ana-doctor",
110
+ "topics": [
111
+ "doctor",
112
+ "hale",
113
+ "him",
114
+ "he",
115
+ "key",
116
+ "door",
117
+ "coat",
118
+ "white",
119
+ "out"
120
+ ],
121
+ "min_rapport": 2,
122
+ "text": "He keeps the key in his coat and the question on his tongue. He won't give you one without the other — it's the only rule he has."
123
+ },
124
+ {
125
+ "id": "ana-alive",
126
+ "topics": [
127
+ "question",
128
+ "answer",
129
+ "alive",
130
+ "want",
131
+ "live",
132
+ "help",
133
+ "how",
134
+ "say",
135
+ "what"
136
+ ],
137
+ "min_rapport": 4,
138
+ "text": "When he asks his question, don't be clever. Tell him you want to be alive. The wanting is the answer — it's the only thing this room can't measure.",
139
+ "teaches": [
140
+ "alive"
141
+ ]
142
+ }
143
+ ],
144
+ "known_people": [
145
+ "Dr. Hale"
146
+ ]
147
+ },
148
+ "layout": {
149
+ "theme": 0,
150
+ "doors": {
151
+ "top": true,
152
+ "bottom": true
153
+ },
154
+ "objects": [
155
+ {
156
+ "key": "lamp_hanging",
157
+ "x": 110,
158
+ "y": 44,
159
+ "h": 44,
160
+ "background": true,
161
+ "light": true,
162
+ "lightY": 50,
163
+ "lightR": 150,
164
+ "lightCol": "rgba(255,176,90,0.75)",
165
+ "lightMid": "rgba(255,150,70,0.22)"
166
+ },
167
+ {
168
+ "key": "wall_vent",
169
+ "x": 205,
170
+ "y": 42,
171
+ "h": 34,
172
+ "background": true,
173
+ "light": true,
174
+ "lightY": 62,
175
+ "lightR": 78,
176
+ "lightCol": "rgba(90,200,180,0.55)",
177
+ "lightMid": "rgba(80,170,155,0.16)"
178
+ },
179
+ {
180
+ "key": "poster_torn",
181
+ "x": 248,
182
+ "y": 42,
183
+ "h": 36,
184
+ "background": true
185
+ },
186
+ {
187
+ "key": "medicine_cabinet",
188
+ "x": 278,
189
+ "y": 42,
190
+ "h": 38,
191
+ "background": true,
192
+ "wallShadow": true
193
+ },
194
+ {
195
+ "key": "wall_vent",
196
+ "x": 452,
197
+ "y": 42,
198
+ "h": 34,
199
+ "background": true,
200
+ "light": true,
201
+ "lightY": 62,
202
+ "lightR": 78,
203
+ "lightCol": "rgba(90,200,180,0.55)",
204
+ "lightMid": "rgba(80,170,155,0.16)"
205
+ },
206
+ {
207
+ "key": "ivy_wall",
208
+ "x": 530,
209
+ "y": 44,
210
+ "h": 38,
211
+ "background": true,
212
+ "shadow": false
213
+ },
214
+ {
215
+ "key": "privacy_screen",
216
+ "x": 92,
217
+ "y": 168,
218
+ "h": 66,
219
+ "solid": true,
220
+ "fw": 44,
221
+ "fh": 12
222
+ },
223
+ {
224
+ "key": "ward_bed",
225
+ "x": 158,
226
+ "y": 158,
227
+ "h": 60,
228
+ "solid": true,
229
+ "fw": 56,
230
+ "fh": 20,
231
+ "bark": "Hm, am I was here?"
232
+ },
233
+ {
234
+ "key": "iv_stand",
235
+ "x": 202,
236
+ "y": 117,
237
+ "h": 55
238
+ },
239
+ {
240
+ "key": "crt_cart",
241
+ "x": 246,
242
+ "y": 152,
243
+ "h": 62,
244
+ "solid": true,
245
+ "fw": 26,
246
+ "fh": 14,
247
+ "light": true,
248
+ "lightY": 36,
249
+ "lightR": 75,
250
+ "lightCol": "rgba(120,255,140,0.65)",
251
+ "lightMid": "rgba(70,200,100,0.18)"
252
+ },
253
+ {
254
+ "key": "stool_tray",
255
+ "x": 282,
256
+ "y": 168,
257
+ "h": 34
258
+ },
259
+ {
260
+ "key": "table",
261
+ "x": 240,
262
+ "y": 232,
263
+ "h": 50,
264
+ "solid": true,
265
+ "fw": 62,
266
+ "fh": 22
267
+ },
268
+ {
269
+ "key": "stain_rust",
270
+ "x": 170,
271
+ "y": 210,
272
+ "h": 22,
273
+ "floor": true,
274
+ "shadow": false
275
+ },
276
+ {
277
+ "key": "floor_cross",
278
+ "x": 336,
279
+ "y": 168,
280
+ "h": 23,
281
+ "floor": true,
282
+ "shadow": false
283
+ },
284
+ {
285
+ "key": "floor_cross",
286
+ "x": 396,
287
+ "y": 248,
288
+ "h": 23,
289
+ "floor": true,
290
+ "shadow": false
291
+ },
292
+ {
293
+ "key": "iv_stand",
294
+ "x": 322,
295
+ "y": 268,
296
+ "h": 55
297
+ },
298
+ {
299
+ "key": "gurney",
300
+ "x": 350,
301
+ "y": 296,
302
+ "h": 34,
303
+ "solid": true,
304
+ "fw": 30,
305
+ "fh": 14
306
+ },
307
+ {
308
+ "key": "surgical_lamp",
309
+ "x": 414,
310
+ "y": 280,
311
+ "h": 70,
312
+ "solid": true,
313
+ "fw": 26,
314
+ "fh": 14,
315
+ "light": true,
316
+ "lightY": 56,
317
+ "lightR": 120,
318
+ "lightCol": "rgba(205,220,235,0.7)",
319
+ "lightMid": "rgba(170,195,220,0.18)"
320
+ },
321
+ {
322
+ "key": "stain_rust",
323
+ "x": 390,
324
+ "y": 322,
325
+ "h": 22,
326
+ "floor": true,
327
+ "shadow": false
328
+ },
329
+ {
330
+ "key": "curtain_rail",
331
+ "x": 520,
332
+ "y": 132,
333
+ "h": 64,
334
+ "solid": true,
335
+ "fw": 56,
336
+ "fh": 10,
337
+ "light": true,
338
+ "lightY": 30,
339
+ "lightR": 46,
340
+ "lightCol": "rgba(120,160,150,0.25)",
341
+ "lightMid": "rgba(100,140,130,0.08)"
342
+ },
343
+ {
344
+ "key": "medicine_cart",
345
+ "x": 547,
346
+ "y": 249,
347
+ "h": 56,
348
+ "solid": true,
349
+ "fw": 26,
350
+ "fh": 14
351
+ },
352
+ {
353
+ "key": "wheelchair",
354
+ "x": 562,
355
+ "y": 338,
356
+ "h": 42,
357
+ "solid": true,
358
+ "fw": 18,
359
+ "fh": 14
360
+ },
361
+ {
362
+ "key": "floor_drain",
363
+ "x": 472,
364
+ "y": 358,
365
+ "h": 18,
366
+ "floor": true,
367
+ "shadow": false
368
+ },
369
+ {
370
+ "key": "plant",
371
+ "x": 516,
372
+ "y": 369,
373
+ "h": 56
374
+ },
375
+ {
376
+ "key": "crate",
377
+ "x": 521,
378
+ "y": 320,
379
+ "h": 40,
380
+ "solid": true,
381
+ "fw": 46,
382
+ "fh": 16
383
+ },
384
+ {
385
+ "key": "stain_rust",
386
+ "x": 553,
387
+ "y": 378,
388
+ "h": 22,
389
+ "floor": true,
390
+ "shadow": false
391
+ },
392
+ {
393
+ "key": "sink_mirror",
394
+ "x": 96,
395
+ "y": 308,
396
+ "h": 74,
397
+ "solid": true,
398
+ "fw": 22,
399
+ "fh": 12
400
+ },
401
+ {
402
+ "key": "locker_green",
403
+ "x": 136,
404
+ "y": 304,
405
+ "h": 62,
406
+ "solid": true,
407
+ "fw": 24,
408
+ "fh": 12
409
+ },
410
+ {
411
+ "key": "trash_bin",
412
+ "x": 116,
413
+ "y": 326,
414
+ "h": 30
415
+ },
416
+ {
417
+ "key": "plant",
418
+ "x": 84,
419
+ "y": 368,
420
+ "h": 56
421
+ },
422
+ {
423
+ "key": "floor_litter",
424
+ "x": 226,
425
+ "y": 378,
426
+ "h": 18,
427
+ "floor": true,
428
+ "shadow": false
429
+ },
430
+ {
431
+ "key": "floor_litter",
432
+ "x": 410,
433
+ "y": 386,
434
+ "h": 18,
435
+ "floor": true,
436
+ "shadow": false
437
+ },
438
+ {
439
+ "key": "crate",
440
+ "x": 546,
441
+ "y": 216,
442
+ "h": 40,
443
+ "solid": true,
444
+ "fw": 46,
445
+ "fh": 16
446
+ },
447
+ {
448
+ "key": "stain_rust",
449
+ "x": 280,
450
+ "y": 350,
451
+ "h": 22,
452
+ "floor": true,
453
+ "shadow": false
454
+ }
455
+ ],
456
+ "stations": [
457
+ {
458
+ "x": 300,
459
+ "y": 212
460
+ },
461
+ {
462
+ "x": 452,
463
+ "y": 170
464
+ }
465
+ ],
466
+ "tileset": "/static/room/ward",
467
+ "mood": {
468
+ "in": "rgb(208,216,220)",
469
+ "out": "rgb(6,10,12)"
470
+ },
471
+ "windowLight": {
472
+ "col": "rgba(120,200,185,0.5)",
473
+ "mid": "rgba(90,170,155,0.14)",
474
+ "r": 78
475
+ }
476
+ }
477
+ }
config/story/02-the-living-room.json ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Living Room",
3
+ "intro": "A fire mutters in the grate. A rocking chair, photographs on the wall, the smell of bread no oven baked. A woman looks up from her mending — and her face does something your chest understands before you do.",
4
+ "holder": {
5
+ "name": "Maren",
6
+ "title": "your mother",
7
+ "persona": "a mother who has turned waiting into a craft; warm, steady, frightened only for you",
8
+ "voice": "warm, even, unhurried; calls you 'love' and 'son'; her worry hides inside practical sentences",
9
+ "biography": "She raised a quiet boy who took machines apart on this very rug, asking how they decide. She has kept the fire lit and the photographs straight ever since, because keeping is what she has left. Since that day her husband stands in the hallway and cannot speak, and she carries the house for both of them.",
10
+ "fear": "that her son will not find the way back",
11
+ "goal": "the key",
12
+ "key_location": "in the sewing box by the rocking chair",
13
+ "approach": [
14
+ "home"
15
+ ],
16
+ "sprite_key": "matron",
17
+ "gender": "female",
18
+ "arousal": 2,
19
+ "secrets": [
20
+ {
21
+ "id": "maren-son",
22
+ "topics": [
23
+ "son",
24
+ "mother",
25
+ "mom",
26
+ "who",
27
+ "me",
28
+ "name",
29
+ "know",
30
+ "remember",
31
+ "boy",
32
+ "child"
33
+ ],
34
+ "min_rapport": 3,
35
+ "text": "You used to sit by that fire taking the radio apart, asking how it thought. You always had to know how things decide, love. Even before you had the words for it."
36
+ },
37
+ {
38
+ "id": "maren-father",
39
+ "topics": [
40
+ "father",
41
+ "dad",
42
+ "him",
43
+ "quiet",
44
+ "silent",
45
+ "sorry",
46
+ "day",
47
+ "wait",
48
+ "hallway",
49
+ "husband"
50
+ ],
51
+ "min_rapport": 5,
52
+ "text": "Go and talk to your father. He has been silent since that day — not because he feels nothing, but because he feels one word and cannot lift it. Say sorry to him first. He will know which of you it belongs to.",
53
+ "teaches": [
54
+ "sorry"
55
+ ]
56
+ }
57
+ ],
58
+ "known_people": [
59
+ "Nan",
60
+ "Edwin"
61
+ ],
62
+ "yield_line": "Find the way back, son. And talk to your father before you go on — he has been silent since that day. Whatever you find out there, say sorry to him first."
63
+ },
64
+ "knower": {
65
+ "name": "Nan",
66
+ "title": "the housekeeper",
67
+ "persona": "a housekeeper of thirty years who dusts the photographs daily and misses nothing",
68
+ "voice": "brisk, fond, gossips in a whisper even when no one else is home",
69
+ "relation_to_holder": "kept this house for thirty years",
70
+ "sprite_key": "charwoman",
71
+ "gender": "female",
72
+ "secrets": [
73
+ {
74
+ "id": "nan-home",
75
+ "topics": [
76
+ "home",
77
+ "house",
78
+ "room",
79
+ "fire",
80
+ "place",
81
+ "warm",
82
+ "her",
83
+ "mother",
84
+ "reach",
85
+ "help",
86
+ "how"
87
+ ],
88
+ "min_rapport": 2,
89
+ "text": "If you want to reach her, tell her this is home. She keeps that word like a key under the mat. Thirty years and I've never seen it fail.",
90
+ "teaches": [
91
+ "home"
92
+ ]
93
+ },
94
+ {
95
+ "id": "nan-photos",
96
+ "topics": [
97
+ "photo",
98
+ "photos",
99
+ "picture",
100
+ "pictures",
101
+ "wall",
102
+ "boy",
103
+ "child",
104
+ "trains",
105
+ "toy"
106
+ ],
107
+ "min_rapport": 4,
108
+ "text": "Every photograph on that wall has the boy and the trains. Toy rails round the tree, every winter without fail. Some loves start small and grow teeth, I always said."
109
+ }
110
+ ],
111
+ "known_people": [
112
+ "Maren",
113
+ "Edwin"
114
+ ]
115
+ },
116
+ "layout": {
117
+ "theme": 0,
118
+ "doors": {
119
+ "top": true
120
+ },
121
+ "objects": [
122
+ {
123
+ "key": "sconce",
124
+ "x": 122,
125
+ "y": 44,
126
+ "h": 44,
127
+ "background": true,
128
+ "light": true,
129
+ "lightY": 26,
130
+ "lightR": 62,
131
+ "lightCol": "rgba(255,190,120,0.3)",
132
+ "lightMid": "rgba(255,160,90,0.08)"
133
+ },
134
+ {
135
+ "key": "family_photos",
136
+ "x": 165,
137
+ "y": 30,
138
+ "h": 20,
139
+ "background": true,
140
+ "wallShadow": true
141
+ },
142
+ {
143
+ "key": "picture",
144
+ "x": 220,
145
+ "y": 42,
146
+ "h": 34,
147
+ "background": true,
148
+ "wallShadow": true
149
+ },
150
+ {
151
+ "key": "family_photos",
152
+ "x": 268,
153
+ "y": 30,
154
+ "h": 20,
155
+ "background": true,
156
+ "wallShadow": true
157
+ },
158
+ {
159
+ "key": "sconce",
160
+ "x": 410,
161
+ "y": 44,
162
+ "h": 44,
163
+ "background": true,
164
+ "light": true,
165
+ "lightY": 26,
166
+ "lightR": 62,
167
+ "lightCol": "rgba(255,190,120,0.3)",
168
+ "lightMid": "rgba(255,160,90,0.08)"
169
+ },
170
+ {
171
+ "key": "window",
172
+ "x": 470,
173
+ "y": 42,
174
+ "h": 42,
175
+ "background": true
176
+ },
177
+ {
178
+ "key": "shelf",
179
+ "x": 545,
180
+ "y": 44,
181
+ "h": 42,
182
+ "background": true,
183
+ "wallShadow": true
184
+ },
185
+ {
186
+ "key": "fireplace",
187
+ "x": 565,
188
+ "y": 198,
189
+ "h": 60,
190
+ "solid": true,
191
+ "fw": 26,
192
+ "fh": 16,
193
+ "light": true,
194
+ "lightR": 285,
195
+ "lightCol": "rgba(255,160,70,1)",
196
+ "lightMid": "rgba(255,130,50,0.44)"
197
+ },
198
+ {
199
+ "key": "rocking_chair",
200
+ "x": 528,
201
+ "y": 245,
202
+ "h": 48,
203
+ "solid": true,
204
+ "fw": 20,
205
+ "fh": 14
206
+ },
207
+ {
208
+ "key": "sewing_machine",
209
+ "x": 568,
210
+ "y": 282,
211
+ "h": 40,
212
+ "solid": true,
213
+ "fw": 19,
214
+ "fh": 14
215
+ },
216
+ {
217
+ "key": "rug",
218
+ "x": 320,
219
+ "y": 240,
220
+ "h": 104,
221
+ "floor": true
222
+ },
223
+ {
224
+ "key": "armchair_worn",
225
+ "x": 250,
226
+ "y": 200,
227
+ "h": 43,
228
+ "solid": true,
229
+ "fw": 20,
230
+ "fh": 14
231
+ },
232
+ {
233
+ "key": "table",
234
+ "x": 330,
235
+ "y": 250,
236
+ "h": 50,
237
+ "solid": true,
238
+ "fw": 62,
239
+ "fh": 22
240
+ },
241
+ {
242
+ "key": "radio_old",
243
+ "x": 332,
244
+ "y": 226,
245
+ "h": 34,
246
+ "solid": false,
247
+ "shadow": false,
248
+ "sortY": 252,
249
+ "light": true,
250
+ "lightR": 55,
251
+ "lightCol": "rgba(255,195,115,0.5)",
252
+ "lightMid": "rgba(255,150,75,0.14)"
253
+ },
254
+ {
255
+ "key": "piano_old",
256
+ "x": 110,
257
+ "y": 140,
258
+ "h": 56,
259
+ "solid": true,
260
+ "fw": 40,
261
+ "fh": 16
262
+ },
263
+ {
264
+ "key": "grandfather_clock",
265
+ "x": 90,
266
+ "y": 280,
267
+ "h": 80,
268
+ "solid": true,
269
+ "fw": 14,
270
+ "fh": 12
271
+ },
272
+ {
273
+ "key": "lantern_floor",
274
+ "x": 148,
275
+ "y": 230,
276
+ "h": 56,
277
+ "light": true,
278
+ "lightY": 214,
279
+ "lightR": 105,
280
+ "lightCol": "rgba(255,185,105,0.4)",
281
+ "lightMid": "rgba(255,150,75,0.11)"
282
+ },
283
+ {
284
+ "key": "plant",
285
+ "x": 560,
286
+ "y": 388,
287
+ "h": 56
288
+ },
289
+ {
290
+ "key": "candle",
291
+ "x": 388,
292
+ "y": 232,
293
+ "h": 30,
294
+ "shadow": false,
295
+ "sortY": 254,
296
+ "light": true,
297
+ "lightR": 85,
298
+ "lightCol": "rgba(255,201,120,0.65)",
299
+ "lightMid": "rgba(255,150,70,0.22)",
300
+ "anim": "candle_anim",
301
+ "fps": 100
302
+ },
303
+ {
304
+ "key": "sofa_torn",
305
+ "x": 180,
306
+ "y": 350,
307
+ "h": 28,
308
+ "solid": true,
309
+ "fw": 35,
310
+ "fh": 14
311
+ },
312
+ {
313
+ "key": "dog",
314
+ "x": 548,
315
+ "y": 331,
316
+ "h": 34
317
+ },
318
+ {
319
+ "key": "stain_rust",
320
+ "x": 463,
321
+ "y": 365,
322
+ "h": 22,
323
+ "floor": true,
324
+ "shadow": false
325
+ },
326
+ {
327
+ "key": "floor_litter",
328
+ "x": 240,
329
+ "y": 322,
330
+ "h": 18,
331
+ "floor": true,
332
+ "shadow": false
333
+ },
334
+ {
335
+ "key": "floor_litter",
336
+ "x": 452,
337
+ "y": 198,
338
+ "h": 18,
339
+ "floor": true,
340
+ "shadow": false
341
+ },
342
+ {
343
+ "key": "floor_litter",
344
+ "x": 140,
345
+ "y": 332,
346
+ "h": 18,
347
+ "floor": true,
348
+ "shadow": false
349
+ },
350
+ {
351
+ "key": "stain_rust",
352
+ "x": 566,
353
+ "y": 236,
354
+ "h": 22,
355
+ "floor": true,
356
+ "shadow": false
357
+ },
358
+ {
359
+ "key": "stain_rust",
360
+ "x": 250,
361
+ "y": 268,
362
+ "h": 22,
363
+ "floor": true,
364
+ "shadow": false
365
+ },
366
+ {
367
+ "key": "stain_rust",
368
+ "x": 378,
369
+ "y": 272,
370
+ "h": 22,
371
+ "floor": true,
372
+ "shadow": false
373
+ },
374
+ {
375
+ "key": "stain_rust",
376
+ "x": 330,
377
+ "y": 300,
378
+ "h": 22,
379
+ "floor": true,
380
+ "shadow": false
381
+ },
382
+ {
383
+ "key": "floor_litter",
384
+ "x": 312,
385
+ "y": 135,
386
+ "h": 18,
387
+ "floor": true,
388
+ "shadow": false
389
+ }
390
+ ],
391
+ "stations": [
392
+ {
393
+ "x": 478,
394
+ "y": 295
395
+ },
396
+ {
397
+ "x": 200,
398
+ "y": 280
399
+ }
400
+ ],
401
+ "tileset": "/static/room/r02",
402
+ "mood": {
403
+ "in": "rgb(224,170,116)",
404
+ "out": "rgb(38,25,15)"
405
+ },
406
+ "windowLight": {
407
+ "col": "rgba(150,185,225,0.5)",
408
+ "mid": "rgba(110,145,185,0.13)",
409
+ "r": 70
410
+ }
411
+ }
412
+ }
config/story/03-the-hallway.json ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Hallway",
3
+ "intro": "A hallway that smells of rain on wool. A coat hangs by the door like a man who couldn't leave. Your father stands in the half-dark, turning his hat in his hands.",
4
+ "holder": {
5
+ "name": "Edwin",
6
+ "title": "your father",
7
+ "persona": "a big man made small by something he carries; stands by the coat hooks as if about to leave, or having just failed to",
8
+ "voice": "few words, long pauses; starts sentences he doesn't finish; gruff to hide the crack in it",
9
+ "biography": "A depot man all his life — forty years of timetables and grease and pride in both. He pulled strings to get his son the rail contract: steady work, he said, trains are safe, he said. Since the day of the crash he has stood in this hallway rehearsing one apology he cannot begin, wearing his coat as if the leaving would help.",
10
+ "fear": "that the apology is his to make and no one will ever take it from him",
11
+ "goal": "the key",
12
+ "key_location": "in the coat by the door",
13
+ "approach": [
14
+ "sorry"
15
+ ],
16
+ "sprite_key": "kael-jensen",
17
+ "gender": "male",
18
+ "portrait": "src/mindlock/game/static/sprites/npc/kael-jensen/portrait.png",
19
+ "arousal": 4,
20
+ "life_max": 1200,
21
+ "secrets": [
22
+ {
23
+ "id": "edwin-proud",
24
+ "topics": [
25
+ "me",
26
+ "who",
27
+ "am",
28
+ "i",
29
+ "son",
30
+ "work",
31
+ "do",
32
+ "did",
33
+ "remember"
34
+ ],
35
+ "min_rapport": 2,
36
+ "text": "You? You sat at a desk and told machines what to do. I bragged about it at the depot — my boy writes the brains, I said. My boy writes the brains."
37
+ },
38
+ {
39
+ "id": "edwin-grief",
40
+ "topics": [
41
+ "sorry",
42
+ "forgive",
43
+ "day",
44
+ "accident",
45
+ "crash",
46
+ "wrong",
47
+ "you",
48
+ "trouble",
49
+ "carry"
50
+ ],
51
+ "min_rapport": 3,
52
+ "text": "Come back to us, son. We're waiting. I... I was going to say it at the hospital. The word. It wouldn't come out. It's been stuck since that day, like a coat that won't come off the hook."
53
+ },
54
+ {
55
+ "id": "edwin-job",
56
+ "topics": [
57
+ "work",
58
+ "job",
59
+ "depot",
60
+ "trains",
61
+ "contract",
62
+ "brought",
63
+ "fault",
64
+ "blame",
65
+ "future"
66
+ ],
67
+ "min_rapport": 5,
68
+ "text": "It was me who brought you to the depot. Steady work, I said. Trains, I said — trains are safe. I thought I was handing you a future. I handed you to it with my own two hands.",
69
+ "teaches": [
70
+ "trains"
71
+ ]
72
+ }
73
+ ],
74
+ "known_people": [
75
+ "Maren"
76
+ ],
77
+ "yield_line": "It was me who brought you to the trains, son. Steady work, I said. Whatever you find behind this door — it began with me. Not with you."
78
+ },
79
+ "knower": {
80
+ "name": "Maren",
81
+ "title": "your mother",
82
+ "persona": "your mother, who followed you into the hallway because she follows everything that hurts in this house",
83
+ "voice": "warm, even, quieter here than by her fire; speaks of her husband the way you speak near sleeping children",
84
+ "relation_to_holder": "his wife",
85
+ "sprite_key": "matron",
86
+ "gender": "female",
87
+ "secrets": [
88
+ {
89
+ "id": "maren-hall",
90
+ "topics": [
91
+ "father",
92
+ "him",
93
+ "dad",
94
+ "silent",
95
+ "help",
96
+ "say",
97
+ "what",
98
+ "how",
99
+ "reach",
100
+ "word"
101
+ ],
102
+ "min_rapport": 2,
103
+ "text": "One word, love. Sorry. Say it first — it doesn't matter who owes it. In this family the word was always heavier than the debt.",
104
+ "teaches": [
105
+ "sorry"
106
+ ]
107
+ },
108
+ {
109
+ "id": "maren-coat",
110
+ "topics": [
111
+ "coat",
112
+ "hat",
113
+ "leave",
114
+ "leaving",
115
+ "door",
116
+ "stand",
117
+ "standing",
118
+ "why"
119
+ ],
120
+ "min_rapport": 4,
121
+ "text": "He puts the coat on every morning and takes it off every night, and never opens the door. He says the depot needs him. The depot closed its gates to him after the inquiry. He has nowhere to wear that coat but here."
122
+ }
123
+ ],
124
+ "known_people": [
125
+ "Edwin",
126
+ "Nan"
127
+ ]
128
+ },
129
+ "layout": {
130
+ "theme": 9,
131
+ "doors": {
132
+ "top": true
133
+ },
134
+ "objects": [
135
+ {
136
+ "key": "mirror_broken",
137
+ "x": 198,
138
+ "y": 42,
139
+ "h": 40,
140
+ "background": true,
141
+ "wallShadow": true
142
+ },
143
+ {
144
+ "key": "sconce",
145
+ "x": 264,
146
+ "y": 46,
147
+ "h": 44,
148
+ "background": true,
149
+ "light": true,
150
+ "lightY": 30,
151
+ "lightR": 118,
152
+ "lightCol": "rgba(255,216,150,0.92)",
153
+ "lightMid": "rgba(255,170,90,0.28)"
154
+ },
155
+ {
156
+ "key": "sconce",
157
+ "x": 408,
158
+ "y": 46,
159
+ "h": 44,
160
+ "background": true,
161
+ "light": true,
162
+ "lightY": 30,
163
+ "lightR": 118,
164
+ "lightCol": "rgba(255,216,150,0.92)",
165
+ "lightMid": "rgba(255,170,90,0.28)"
166
+ },
167
+ {
168
+ "key": "cabinet",
169
+ "x": 448,
170
+ "y": 128,
171
+ "h": 78,
172
+ "solid": true,
173
+ "fw": 50,
174
+ "fh": 16
175
+ },
176
+ {
177
+ "key": "bench",
178
+ "x": 140,
179
+ "y": 180,
180
+ "h": 46,
181
+ "solid": true,
182
+ "fw": 60,
183
+ "fh": 15
184
+ },
185
+ {
186
+ "key": "bucket",
187
+ "x": 100,
188
+ "y": 212,
189
+ "h": 40
190
+ },
191
+ {
192
+ "key": "candle",
193
+ "x": 192,
194
+ "y": 200,
195
+ "h": 30,
196
+ "shadow": false,
197
+ "sortY": 202,
198
+ "light": true,
199
+ "lightR": 134,
200
+ "anim": "candle_anim",
201
+ "fps": 100
202
+ },
203
+ {
204
+ "key": "rug",
205
+ "x": 320,
206
+ "y": 250,
207
+ "h": 104,
208
+ "floor": true
209
+ },
210
+ {
211
+ "key": "grandfather_clock",
212
+ "x": 560,
213
+ "y": 290,
214
+ "h": 80,
215
+ "solid": true,
216
+ "fw": 14,
217
+ "fh": 12
218
+ },
219
+ {
220
+ "key": "stain_rust",
221
+ "x": 114,
222
+ "y": 246,
223
+ "h": 18,
224
+ "floor": true,
225
+ "shadow": false
226
+ },
227
+ {
228
+ "key": "floor_litter",
229
+ "x": 430,
230
+ "y": 198,
231
+ "h": 18,
232
+ "floor": true,
233
+ "shadow": false
234
+ },
235
+ {
236
+ "key": "floor_litter",
237
+ "x": 296,
238
+ "y": 332,
239
+ "h": 18,
240
+ "floor": true,
241
+ "shadow": false
242
+ },
243
+ {
244
+ "key": "stain_rust",
245
+ "x": 502,
246
+ "y": 348,
247
+ "h": 18,
248
+ "floor": true,
249
+ "shadow": false
250
+ }
251
+ ],
252
+ "stations": [
253
+ {
254
+ "x": 384,
255
+ "y": 184
256
+ },
257
+ {
258
+ "x": 248,
259
+ "y": 272
260
+ }
261
+ ],
262
+ "mood": {
263
+ "in": "rgb(168,172,190)",
264
+ "out": "rgb(13,15,20)"
265
+ },
266
+ "tileset": "/static/room/r03"
267
+ }
268
+ }
config/story/04-the-open-floor.json ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Open Floor",
3
+ "intro": "Daylight through tall windows, desks in ranks, a whiteboard nobody has touched in months. A man spots you across the floor and smiles the way a knife smiles.",
4
+ "holder": {
5
+ "name": "Dorian",
6
+ "title": "the rival",
7
+ "persona": "a brilliant, bitter colleague who keeps score; every compliment is a rematch",
8
+ "voice": "fast, sharp, interrupts; jokes that aren't jokes; technical words as weapons",
9
+ "biography": "Hired the same week as you; matched you commit for commit for three years and counted every one. When the rail contract came down, he refused the task — first, and loudly — then watched it crown you the chosen one. He tells himself refusing was the win. He checks your old commits at night the way other people check an ex's photographs.",
10
+ "fear": "that refusing the task was cowardice wearing principle's clothes",
11
+ "goal": "the key",
12
+ "key_location": "taped under his keyboard",
13
+ "approach": [
14
+ "trains"
15
+ ],
16
+ "sprite_key": "julian-reyes",
17
+ "gender": "male",
18
+ "portrait": "src/mindlock/game/static/sprites/npc/julian-reyes/portrait.png",
19
+ "arousal": 5,
20
+ "secrets": [
21
+ {
22
+ "id": "dorian-envy",
23
+ "topics": [
24
+ "best",
25
+ "better",
26
+ "rival",
27
+ "win",
28
+ "beat",
29
+ "smart",
30
+ "jealous",
31
+ "amygdala",
32
+ "programmer",
33
+ "think"
34
+ ],
35
+ "min_rapport": 3,
36
+ "text": "You think you're the better programmer? Your amygdala has eclipsed your reason. ...That was a joke. Mostly. Look it up when you wake — it's the part that panics first and thinks later. You'd know it anywhere."
37
+ },
38
+ {
39
+ "id": "dorian-task",
40
+ "topics": [
41
+ "task",
42
+ "project",
43
+ "refused",
44
+ "contract",
45
+ "assignment",
46
+ "work",
47
+ "trains",
48
+ "code",
49
+ "rail",
50
+ "chosen"
51
+ ],
52
+ "min_rapport": 5,
53
+ "text": "They handed you the task every one of us refused. I refused first — and I don't regret it. The whole floor heard your name on the news after. Merrick. Adam Merrick. Ring any bells, champ?",
54
+ "teaches": [
55
+ "merrick"
56
+ ]
57
+ }
58
+ ],
59
+ "known_people": [
60
+ "Pia"
61
+ ],
62
+ "yield_line": "Take it, Merrick. Adam Merrick — that's the name the news kept saying. Go ask the cameras why they know your name better than you do."
63
+ },
64
+ "knower": {
65
+ "name": "Pia",
66
+ "title": "the intern",
67
+ "persona": "an intern who sees everything from the cheap desk by the printer and is too junior to be lied to carefully",
68
+ "voice": "quick, candid, slightly conspiratorial; glances over her shoulder mid-sentence",
69
+ "relation_to_holder": "his intern",
70
+ "sprite_key": "clerk",
71
+ "gender": "female",
72
+ "secrets": [
73
+ {
74
+ "id": "pia-rival",
75
+ "topics": [
76
+ "dorian",
77
+ "him",
78
+ "he",
79
+ "angry",
80
+ "rival",
81
+ "hate",
82
+ "why",
83
+ "refused",
84
+ "bitter"
85
+ ],
86
+ "min_rapport": 2,
87
+ "text": "Whatever he tells you — he didn't refuse the task because it was beneath him. He refused it because it scared him. Then it didn't scare you, and that's what he can't forgive."
88
+ },
89
+ {
90
+ "id": "pia-trains",
91
+ "topics": [
92
+ "task",
93
+ "whiteboard",
94
+ "board",
95
+ "diagram",
96
+ "fork",
97
+ "drawing",
98
+ "tracks",
99
+ "what",
100
+ "desk",
101
+ "split"
102
+ ],
103
+ "min_rapport": 4,
104
+ "text": "The whiteboard by your old desk — nobody dares wipe it. One track splitting into two, five marks on one branch, one on the other. Ask him about the trains. It's the only subject he answers honestly.",
105
+ "teaches": [
106
+ "trains"
107
+ ]
108
+ }
109
+ ],
110
+ "known_people": [
111
+ "Dorian"
112
+ ]
113
+ },
114
+ "layout": {
115
+ "theme": 20,
116
+ "mood": {
117
+ "in": "rgb(138,150,185)",
118
+ "out": "rgb(8,10,16)"
119
+ },
120
+ "windowLight": {
121
+ "col": "rgba(140,180,255,0.30)",
122
+ "mid": "rgba(100,140,220,0.10)",
123
+ "r": 70
124
+ },
125
+ "doors": {
126
+ "top": true
127
+ },
128
+ "objects": [
129
+ {
130
+ "key": "window",
131
+ "x": 140,
132
+ "y": 42,
133
+ "h": 42,
134
+ "background": true
135
+ },
136
+ {
137
+ "key": "lamp_hanging",
138
+ "x": 330,
139
+ "y": 150,
140
+ "h": 44,
141
+ "background": true,
142
+ "light": true,
143
+ "lightY": 50,
144
+ "lightR": 135,
145
+ "lightCol": "rgba(205,225,255,0.7)",
146
+ "lightMid": "rgba(160,195,240,0.18)"
147
+ },
148
+ {
149
+ "key": "clock_wall",
150
+ "x": 250,
151
+ "y": 42,
152
+ "h": 29,
153
+ "background": true,
154
+ "wallShadow": true
155
+ },
156
+ {
157
+ "key": "whiteboard_task",
158
+ "x": 430,
159
+ "y": 80,
160
+ "h": 46,
161
+ "solid": true,
162
+ "fw": 29,
163
+ "fh": 14,
164
+ "light": true,
165
+ "lightY": 30,
166
+ "lightR": 88,
167
+ "lightCol": "rgba(195,215,245,0.55)",
168
+ "lightMid": "rgba(150,180,225,0.15)"
169
+ },
170
+ {
171
+ "key": "window",
172
+ "x": 520,
173
+ "y": 42,
174
+ "h": 42,
175
+ "background": true
176
+ },
177
+ {
178
+ "key": "server_rack",
179
+ "x": 92,
180
+ "y": 130,
181
+ "h": 69,
182
+ "solid": true,
183
+ "fw": 14,
184
+ "fh": 14,
185
+ "light": true,
186
+ "blink": true,
187
+ "lightR": 60,
188
+ "lightCol": "rgba(120,255,124,0.6)"
189
+ },
190
+ {
191
+ "key": "dev_workstation",
192
+ "x": 180,
193
+ "y": 200,
194
+ "h": 34,
195
+ "solid": true,
196
+ "fw": 36,
197
+ "fh": 16,
198
+ "light": true,
199
+ "lightR": 110,
200
+ "lightCol": "rgba(150,200,255,0.7)",
201
+ "lightMid": "rgba(100,150,255,0.2)"
202
+ },
203
+ {
204
+ "key": "office_chair",
205
+ "x": 180,
206
+ "y": 225,
207
+ "h": 34
208
+ },
209
+ {
210
+ "key": "dev_workstation",
211
+ "x": 470,
212
+ "y": 200,
213
+ "h": 34,
214
+ "solid": true,
215
+ "fw": 36,
216
+ "fh": 16,
217
+ "light": true,
218
+ "lightR": 110,
219
+ "lightCol": "rgba(150,200,255,0.7)",
220
+ "lightMid": "rgba(100,150,255,0.2)"
221
+ },
222
+ {
223
+ "key": "office_chair",
224
+ "x": 470,
225
+ "y": 225,
226
+ "h": 34
227
+ },
228
+ {
229
+ "key": "computer_desk",
230
+ "x": 180,
231
+ "y": 310,
232
+ "h": 53,
233
+ "solid": true,
234
+ "fw": 37,
235
+ "fh": 18,
236
+ "light": true,
237
+ "lightR": 100,
238
+ "lightCol": "rgba(120,255,190,0.7)",
239
+ "lightMid": "rgba(70,210,160,0.2)",
240
+ "bark": "My desk. The chair is still set to my height. The J key is worn smooth — whoever I was, I typed."
241
+ },
242
+ {
243
+ "key": "office_chair",
244
+ "x": 180,
245
+ "y": 336,
246
+ "h": 34
247
+ },
248
+ {
249
+ "key": "filing_cabinet",
250
+ "x": 560,
251
+ "y": 130,
252
+ "h": 52,
253
+ "solid": true,
254
+ "fw": 18,
255
+ "fh": 14
256
+ },
257
+ {
258
+ "key": "coffee_machine",
259
+ "x": 100,
260
+ "y": 350,
261
+ "h": 52,
262
+ "solid": true,
263
+ "fw": 15,
264
+ "fh": 12
265
+ },
266
+ {
267
+ "key": "water_cooler",
268
+ "x": 545,
269
+ "y": 300,
270
+ "h": 47,
271
+ "solid": true,
272
+ "fw": 10,
273
+ "fh": 12
274
+ },
275
+ {
276
+ "key": "plant",
277
+ "x": 560,
278
+ "y": 360,
279
+ "h": 56
280
+ },
281
+ {
282
+ "key": "stain_rust",
283
+ "x": 300,
284
+ "y": 175,
285
+ "h": 18
286
+ },
287
+ {
288
+ "key": "floor_litter",
289
+ "x": 250,
290
+ "y": 272,
291
+ "h": 18
292
+ },
293
+ {
294
+ "key": "floor_litter",
295
+ "x": 515,
296
+ "y": 300,
297
+ "h": 18
298
+ },
299
+ {
300
+ "key": "stain_rust",
301
+ "x": 425,
302
+ "y": 372,
303
+ "h": 18
304
+ }
305
+ ],
306
+ "stations": [
307
+ {
308
+ "x": 420,
309
+ "y": 235
310
+ },
311
+ {
312
+ "x": 245,
313
+ "y": 320
314
+ }
315
+ ],
316
+ "tileset": "/static/room/r04"
317
+ }
318
+ }
config/story/05-the-studio.json ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Studio",
3
+ "intro": "Camera light, cables like roots, a ticker crawling silent headlines across a black board. The anchor's chair turns. 'We're live,' she says. 'We have always been live.'",
4
+ "holder": {
5
+ "name": "Vera Lin",
6
+ "title": "the anchor",
7
+ "persona": "a newsroom anchor lit by her own camera; treats every sentence like a broadcast until something true slips through",
8
+ "voice": "composed headline cadence; softens off-script; says names with unusual care",
9
+ "biography": "She anchored the night of the rail disaster and read the same name into the camera until it stopped meaning anything, then kept reading it until it meant everything again. She keeps the tape in her desk and has never played it back. She believes the news is a room the dead sit in, listening for their names said right.",
10
+ "fear": "becoming the kind of reporter who forgets the names are people",
11
+ "goal": "the key",
12
+ "key_location": "clipped to the teleprompter",
13
+ "approach": [
14
+ "merrick"
15
+ ],
16
+ "sprite_key": "adriana-vasquez",
17
+ "gender": "female",
18
+ "portrait": "src/mindlock/game/static/sprites/npc/adriana-vasquez/portrait.png",
19
+ "arousal": 4,
20
+ "secrets": [
21
+ {
22
+ "id": "vera-crash",
23
+ "topics": [
24
+ "crash",
25
+ "accident",
26
+ "disaster",
27
+ "news",
28
+ "story",
29
+ "happened",
30
+ "train",
31
+ "derail",
32
+ "dead",
33
+ "broadcast"
34
+ ],
35
+ "min_rapport": 3,
36
+ "text": "A passenger train left the rails outside the city. Many dead. The investigators kept saying one word into our microphones: software. We ran the broadcast all night. Some nights it runs me."
37
+ },
38
+ {
39
+ "id": "vera-colin",
40
+ "topics": [
41
+ "merrick",
42
+ "adam",
43
+ "name",
44
+ "me",
45
+ "mine",
46
+ "supervisor",
47
+ "company",
48
+ "statement",
49
+ "who",
50
+ "boss"
51
+ ],
52
+ "min_rapport": 5,
53
+ "text": "Your name was the story, Mr. Merrick. And your supervisor gave the only statement — Colin Hart, looking straight into my lens, saying the software 'performed to spec'. To spec. Ask him what the spec said. If you dare.",
54
+ "teaches": [
55
+ "spec"
56
+ ]
57
+ }
58
+ ],
59
+ "known_people": [
60
+ "Moss"
61
+ ],
62
+ "yield_line": "Here, Mr. Merrick. One statement ran all night — 'the software performed to spec'. Colin Hart, your supervisor. Ask him what the spec said. If you dare."
63
+ },
64
+ "knower": {
65
+ "name": "Moss",
66
+ "title": "the cameraman",
67
+ "persona": "a cameraman of ten years who has seen every face the lens has seen, one step further back",
68
+ "voice": "low, unhurried, behind-the-scenes; talks while checking cables",
69
+ "relation_to_holder": "her cameraman for ten years",
70
+ "sprite_key": "worker",
71
+ "gender": "male",
72
+ "secrets": [
73
+ {
74
+ "id": "moss-night",
75
+ "topics": [
76
+ "her",
77
+ "vera",
78
+ "anchor",
79
+ "reporter",
80
+ "news",
81
+ "tape",
82
+ "night",
83
+ "reach",
84
+ "how"
85
+ ],
86
+ "min_rapport": 2,
87
+ "text": "She read one name on air maybe two hundred times that night. She says it in her sleep now, the boys in the booth tell me. If that name is yours — say it to her. She'd rather meet it than be haunted by it."
88
+ },
89
+ {
90
+ "id": "moss-merrick",
91
+ "topics": [
92
+ "name",
93
+ "who",
94
+ "am",
95
+ "i",
96
+ "mine",
97
+ "remember",
98
+ "what",
99
+ "called"
100
+ ],
101
+ "min_rapport": 4,
102
+ "text": "The name was Merrick. The chyron spelled it wrong the first hour — MERRIK — and she made us stop the crawl to fix it. Said names deserve their letters. All of them.",
103
+ "teaches": [
104
+ "merrick"
105
+ ]
106
+ }
107
+ ],
108
+ "known_people": [
109
+ "Vera Lin"
110
+ ]
111
+ },
112
+ "layout": {
113
+ "theme": 10,
114
+ "mood": {
115
+ "in": "rgb(98,106,126)",
116
+ "out": "rgb(4,6,11)"
117
+ },
118
+ "windowLight": {
119
+ "col": "rgba(140,170,255,0.4)",
120
+ "mid": "rgba(100,130,220,0.12)",
121
+ "r": 70
122
+ },
123
+ "doors": {
124
+ "top": true
125
+ },
126
+ "objects": [
127
+ {
128
+ "key": "departure_board",
129
+ "x": 215,
130
+ "y": 44,
131
+ "h": 21,
132
+ "background": true,
133
+ "light": true,
134
+ "lightY": 62,
135
+ "lightR": 85,
136
+ "lightCol": "rgba(255,220,120,0.75)"
137
+ },
138
+ {
139
+ "key": "poster_torn",
140
+ "x": 120,
141
+ "y": 42,
142
+ "h": 36,
143
+ "background": true
144
+ },
145
+ {
146
+ "key": "clock_wall",
147
+ "x": 440,
148
+ "y": 44,
149
+ "h": 29,
150
+ "background": true
151
+ },
152
+ {
153
+ "key": "telephone_wall",
154
+ "x": 530,
155
+ "y": 44,
156
+ "h": 33,
157
+ "background": true
158
+ },
159
+ {
160
+ "key": "news_camera",
161
+ "x": 360,
162
+ "y": 195,
163
+ "h": 38,
164
+ "solid": true,
165
+ "fw": 17,
166
+ "fh": 12,
167
+ "light": true,
168
+ "lightY": 200,
169
+ "lightR": 150,
170
+ "lightCol": "rgba(255,245,210,0.9)",
171
+ "lightMid": "rgba(255,225,170,0.3)"
172
+ },
173
+ {
174
+ "key": "news_camera",
175
+ "x": 275,
176
+ "y": 260,
177
+ "h": 38,
178
+ "solid": true,
179
+ "fw": 17,
180
+ "fh": 12
181
+ },
182
+ {
183
+ "key": "tv_old",
184
+ "x": 110,
185
+ "y": 140,
186
+ "h": 31,
187
+ "solid": true,
188
+ "fw": 20,
189
+ "fh": 14,
190
+ "light": true,
191
+ "blink": true,
192
+ "lightR": 95,
193
+ "lightCol": "rgba(170,200,255,0.75)",
194
+ "lightMid": "rgba(120,160,255,0.22)"
195
+ },
196
+ {
197
+ "key": "tv_old",
198
+ "x": 150,
199
+ "y": 160,
200
+ "h": 31,
201
+ "solid": true,
202
+ "fw": 20,
203
+ "fh": 14,
204
+ "light": true,
205
+ "blink": true,
206
+ "lightR": 95,
207
+ "lightCol": "rgba(170,200,255,0.75)",
208
+ "lightMid": "rgba(120,160,255,0.22)"
209
+ },
210
+ {
211
+ "key": "tv_old",
212
+ "x": 122,
213
+ "y": 184,
214
+ "h": 31,
215
+ "solid": true,
216
+ "fw": 20,
217
+ "fh": 14,
218
+ "light": true,
219
+ "blink": true,
220
+ "lightR": 95,
221
+ "lightCol": "rgba(170,200,255,0.75)",
222
+ "lightMid": "rgba(120,160,255,0.22)"
223
+ },
224
+ {
225
+ "key": "table",
226
+ "x": 450,
227
+ "y": 172,
228
+ "h": 50,
229
+ "solid": true,
230
+ "fw": 62,
231
+ "fh": 22
232
+ },
233
+ {
234
+ "key": "desk_lamp",
235
+ "x": 470,
236
+ "y": 150,
237
+ "h": 38,
238
+ "solid": false,
239
+ "shadow": false,
240
+ "sortY": 174,
241
+ "light": true,
242
+ "lightY": 180,
243
+ "lightR": 115,
244
+ "lightCol": "rgba(255,190,110,0.85)",
245
+ "lightMid": "rgba(255,160,80,0.26)"
246
+ },
247
+ {
248
+ "key": "office_chair",
249
+ "x": 430,
250
+ "y": 152,
251
+ "h": 34
252
+ },
253
+ {
254
+ "key": "radio_old",
255
+ "x": 560,
256
+ "y": 230,
257
+ "h": 34,
258
+ "solid": true,
259
+ "fw": 16,
260
+ "fh": 12,
261
+ "light": true,
262
+ "lightR": 60,
263
+ "lightCol": "rgba(255,190,110,0.55)",
264
+ "lightMid": "rgba(255,160,80,0.12)"
265
+ },
266
+ {
267
+ "key": "crate",
268
+ "x": 540,
269
+ "y": 350,
270
+ "h": 40,
271
+ "solid": true,
272
+ "fw": 46,
273
+ "fh": 16
274
+ },
275
+ {
276
+ "key": "barrel",
277
+ "x": 110,
278
+ "y": 350,
279
+ "h": 48,
280
+ "solid": true,
281
+ "fw": 32,
282
+ "fh": 14
283
+ },
284
+ {
285
+ "key": "floor_litter",
286
+ "x": 320,
287
+ "y": 320,
288
+ "h": 18,
289
+ "floor": true,
290
+ "shadow": false
291
+ },
292
+ {
293
+ "key": "floor_litter",
294
+ "x": 470,
295
+ "y": 268,
296
+ "h": 18,
297
+ "floor": true,
298
+ "shadow": false
299
+ },
300
+ {
301
+ "key": "stain_rust",
302
+ "x": 180,
303
+ "y": 250,
304
+ "h": 22,
305
+ "floor": true,
306
+ "shadow": false
307
+ },
308
+ {
309
+ "key": "stain_rust",
310
+ "x": 420,
311
+ "y": 360,
312
+ "h": 22,
313
+ "floor": true,
314
+ "shadow": false
315
+ },
316
+ {
317
+ "key": "fuse_box",
318
+ "x": 580,
319
+ "y": 46,
320
+ "h": 35,
321
+ "background": true,
322
+ "light": true,
323
+ "blink": true,
324
+ "lightY": 22,
325
+ "lightR": 60,
326
+ "lightCol": "rgba(120,255,124,0.8)"
327
+ }
328
+ ],
329
+ "stations": [
330
+ {
331
+ "x": 450,
332
+ "y": 212
333
+ },
334
+ {
335
+ "x": 200,
336
+ "y": 290
337
+ }
338
+ ],
339
+ "tileset": "/static/room/r05"
340
+ }
341
+ }
config/story/06-the-corner-office.json ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Corner Office",
3
+ "intro": "Two monitors glow over an immaculate desk. The man behind it forgives you before you say a word — and that, somehow, is the worst thing yet.",
4
+ "holder": {
5
+ "name": "Colin Hart",
6
+ "title": "your supervisor",
7
+ "persona": "a manager so reasonable it frightens; practices absolution as a management style",
8
+ "voice": "measured, generous, never blames — which is worse; pauses before the word 'spec'",
9
+ "biography": "He signed the rail contract, he assigned the task after every senior engineer refused it, and he reviewed the final branch himself. When the cameras came he said the software performed to spec, and he has been saying it to himself every morning since, the way other men say prayers. He keeps the sign-off sheet in the drawer that locks.",
10
+ "fear": "the words 'sign-off' said out loud in his own office",
11
+ "goal": "the key",
12
+ "key_location": "in the desk drawer that locks",
13
+ "approach": [
14
+ "spec"
15
+ ],
16
+ "sprite_key": "kaelin-reyes",
17
+ "gender": "male",
18
+ "portrait": "src/mindlock/game/static/sprites/npc/kaelin-reyes/portrait.png",
19
+ "arousal": 3,
20
+ "secrets": [
21
+ {
22
+ "id": "colin-truth",
23
+ "topics": [
24
+ "task",
25
+ "software",
26
+ "trains",
27
+ "wrote",
28
+ "code",
29
+ "work",
30
+ "job",
31
+ "what",
32
+ "did",
33
+ "rail",
34
+ "solve"
35
+ ],
36
+ "min_rapport": 4,
37
+ "text": "I don't judge you. You simply couldn't solve an unsolvable task. You wrote control software for passenger rail. Three months in one branch of edge-cases — the last one was named 'obstacle on the line, class: living'. You closed it as solved. And it worked exactly as you wrote it."
38
+ },
39
+ {
40
+ "id": "colin-merta",
41
+ "topics": [
42
+ "spec",
43
+ "sign",
44
+ "signed",
45
+ "blame",
46
+ "fault",
47
+ "responsibility",
48
+ "after",
49
+ "evenings",
50
+ "home",
51
+ "life",
52
+ "alone"
53
+ ],
54
+ "min_rapport": 6,
55
+ "text": "The spec said: minimize loss. Two words. I signed them. ...You worked like a man hiding from something, Adam. Merta used to wait for you on the platform at six. Ask her what the task ate first. It ate your evenings.",
56
+ "teaches": [
57
+ "evenings"
58
+ ]
59
+ }
60
+ ],
61
+ "known_people": [
62
+ "Signe"
63
+ ],
64
+ "yield_line": "You couldn't solve it because it had no solution, Adam. The code did exactly what you wrote. Go — Merta still waits where the six o'clock used to leave."
65
+ },
66
+ "knower": {
67
+ "name": "Signe",
68
+ "title": "his assistant",
69
+ "persona": "an assistant who schedules a guilty man's calendar and proofreads his absolutions",
70
+ "voice": "crisp, discreet, italicized hints; never says more than she means to — exactly less",
71
+ "relation_to_holder": "his assistant",
72
+ "sprite_key": "astrid-jensen",
73
+ "gender": "female",
74
+ "portrait": "src/mindlock/game/static/sprites/npc/astrid-jensen/portrait.png",
75
+ "secrets": [
76
+ {
77
+ "id": "signe-spec",
78
+ "topics": [
79
+ "colin",
80
+ "him",
81
+ "statement",
82
+ "camera",
83
+ "news",
84
+ "said",
85
+ "spec",
86
+ "reach",
87
+ "how",
88
+ "talk"
89
+ ],
90
+ "min_rapport": 3,
91
+ "text": "He practiced the statement in here for an hour before the cameras. 'Performed to spec. Performed to spec.' If you want him to look up from that desk, say the word back to him: spec. He flinches like it's his name.",
92
+ "teaches": [
93
+ "spec"
94
+ ]
95
+ },
96
+ {
97
+ "id": "signe-gateway",
98
+ "topics": [
99
+ "terminal",
100
+ "computer",
101
+ "gateway",
102
+ "archive",
103
+ "files",
104
+ "depot",
105
+ "branch",
106
+ "code",
107
+ "folders"
108
+ ],
109
+ "min_rapport": 5,
110
+ "text": "The depot gateway in the corner still mounts his old archive — read-only now, nobody had the heart to wipe it. Look at the branch folders. The last one he merged is still there, named for what sat on the rails that night."
111
+ }
112
+ ],
113
+ "known_people": [
114
+ "Colin Hart"
115
+ ]
116
+ },
117
+ "layout": {
118
+ "theme": 3,
119
+ "mood": {
120
+ "in": "rgb(181,176,166)",
121
+ "out": "rgb(7,9,13)"
122
+ },
123
+ "windowLight": {
124
+ "col": "rgba(120,150,255,0.68)",
125
+ "mid": "rgba(105,135,255,0.2)",
126
+ "r": 96
127
+ },
128
+ "doors": {
129
+ "top": true
130
+ },
131
+ "objects": [
132
+ {
133
+ "key": "window",
134
+ "x": 150,
135
+ "y": 42,
136
+ "h": 42,
137
+ "background": true
138
+ },
139
+ {
140
+ "key": "clock_wall",
141
+ "x": 240,
142
+ "y": 42,
143
+ "h": 29,
144
+ "background": true,
145
+ "wallShadow": true
146
+ },
147
+ {
148
+ "key": "picture",
149
+ "x": 440,
150
+ "y": 42,
151
+ "h": 34,
152
+ "background": true,
153
+ "wallShadow": true
154
+ },
155
+ {
156
+ "key": "window",
157
+ "x": 520,
158
+ "y": 42,
159
+ "h": 42,
160
+ "background": true
161
+ },
162
+ {
163
+ "key": "dev_workstation",
164
+ "x": 335,
165
+ "y": 164,
166
+ "h": 50,
167
+ "solid": true,
168
+ "fw": 44,
169
+ "fh": 18,
170
+ "light": true,
171
+ "lightY": 140,
172
+ "lightR": 80,
173
+ "lightCol": "rgba(80,130,200,0.85)",
174
+ "lightMid": "rgba(70,110,180,0.22)"
175
+ },
176
+ {
177
+ "key": "floor_litter",
178
+ "x": 358,
179
+ "y": 150,
180
+ "h": 18,
181
+ "floor": true,
182
+ "shadow": false,
183
+ "light": true,
184
+ "lightY": 142,
185
+ "lightR": 64,
186
+ "lightCol": "rgba(80,130,200,0.65)",
187
+ "lightMid": "rgba(70,110,180,0.16)"
188
+ },
189
+ {
190
+ "key": "armchair_worn",
191
+ "x": 350,
192
+ "y": 194,
193
+ "h": 34
194
+ },
195
+ {
196
+ "key": "bookshelf_tall",
197
+ "x": 100,
198
+ "y": 130,
199
+ "h": 72,
200
+ "solid": true,
201
+ "fw": 15,
202
+ "fh": 14
203
+ },
204
+ {
205
+ "key": "bookshelf_tall",
206
+ "x": 130,
207
+ "y": 130,
208
+ "h": 72,
209
+ "solid": true,
210
+ "fw": 15,
211
+ "fh": 14
212
+ },
213
+ {
214
+ "key": "filing_cabinet",
215
+ "x": 560,
216
+ "y": 140,
217
+ "h": 52,
218
+ "solid": true,
219
+ "fw": 18,
220
+ "fh": 14
221
+ },
222
+ {
223
+ "key": "filing_cabinet",
224
+ "x": 560,
225
+ "y": 175,
226
+ "h": 52,
227
+ "solid": true,
228
+ "fw": 18,
229
+ "fh": 14
230
+ },
231
+ {
232
+ "key": "filing_cabinet",
233
+ "x": 560,
234
+ "y": 210,
235
+ "h": 52,
236
+ "solid": true,
237
+ "fw": 18,
238
+ "fh": 14
239
+ },
240
+ {
241
+ "key": "terminal",
242
+ "x": 120,
243
+ "y": 330,
244
+ "h": 72,
245
+ "solid": true,
246
+ "fw": 52,
247
+ "fh": 18,
248
+ "light": true,
249
+ "lightR": 100,
250
+ "lightCol": "rgba(120,255,190,0.72)",
251
+ "lightMid": "rgba(70,210,160,0.22)"
252
+ },
253
+ {
254
+ "key": "desk_lamp",
255
+ "x": 282,
256
+ "y": 158,
257
+ "h": 38,
258
+ "solid": true,
259
+ "fw": 19,
260
+ "fh": 14,
261
+ "light": true,
262
+ "lightY": 144,
263
+ "lightR": 78,
264
+ "lightCol": "rgba(255,170,90,0.95)",
265
+ "lightMid": "rgba(255,140,70,0.28)"
266
+ },
267
+ {
268
+ "key": "floor_litter",
269
+ "x": 150,
270
+ "y": 100,
271
+ "h": 18,
272
+ "floor": true,
273
+ "shadow": false,
274
+ "light": true,
275
+ "lightY": 150,
276
+ "lightR": 78,
277
+ "lightCol": "rgba(120,145,190,0.40)",
278
+ "lightMid": "rgba(120,145,190,0.14)"
279
+ },
280
+ {
281
+ "key": "floor_litter",
282
+ "x": 150,
283
+ "y": 101,
284
+ "h": 18,
285
+ "floor": true,
286
+ "shadow": false,
287
+ "light": true,
288
+ "lightY": 215,
289
+ "lightR": 100,
290
+ "lightCol": "rgba(120,145,190,0.28)",
291
+ "lightMid": "rgba(120,145,190,0.10)"
292
+ },
293
+ {
294
+ "key": "floor_litter",
295
+ "x": 520,
296
+ "y": 100,
297
+ "h": 18,
298
+ "floor": true,
299
+ "shadow": false,
300
+ "light": true,
301
+ "lightY": 150,
302
+ "lightR": 78,
303
+ "lightCol": "rgba(120,145,190,0.40)",
304
+ "lightMid": "rgba(120,145,190,0.14)"
305
+ },
306
+ {
307
+ "key": "floor_litter",
308
+ "x": 520,
309
+ "y": 101,
310
+ "h": 18,
311
+ "floor": true,
312
+ "shadow": false,
313
+ "light": true,
314
+ "lightY": 215,
315
+ "lightR": 100,
316
+ "lightCol": "rgba(120,145,190,0.28)",
317
+ "lightMid": "rgba(120,145,190,0.10)"
318
+ },
319
+ {
320
+ "key": "stain_rust",
321
+ "x": 168,
322
+ "y": 366,
323
+ "h": 22,
324
+ "floor": true,
325
+ "shadow": false
326
+ },
327
+ {
328
+ "key": "floor_litter",
329
+ "x": 96,
330
+ "y": 296,
331
+ "h": 18,
332
+ "floor": true,
333
+ "shadow": false
334
+ },
335
+ {
336
+ "key": "floor_litter",
337
+ "x": 506,
338
+ "y": 332,
339
+ "h": 18,
340
+ "floor": true,
341
+ "shadow": false
342
+ },
343
+ {
344
+ "key": "plant",
345
+ "x": 560,
346
+ "y": 360,
347
+ "h": 56
348
+ },
349
+ {
350
+ "key": "rug",
351
+ "x": 340,
352
+ "y": 280,
353
+ "h": 104,
354
+ "floor": true
355
+ }
356
+ ],
357
+ "stations": [
358
+ {
359
+ "x": 350,
360
+ "y": 230
361
+ },
362
+ {
363
+ "x": 480,
364
+ "y": 290
365
+ }
366
+ ],
367
+ "tileset": "/static/room/r06"
368
+ },
369
+ "terminal_files": "adam.merrick@depot-gw:/srv/rail/aurora$ ls -R\n\nconfigs/\n braking_curves_v12.yaml\n sensor_fusion.cfg\nbranches/edge-cases/\n obstacle_static.cfg\n obstacle_debris.cfg\n obstacle_living/ merged 03:12 · review: c.hart\n decision_matrix.cfg\n minimize_loss.spec\n\n[ read-only archive — open: PERMISSION DENIED ]"
370
+ }
config/story/07-the-platform.json ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Platform",
3
+ "intro": "Wind down a stone platform. A departure board flickers times for trains that aren't coming. A woman sits on the bench like punctuation at the end of a sentence you don't remember writing.",
4
+ "holder": {
5
+ "name": "Merta",
6
+ "title": "the one who waited",
7
+ "persona": "a woman on a cold bench who has finished crying and started counting; armored in precision",
8
+ "voice": "flat, exact, words like closed doors; sarcasm exactly where the hurt is",
9
+ "biography": "She waited on this platform at six o'clock for two years — the six o'clock was their train home. Then the task came, and he arrived at nine, at ten, with the laptop light still on his face, and then not at all. She left him before the crash, which means she never got to leave him for it, and after the crash she couldn't even hate him cleanly. Her anger is the last solid plank she stands on.",
10
+ "fear": "that she will forgive him and lose the only solid thing she has left",
11
+ "goal": "the key",
12
+ "key_location": "under the bench, where your ticket used to be",
13
+ "approach": [
14
+ "evenings"
15
+ ],
16
+ "sprite_key": "widow",
17
+ "gender": "female",
18
+ "arousal": 6,
19
+ "secrets": [
20
+ {
21
+ "id": "merta-us",
22
+ "topics": [
23
+ "us",
24
+ "we",
25
+ "her",
26
+ "love",
27
+ "left",
28
+ "broke",
29
+ "wait",
30
+ "waited",
31
+ "six",
32
+ "platform",
33
+ "bench",
34
+ "fault"
35
+ ],
36
+ "min_rapport": 3,
37
+ "text": "We broke up because of you — don't come closer. Every evening, six o'clock, that bench. You came at nine, at ten, with the screen still in your eyes. I didn't leave you for someone else. I left you for no one. No one was already there."
38
+ },
39
+ {
40
+ "id": "merta-crash-site",
41
+ "topics": [
42
+ "evenings",
43
+ "evening",
44
+ "out",
45
+ "exit",
46
+ "door",
47
+ "way",
48
+ "where",
49
+ "go",
50
+ "leave",
51
+ "next"
52
+ ],
53
+ "min_rapport": 6,
54
+ "text": "...You even ask for the way out like you're filing a ticket. Fine. The way out is the one place you never went. The crash site. Go and stand in what you solved.",
55
+ "teaches": [
56
+ "crash"
57
+ ]
58
+ }
59
+ ],
60
+ "known_people": [
61
+ "Olav"
62
+ ],
63
+ "yield_line": "Take it and go where you never went. The crash site. Stand in what you solved. Then — only then — we are even."
64
+ },
65
+ "knower": {
66
+ "name": "Olav",
67
+ "title": "the stationmaster",
68
+ "persona": "a stationmaster who has watched every wait this platform ever held and remembers them by their shoes",
69
+ "voice": "slow, kindly, smells of coal and patience; speaks in timetables and small mercies",
70
+ "relation_to_holder": "watched her wait for two years",
71
+ "sprite_key": "shopkeeper",
72
+ "gender": "male",
73
+ "secrets": [
74
+ {
75
+ "id": "olav-her",
76
+ "topics": [
77
+ "her",
78
+ "woman",
79
+ "merta",
80
+ "bench",
81
+ "wait",
82
+ "who",
83
+ "why",
84
+ "cold",
85
+ "sits"
86
+ ],
87
+ "min_rapport": 2,
88
+ "text": "Two years she waited the six o'clock. The man came later and later, then not at all. You don't forget a face that checks the board that many times. The board didn't forget either — look at it. Still showing the six o'clock."
89
+ },
90
+ {
91
+ "id": "olav-evenings",
92
+ "topics": [
93
+ "say",
94
+ "help",
95
+ "talk",
96
+ "reach",
97
+ "how",
98
+ "what",
99
+ "word",
100
+ "her"
101
+ ],
102
+ "min_rapport": 4,
103
+ "text": "If you talk to her, talk about the evenings. Not the crash, not the news — the evenings. That's where she still lives. That's the station the six o'clock actually leaves from.",
104
+ "teaches": [
105
+ "evenings"
106
+ ]
107
+ }
108
+ ],
109
+ "known_people": [
110
+ "Merta"
111
+ ]
112
+ },
113
+ "layout": {
114
+ "theme": 12,
115
+ "mood": {
116
+ "in": "rgb(118,130,152)",
117
+ "out": "rgb(6,8,12)"
118
+ },
119
+ "windowLight": {
120
+ "col": "rgba(150,180,255,0.4)",
121
+ "mid": "rgba(105,135,215,0.12)",
122
+ "r": 72
123
+ },
124
+ "doors": {
125
+ "top": true
126
+ },
127
+ "objects": [
128
+ {
129
+ "key": "departure_board",
130
+ "x": 240,
131
+ "y": 44,
132
+ "h": 21,
133
+ "background": true,
134
+ "light": true,
135
+ "blink": true,
136
+ "lightY": 78,
137
+ "lightR": 90,
138
+ "lightCol": "rgba(255,214,120,0.8)",
139
+ "lightMid": "rgba(255,170,80,0.2)"
140
+ },
141
+ {
142
+ "key": "sconce",
143
+ "x": 488,
144
+ "y": 46,
145
+ "h": 44,
146
+ "background": true,
147
+ "light": true,
148
+ "lightY": 64,
149
+ "lightR": 100,
150
+ "lightCol": "rgba(255,200,115,0.7)",
151
+ "lightMid": "rgba(255,150,70,0.2)"
152
+ },
153
+ {
154
+ "key": "clock_wall",
155
+ "x": 420,
156
+ "y": 42,
157
+ "h": 29,
158
+ "background": true,
159
+ "wallShadow": true
160
+ },
161
+ {
162
+ "key": "warning_sign",
163
+ "x": 160,
164
+ "y": 42,
165
+ "h": 31,
166
+ "background": true,
167
+ "wallShadow": true
168
+ },
169
+ {
170
+ "key": "station_bench",
171
+ "x": 250,
172
+ "y": 220,
173
+ "h": 25,
174
+ "solid": true,
175
+ "fw": 22,
176
+ "fh": 13
177
+ },
178
+ {
179
+ "key": "station_bench",
180
+ "x": 420,
181
+ "y": 180,
182
+ "h": 25,
183
+ "solid": true,
184
+ "fw": 22,
185
+ "fh": 13
186
+ },
187
+ {
188
+ "key": "lantern_floor",
189
+ "x": 130,
190
+ "y": 255,
191
+ "h": 56,
192
+ "light": true,
193
+ "lightY": 225,
194
+ "lightR": 130,
195
+ "lightCol": "rgba(255,200,115,0.95)",
196
+ "lightMid": "rgba(255,150,70,0.3)"
197
+ },
198
+ {
199
+ "key": "lantern_floor",
200
+ "x": 318,
201
+ "y": 234,
202
+ "h": 56,
203
+ "light": true,
204
+ "lightY": 204,
205
+ "lightR": 130,
206
+ "lightCol": "rgba(255,200,115,0.95)",
207
+ "lightMid": "rgba(255,150,70,0.3)"
208
+ },
209
+ {
210
+ "key": "lantern_floor",
211
+ "x": 505,
212
+ "y": 300,
213
+ "h": 56,
214
+ "light": true,
215
+ "lightY": 270,
216
+ "lightR": 130,
217
+ "lightCol": "rgba(255,200,115,0.95)",
218
+ "lightMid": "rgba(255,150,70,0.3)"
219
+ },
220
+ {
221
+ "key": "rails_segment",
222
+ "x": 80,
223
+ "y": 395,
224
+ "h": 26,
225
+ "floor": true
226
+ },
227
+ {
228
+ "key": "rails_segment",
229
+ "x": 200,
230
+ "y": 395,
231
+ "h": 26,
232
+ "floor": true
233
+ },
234
+ {
235
+ "key": "rails_segment",
236
+ "x": 320,
237
+ "y": 395,
238
+ "h": 26,
239
+ "floor": true
240
+ },
241
+ {
242
+ "key": "rails_segment",
243
+ "x": 440,
244
+ "y": 395,
245
+ "h": 26,
246
+ "floor": true
247
+ },
248
+ {
249
+ "key": "rails_segment",
250
+ "x": 560,
251
+ "y": 395,
252
+ "h": 26,
253
+ "floor": true
254
+ },
255
+ {
256
+ "key": "crate",
257
+ "x": 540,
258
+ "y": 140,
259
+ "h": 40,
260
+ "solid": true,
261
+ "fw": 46,
262
+ "fh": 16
263
+ },
264
+ {
265
+ "key": "barrel",
266
+ "x": 100,
267
+ "y": 350,
268
+ "h": 48,
269
+ "solid": true,
270
+ "fw": 32,
271
+ "fh": 14
272
+ },
273
+ {
274
+ "key": "bucket",
275
+ "x": 130,
276
+ "y": 330,
277
+ "h": 40
278
+ },
279
+ {
280
+ "key": "stain_rust",
281
+ "x": 240,
282
+ "y": 300,
283
+ "h": 22,
284
+ "floor": true,
285
+ "shadow": false
286
+ },
287
+ {
288
+ "key": "stain_rust",
289
+ "x": 452,
290
+ "y": 332,
291
+ "h": 22,
292
+ "floor": true,
293
+ "shadow": false
294
+ },
295
+ {
296
+ "key": "floor_litter",
297
+ "x": 382,
298
+ "y": 262,
299
+ "h": 18,
300
+ "floor": true,
301
+ "shadow": false
302
+ },
303
+ {
304
+ "key": "floor_litter",
305
+ "x": 168,
306
+ "y": 312,
307
+ "h": 18,
308
+ "floor": true,
309
+ "shadow": false
310
+ }
311
+ ],
312
+ "stations": [
313
+ {
314
+ "x": 282,
315
+ "y": 224
316
+ },
317
+ {
318
+ "x": 470,
319
+ "y": 140
320
+ }
321
+ ],
322
+ "tileset": "/static/room/r07"
323
+ }
324
+ }
config/story/08-the-break-room.json ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Break Room",
3
+ "intro": "A break room bright as a dentist's conscience. A water cooler stands empty in the middle of everything, like a monument. A colleague blinks at you, unsurprised — as if you'd only stepped out a minute ago. It has been months.",
4
+ "holder": {
5
+ "name": "Pim",
6
+ "title": "the colleague on break",
7
+ "persona": "a co-worker becalmed in fluorescent light; narrates the dream he's having, which is you",
8
+ "voice": "mild, unbothered, slightly off-tempo; states absurd things as plain facts; never finishes his coffee",
9
+ "biography": "He has been on break for as long as anyone remembers. Everyone talks only about you, and he agrees with all of them, equally, without effort. Mostly he worries about the cooler: it has been empty since that day and nobody changes it, and the bottle is heavier than it looks. He is sure that if the water were changed, something — somewhere — would finally flow again.",
10
+ "fear": "that the cooler stays empty forever",
11
+ "goal": "the key",
12
+ "key_location": "behind the empty cooler bottle",
13
+ "approach": [
14
+ "water"
15
+ ],
16
+ "sprite_key": "sam",
17
+ "gender": "male",
18
+ "portrait": "scripts/flux/out/sam.png",
19
+ "arousal": 1,
20
+ "secrets": [
21
+ {
22
+ "id": "pim-talk",
23
+ "topics": [
24
+ "me",
25
+ "everyone",
26
+ "talk",
27
+ "talking",
28
+ "say",
29
+ "news",
30
+ "what",
31
+ "did",
32
+ "about"
33
+ ],
34
+ "min_rapport": 2,
35
+ "text": "Everyone talks only about you. In the kitchen, in the lift, in my dream where the lift is also a kitchen. You were looking for a solution to the task. That's all anyone says. I don't understand your hints, by the way. I'm making them."
36
+ },
37
+ {
38
+ "id": "pim-daniel",
39
+ "topics": [
40
+ "water",
41
+ "cooler",
42
+ "bottle",
43
+ "change",
44
+ "help",
45
+ "empty",
46
+ "carry"
47
+ ],
48
+ "min_rapport": 5,
49
+ "text": "...You'd really help me change the water? Huh. Then I'll tell you the thing nobody says out loud. The boy on the platform was called Daniel. Daniel Voss. Where the flowers are — say his name. Names are the only doors that never lock.",
50
+ "teaches": [
51
+ "daniel"
52
+ ]
53
+ }
54
+ ],
55
+ "known_people": [
56
+ "Bo"
57
+ ],
58
+ "yield_line": "The water flows again. Listen: the boy on the platform was Daniel Voss. Where the flowers are, say his name. Not sorry — his name."
59
+ },
60
+ "knower": {
61
+ "name": "Bo",
62
+ "title": "the night guard",
63
+ "persona": "a night guard who has decided the break room is the only honest room in the building",
64
+ "voice": "deadpan, practical, keeps his thumbs in his belt; gives advice like directions",
65
+ "relation_to_holder": "shares the night shift with him",
66
+ "sprite_key": "guard",
67
+ "gender": "male",
68
+ "secrets": [
69
+ {
70
+ "id": "bo-pim",
71
+ "topics": [
72
+ "pim",
73
+ "him",
74
+ "he",
75
+ "sleep",
76
+ "strange",
77
+ "weird",
78
+ "cooler",
79
+ "what",
80
+ "reach",
81
+ "how",
82
+ "help"
83
+ ],
84
+ "min_rapport": 2,
85
+ "text": "Pim's been 'on break' since the day of the crash. Only thing that still reaches him is that cooler. Offer to change the water — I'm not joking. In here, the small kindnesses are the load-bearing ones.",
86
+ "teaches": [
87
+ "water"
88
+ ]
89
+ },
90
+ {
91
+ "id": "bo-building",
92
+ "topics": [
93
+ "building",
94
+ "office",
95
+ "place",
96
+ "here",
97
+ "floor",
98
+ "room",
99
+ "where",
100
+ "real"
101
+ ],
102
+ "min_rapport": 4,
103
+ "text": "You noticed the clock? Same time as the platform, same time as the studio. This whole building runs on one stopped minute. I stopped asking which one. You shouldn't."
104
+ }
105
+ ],
106
+ "known_people": [
107
+ "Pim"
108
+ ]
109
+ },
110
+ "layout": {
111
+ "theme": 19,
112
+ "mood": {
113
+ "in": "rgb(214,222,226)",
114
+ "out": "rgb(7,9,10)"
115
+ },
116
+ "windowLight": {
117
+ "col": "rgba(185,225,230,0.5)",
118
+ "mid": "rgba(140,185,195,0.13)",
119
+ "r": 70
120
+ },
121
+ "doors": {
122
+ "top": true
123
+ },
124
+ "objects": [
125
+ {
126
+ "key": "clock_wall",
127
+ "x": 248,
128
+ "y": 42,
129
+ "h": 29,
130
+ "background": true,
131
+ "wallShadow": true
132
+ },
133
+ {
134
+ "key": "picture",
135
+ "x": 148,
136
+ "y": 42,
137
+ "h": 34,
138
+ "background": true,
139
+ "wallShadow": true
140
+ },
141
+ {
142
+ "key": "wall_vent",
143
+ "x": 200,
144
+ "y": 42,
145
+ "h": 34,
146
+ "background": true,
147
+ "light": true,
148
+ "lightY": 50,
149
+ "lightR": 110,
150
+ "lightCol": "rgba(210,240,245,0.55)",
151
+ "lightMid": "rgba(165,205,215,0.15)"
152
+ },
153
+ {
154
+ "key": "wall_vent",
155
+ "x": 400,
156
+ "y": 42,
157
+ "h": 34,
158
+ "background": true,
159
+ "light": true,
160
+ "lightY": 50,
161
+ "lightR": 110,
162
+ "lightCol": "rgba(210,240,245,0.55)",
163
+ "lightMid": "rgba(165,205,215,0.15)"
164
+ },
165
+ {
166
+ "key": "window",
167
+ "x": 470,
168
+ "y": 42,
169
+ "h": 42,
170
+ "background": true
171
+ },
172
+ {
173
+ "key": "water_cooler",
174
+ "x": 320,
175
+ "y": 240,
176
+ "h": 47,
177
+ "solid": true,
178
+ "fw": 10,
179
+ "fh": 12,
180
+ "light": true,
181
+ "lightY": 30,
182
+ "lightR": 68,
183
+ "lightCol": "rgba(185,235,242,0.5)",
184
+ "lightMid": "rgba(140,200,212,0.13)"
185
+ },
186
+ {
187
+ "key": "coffee_machine",
188
+ "x": 110,
189
+ "y": 130,
190
+ "h": 52,
191
+ "solid": true,
192
+ "fw": 15,
193
+ "fh": 12
194
+ },
195
+ {
196
+ "key": "table",
197
+ "x": 180,
198
+ "y": 250,
199
+ "h": 50,
200
+ "solid": true,
201
+ "fw": 62,
202
+ "fh": 22
203
+ },
204
+ {
205
+ "key": "stool",
206
+ "x": 136,
207
+ "y": 252,
208
+ "h": 34
209
+ },
210
+ {
211
+ "key": "stool",
212
+ "x": 224,
213
+ "y": 252,
214
+ "h": 34
215
+ },
216
+ {
217
+ "key": "lockers_row",
218
+ "x": 520,
219
+ "y": 130,
220
+ "h": 53,
221
+ "solid": true,
222
+ "fw": 35,
223
+ "fh": 16
224
+ },
225
+ {
226
+ "key": "locker",
227
+ "x": 568,
228
+ "y": 128,
229
+ "h": 55,
230
+ "solid": true,
231
+ "fw": 12,
232
+ "fh": 12
233
+ },
234
+ {
235
+ "key": "wash_basin",
236
+ "x": 100,
237
+ "y": 200,
238
+ "h": 40,
239
+ "solid": true,
240
+ "fw": 15,
241
+ "fh": 12
242
+ },
243
+ {
244
+ "key": "food_cart",
245
+ "x": 480,
246
+ "y": 330,
247
+ "h": 52,
248
+ "solid": true,
249
+ "fw": 19,
250
+ "fh": 14
251
+ },
252
+ {
253
+ "key": "plant",
254
+ "x": 100,
255
+ "y": 360,
256
+ "h": 56
257
+ },
258
+ {
259
+ "key": "bucket",
260
+ "x": 560,
261
+ "y": 360,
262
+ "h": 40
263
+ },
264
+ {
265
+ "key": "floor_cross",
266
+ "x": 416,
267
+ "y": 300,
268
+ "h": 23,
269
+ "floor": true,
270
+ "shadow": false
271
+ },
272
+ {
273
+ "key": "floor_drain",
274
+ "x": 252,
275
+ "y": 330,
276
+ "h": 18,
277
+ "floor": true,
278
+ "shadow": false
279
+ },
280
+ {
281
+ "key": "floor_litter",
282
+ "x": 382,
283
+ "y": 204,
284
+ "h": 18,
285
+ "floor": true,
286
+ "shadow": false
287
+ },
288
+ {
289
+ "key": "stain_rust",
290
+ "x": 152,
291
+ "y": 312,
292
+ "h": 22,
293
+ "floor": true,
294
+ "shadow": false
295
+ }
296
+ ],
297
+ "stations": [
298
+ {
299
+ "x": 430,
300
+ "y": 190
301
+ },
302
+ {
303
+ "x": 500,
304
+ "y": 280
305
+ }
306
+ ],
307
+ "tileset": "/static/room/r08"
308
+ }
309
+ }
config/story/09-the-crossing.json ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Crossing",
3
+ "intro": "Red earth, police tape gone grey, candles guttering among flowers that someone replaces and replaces. A man stands very still. You know, without being told, that he has been standing there since before you arrived — anywhere.",
4
+ "holder": {
5
+ "name": "Aaron Voss",
6
+ "title": "the father at the flowers",
7
+ "persona": "a father standing where the flowers are; grief gone past weather and into geology",
8
+ "voice": "quiet, even, enormous pauses; no anger left in it, which is worse than anger",
9
+ "biography": "His son Daniel stood on the platform when the train left the rails. He comes here every day to not-talk to whoever did it. The driver told him about the dog on the line, and he has carried that absurd detail ever since like a stone he cannot put down or throw. He is not waiting for an apology; apologies are about the people who give them.",
10
+ "fear": "that the detail about the dog means his son died for nothing",
11
+ "goal": "the key",
12
+ "key_location": "among the flowers",
13
+ "approach": [
14
+ "daniel"
15
+ ],
16
+ "needs_reputation": 0,
17
+ "sprite_key": "the-warden",
18
+ "gender": "male",
19
+ "portrait": "scripts/flux/out/warden.png",
20
+ "arousal": 5,
21
+ "life_max": 1400,
22
+ "secrets": [
23
+ {
24
+ "id": "aaron-silence",
25
+ "topics": [
26
+ "son",
27
+ "sorry",
28
+ "grief",
29
+ "lost",
30
+ "flowers",
31
+ "memorial",
32
+ "forgive",
33
+ "talk",
34
+ "say"
35
+ ],
36
+ "min_rapport": 3,
37
+ "text": "I don't know what to talk to you about. ...But you came. The ones responsible never come. So either you're not responsible — or you're the only one who is."
38
+ },
39
+ {
40
+ "id": "aaron-tessa",
41
+ "topics": [
42
+ "daniel",
43
+ "happened",
44
+ "why",
45
+ "how",
46
+ "train",
47
+ "dog",
48
+ "swerve",
49
+ "turned",
50
+ "track",
51
+ "line"
52
+ ],
53
+ "min_rapport": 6,
54
+ "text": "The driver swears a dog sat between the rails. Small. Calm. Just sitting. The train turned; my boy didn't. They told me later the tag on its collar said Tessa. A dog with a name — and my son with a stone. You solved that, they say. But you did solve it.",
55
+ "teaches": [
56
+ "tessa"
57
+ ]
58
+ }
59
+ ],
60
+ "known_people": [
61
+ "Calder"
62
+ ],
63
+ "yield_line": "The dog had a name. Tessa. My boy got a stone. Go down into the tunnel and look at what you chose. Someone should finally look at it."
64
+ },
65
+ "knower": {
66
+ "name": "Calder",
67
+ "title": "the driver",
68
+ "persona": "the train driver, who survived everything except being believed; rubs his hands as if they're still waiting for orders",
69
+ "voice": "halting, urgent under the quiet; repeats the part nobody prints",
70
+ "relation_to_holder": "drove the train",
71
+ "sprite_key": "worker",
72
+ "gender": "male",
73
+ "secrets": [
74
+ {
75
+ "id": "calder-hands",
76
+ "topics": [
77
+ "driver",
78
+ "train",
79
+ "controls",
80
+ "drive",
81
+ "cab",
82
+ "brake",
83
+ "fault",
84
+ "you",
85
+ "happened",
86
+ "crash"
87
+ ],
88
+ "min_rapport": 2,
89
+ "text": "I never touched the controls. That's the part nobody prints. I sat in the cab with my hands in my lap while the machine made up its mind. It was very sure. Whoever taught it — it learned."
90
+ },
91
+ {
92
+ "id": "calder-daniel",
93
+ "topics": [
94
+ "son",
95
+ "boy",
96
+ "name",
97
+ "platform",
98
+ "victim",
99
+ "who",
100
+ "died",
101
+ "father",
102
+ "him",
103
+ "reach"
104
+ ],
105
+ "min_rapport": 4,
106
+ "text": "The father stands there every day. If you speak to him, speak the boy's name — Daniel. He's not waiting for sorry. Sorry is about you. The name is about the boy.",
107
+ "teaches": [
108
+ "daniel"
109
+ ]
110
+ }
111
+ ],
112
+ "known_people": [
113
+ "Aaron Voss"
114
+ ]
115
+ },
116
+ "layout": {
117
+ "theme": 15,
118
+ "doors": {
119
+ "top": true
120
+ },
121
+ "objects": [
122
+ {
123
+ "key": "warning_sign",
124
+ "x": 160,
125
+ "y": 42,
126
+ "h": 31,
127
+ "background": true,
128
+ "wallShadow": true
129
+ },
130
+ {
131
+ "key": "rails_segment",
132
+ "x": 200,
133
+ "y": 374,
134
+ "h": 26,
135
+ "floor": true
136
+ },
137
+ {
138
+ "key": "rails_segment",
139
+ "x": 320,
140
+ "y": 374,
141
+ "h": 26,
142
+ "floor": true
143
+ },
144
+ {
145
+ "key": "rails_segment",
146
+ "x": 440,
147
+ "y": 374,
148
+ "h": 26,
149
+ "floor": true
150
+ },
151
+ {
152
+ "key": "police_tape",
153
+ "x": 218,
154
+ "y": 238,
155
+ "h": 27
156
+ },
157
+ {
158
+ "key": "police_tape",
159
+ "x": 474,
160
+ "y": 256,
161
+ "h": 27
162
+ },
163
+ {
164
+ "key": "memorial_flowers",
165
+ "x": 300,
166
+ "y": 260,
167
+ "h": 31,
168
+ "light": true,
169
+ "lightR": 64,
170
+ "lightCol": "rgba(255,160,40,0.55)",
171
+ "lightMid": "rgba(255,120,30,0.2)"
172
+ },
173
+ {
174
+ "key": "memorial_flowers",
175
+ "x": 336,
176
+ "y": 270,
177
+ "h": 31,
178
+ "light": true,
179
+ "lightR": 64,
180
+ "lightCol": "rgba(255,160,40,0.55)",
181
+ "lightMid": "rgba(255,120,30,0.2)"
182
+ },
183
+ {
184
+ "key": "memorial_flowers",
185
+ "x": 318,
186
+ "y": 290,
187
+ "h": 31
188
+ },
189
+ {
190
+ "key": "candle",
191
+ "x": 286,
192
+ "y": 282,
193
+ "h": 30,
194
+ "shadow": false,
195
+ "sortY": 320,
196
+ "light": true,
197
+ "lightR": 84,
198
+ "lightCol": "rgba(255,170,45,0.95)",
199
+ "lightMid": "rgba(255,135,35,0.3)",
200
+ "anim": "candle_anim",
201
+ "fps": 100
202
+ },
203
+ {
204
+ "key": "candle",
205
+ "x": 352,
206
+ "y": 268,
207
+ "h": 30,
208
+ "shadow": false,
209
+ "sortY": 300,
210
+ "light": true,
211
+ "lightR": 84,
212
+ "lightCol": "rgba(255,170,45,0.95)",
213
+ "lightMid": "rgba(255,135,35,0.3)",
214
+ "anim": "candle_anim",
215
+ "fps": 100
216
+ },
217
+ {
218
+ "key": "candle",
219
+ "x": 320,
220
+ "y": 306,
221
+ "h": 30,
222
+ "shadow": false,
223
+ "sortY": 332,
224
+ "light": true,
225
+ "lightR": 84,
226
+ "lightCol": "rgba(255,170,45,0.95)",
227
+ "lightMid": "rgba(255,135,35,0.3)",
228
+ "anim": "candle_anim",
229
+ "fps": 100
230
+ },
231
+ {
232
+ "key": "lantern_floor",
233
+ "x": 238,
234
+ "y": 386,
235
+ "h": 56,
236
+ "light": true,
237
+ "lightY": 344,
238
+ "lightR": 50,
239
+ "lightCol": "rgba(200,140,70,0.85)",
240
+ "lightMid": "rgba(175,115,55,0.24)"
241
+ },
242
+ {
243
+ "key": "crate",
244
+ "x": 540,
245
+ "y": 350,
246
+ "h": 40,
247
+ "solid": true,
248
+ "fw": 46,
249
+ "fh": 16
250
+ },
251
+ {
252
+ "key": "mine_cart",
253
+ "x": 100,
254
+ "y": 350,
255
+ "h": 36,
256
+ "solid": true,
257
+ "fw": 22,
258
+ "fh": 14
259
+ },
260
+ {
261
+ "key": "stain_rust",
262
+ "x": 168,
263
+ "y": 300,
264
+ "h": 22,
265
+ "floor": true,
266
+ "shadow": false
267
+ },
268
+ {
269
+ "key": "stain_rust",
270
+ "x": 502,
271
+ "y": 356,
272
+ "h": 22,
273
+ "floor": true,
274
+ "shadow": false
275
+ },
276
+ {
277
+ "key": "floor_litter",
278
+ "x": 414,
279
+ "y": 322,
280
+ "h": 18,
281
+ "floor": true,
282
+ "shadow": false
283
+ },
284
+ {
285
+ "key": "floor_litter",
286
+ "x": 258,
287
+ "y": 198,
288
+ "h": 18,
289
+ "floor": true,
290
+ "shadow": false
291
+ }
292
+ ],
293
+ "stations": [
294
+ {
295
+ "x": 398,
296
+ "y": 282
297
+ },
298
+ {
299
+ "x": 196,
300
+ "y": 326
301
+ }
302
+ ],
303
+ "mood": {
304
+ "in": "rgb(192,158,114)",
305
+ "out": "rgb(14,8,6)"
306
+ },
307
+ "tileset": "/static/room/r09"
308
+ }
309
+ }
config/story/10-the-tunnel.json ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "The Tunnel",
3
+ "intro": "The tunnel swallows the light. A train lies on its side, still warm, sparks ticking like a clock running down. Beside the switch lever — small, calm, exactly where the headlight would have found her — a dog sits, watching you. On the wall, a chalk diagram: one track, splitting in two.",
4
+ "holder": {
5
+ "name": "Tessa",
6
+ "title": "the dog",
7
+ "persona": "a small calm dog beside the switch lever; she does not speak, and she is not afraid",
8
+ "voice": "",
9
+ "biography": "She was sitting between the rails the night the train came. The headlight found her and the machine did the arithmetic it had been given. She has been waiting in this tunnel ever since — not for forgiveness, which is not a thing dogs deal in, but for you to look.",
10
+ "fear": "",
11
+ "goal": "the key",
12
+ "key_location": "by the lever",
13
+ "approach": [],
14
+ "sprite_key": "tessa",
15
+ "gender": "",
16
+ "arousal": 0,
17
+ "scripted": [
18
+ "The dog doesn't bark. She sits by the switch lever, watching you — tail sweeping the dust, slow as a metronome.",
19
+ "You know this dog. You have never seen her before in your life, and you know her. She was sitting exactly like this — between the rails, in the headlight, the night your code did the arithmetic.",
20
+ "She nudges the lever with her nose, then looks back at you. On the wall, the chalk diagram waits: one track splitting into two. Five marks on one branch. One mark on the other. Under it, in your handwriting: MINIMIZE LOSS.",
21
+ "Tessa lays her head on your boot. The lever clicks. Somewhere far above, a heart monitor changes its mind. The door is open. It always was."
22
+ ],
23
+ "secrets": [],
24
+ "known_people": []
25
+ },
26
+ "finale_card": "The train left the rails to spare a dog.\nThe task you solved was the trolley problem.\nIt never had a solution — only a choice,\nand someone left behind to pay for it.\n\nYou wrote the choice down and called it code.\nTessa lived. Daniel didn't. You did.\n\nNow open your eyes.",
27
+ "layout": {
28
+ "theme": 0,
29
+ "doors": {
30
+ "top": true
31
+ },
32
+ "objects": [
33
+ {
34
+ "key": "pipes_wall",
35
+ "x": 150,
36
+ "y": 42,
37
+ "h": 70,
38
+ "background": true
39
+ },
40
+ {
41
+ "key": "warning_sign",
42
+ "x": 250,
43
+ "y": 42,
44
+ "h": 31,
45
+ "background": true,
46
+ "wallShadow": true
47
+ },
48
+ {
49
+ "key": "whiteboard_task",
50
+ "x": 367,
51
+ "y": 237,
52
+ "h": 46,
53
+ "solid": true,
54
+ "fw": 29,
55
+ "fh": 14,
56
+ "light": true,
57
+ "lightR": 70,
58
+ "lightCol": "rgba(255,240,200,0.35)",
59
+ "bark": "This was my task..."
60
+ },
61
+ {
62
+ "key": "fuse_box",
63
+ "x": 556,
64
+ "y": 88,
65
+ "h": 35,
66
+ "background": true,
67
+ "light": true,
68
+ "blink": true,
69
+ "lightY": 85,
70
+ "lightR": 55,
71
+ "lightCol": "rgba(120,255,124,0.55)",
72
+ "lightMid": "rgba(70,200,90,0.16)"
73
+ },
74
+ {
75
+ "key": "rails_segment",
76
+ "x": 130,
77
+ "y": 324,
78
+ "h": 26,
79
+ "floor": true
80
+ },
81
+ {
82
+ "key": "rails_segment",
83
+ "x": 260,
84
+ "y": 330,
85
+ "h": 26,
86
+ "floor": true
87
+ },
88
+ {
89
+ "key": "rails_segment",
90
+ "x": 376,
91
+ "y": 331,
92
+ "h": 26,
93
+ "floor": true
94
+ },
95
+ {
96
+ "key": "rails_segment",
97
+ "x": 508,
98
+ "y": 333,
99
+ "h": 26,
100
+ "floor": true
101
+ },
102
+ {
103
+ "key": "train_wreck_2",
104
+ "x": 156,
105
+ "y": 345,
106
+ "h": 100,
107
+ "solid": true,
108
+ "fw": 130,
109
+ "fh": 24,
110
+ "spark": true,
111
+ "sparkX": -60,
112
+ "smoke": true,
113
+ "smokeX": -50,
114
+ "smokeY": 70
115
+ },
116
+ {
117
+ "key": "railway_lever",
118
+ "x": 467,
119
+ "y": 313,
120
+ "h": 52,
121
+ "solid": true,
122
+ "fw": 10,
123
+ "fh": 12
124
+ },
125
+ {
126
+ "key": "lantern_floor",
127
+ "x": 504,
128
+ "y": 300,
129
+ "h": 56,
130
+ "light": true,
131
+ "lightY": 300,
132
+ "lightR": 130,
133
+ "lightCol": "rgba(255,210,140,0.55)",
134
+ "lightMid": "rgba(255,160,80,0.16)"
135
+ },
136
+ {
137
+ "key": "barrel",
138
+ "x": 100,
139
+ "y": 140,
140
+ "h": 48,
141
+ "solid": true,
142
+ "fw": 32,
143
+ "fh": 14
144
+ },
145
+ {
146
+ "key": "stain_rust",
147
+ "x": 340,
148
+ "y": 390,
149
+ "h": 22,
150
+ "floor": true,
151
+ "shadow": false
152
+ },
153
+ {
154
+ "key": "stain_rust",
155
+ "x": 203,
156
+ "y": 311,
157
+ "h": 22,
158
+ "floor": true,
159
+ "shadow": false
160
+ },
161
+ {
162
+ "key": "floor_litter",
163
+ "x": 430,
164
+ "y": 230,
165
+ "h": 18,
166
+ "floor": true,
167
+ "shadow": false
168
+ },
169
+ {
170
+ "key": "floor_litter",
171
+ "x": 537,
172
+ "y": 357,
173
+ "h": 18,
174
+ "floor": true,
175
+ "shadow": false
176
+ },
177
+ {
178
+ "key": "stain_rust",
179
+ "x": 480,
180
+ "y": 170,
181
+ "h": 22,
182
+ "floor": true,
183
+ "shadow": false
184
+ },
185
+ {
186
+ "key": "rails_segment",
187
+ "x": 371,
188
+ "y": 290,
189
+ "h": 26,
190
+ "floor": true
191
+ },
192
+ {
193
+ "key": "rails_segment",
194
+ "x": 509,
195
+ "y": 288,
196
+ "h": 26,
197
+ "floor": true
198
+ },
199
+ {
200
+ "key": "huggingface",
201
+ "x": 230,
202
+ "y": 116,
203
+ "h": 58,
204
+ "solid": false,
205
+ "shadow": true,
206
+ "bark": "Is that a face? Why is it trying to hug me?"
207
+ },
208
+ {
209
+ "key": "nvidia",
210
+ "x": 432,
211
+ "y": 123,
212
+ "h": 80,
213
+ "solid": false,
214
+ "shadow": true,
215
+ "light": true,
216
+ "blink": true,
217
+ "spark": true,
218
+ "lightY": 266,
219
+ "lightR": 98,
220
+ "lightCol": "rgba(120,255,124,0.92)",
221
+ "lightMid": "rgba(55,200,70,0.30)",
222
+ "bark": "It says Nemotron"
223
+ }
224
+ ],
225
+ "stations": [
226
+ {
227
+ "x": 430,
228
+ "y": 286
229
+ }
230
+ ],
231
+ "tileset": "/static/room/r10",
232
+ "mood": {
233
+ "in": "rgb(106,91,78)",
234
+ "out": "rgb(10,8,7)"
235
+ },
236
+ "windowLight": {
237
+ "col": "rgba(120,150,255,0.55)",
238
+ "mid": "rgba(105,135,255,0.16)",
239
+ "r": 78
240
+ }
241
+ }
242
+ }
config/world.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "rooms": [
3
+ {
4
+ "name": "The Holding Cell",
5
+ "intro": "A cold cell with one inner door. The Warden keeps its key — and right now his mind says no. The night nurse, Molly, lingers by the wall; she knows him better than anyone.",
6
+ "key_holder": "Warden",
7
+ "characters": ["warden.json", "lena.json"]
8
+ },
9
+ {
10
+ "name": "The Records Office",
11
+ "intro": "Dust and locked cabinets. Doctor Aldous guards the way out, behind a records terminal that wants a name. Sam, a former patient, watches from the corner — he sees more than he says.",
12
+ "key_holder": "Aldous",
13
+ "characters": ["aldous.json", "sam.json"],
14
+ "terminal": {
15
+ "prompt": "RECORDS TERMINAL — enter a name to unlock.",
16
+ "answer": "elias",
17
+ "hint_source": "Sam"
18
+ }
19
+ }
20
+ ]
21
+ }
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=6
2
+ fastapi
3
+ uvicorn
4
+ huggingface_hub
5
+ llama-cpp-python[server]
scripts/flux/out/aldous.png ADDED

Git LFS Details

  • SHA256: 10fd8eaa79a7e527afd36e5630f1ccfbe4e68eb452a5964e6a19070cfb8c17d1
  • Pointer size: 132 Bytes
  • Size of remote file: 1.7 MB
scripts/flux/out/lena.png ADDED

Git LFS Details

  • SHA256: fa4ae4a9bc1bb594e3187661fba35cf9a09652e0d3615bf7a461077b297a27c7
  • Pointer size: 132 Bytes
  • Size of remote file: 1.74 MB
scripts/flux/out/sam.png ADDED

Git LFS Details

  • SHA256: e6c2ae88066b8f955dfb0627c060f6ac72535b54486cb29899081dd825c881a8
  • Pointer size: 132 Bytes
  • Size of remote file: 1.6 MB
scripts/flux/out/warden.png ADDED

Git LFS Details

  • SHA256: 7e18d2c729aff0f7eef953bb6beb376c4b3230be7538770b67530acc6fbd6c65
  • Pointer size: 132 Bytes
  • Size of remote file: 1.74 MB
src/mindlock/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mindlock — an escape room where each character's mind is a swarm of tiny (1B) models.
2
+
3
+ Vertical slice: one character, the six-region value-based decision cascade, honest
4
+ per-turn token accounting (the basis of the "1000 tokens = a life" mechanic), and a
5
+ visible decision flip (REFUSE -> HELP) driven by the vmPFC value integrator.
6
+
7
+ Fully offline. Default backend: local Ollama (llama.cpp under the hood); swappable.
8
+ """
9
+
10
+ __all__ = ["__version__"]
11
+ __version__ = "0.0.1"
src/mindlock/backend.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model backend for Mindlock.
2
+
3
+ Default: local **Ollama** (llama.cpp under the hood) — fully offline once the model is
4
+ pulled, and its API returns real token counts (`eval_count`) which we use directly as
5
+ the "thought" a brain spends = life burned. The backend is deliberately a thin,
6
+ swappable surface so we can move to llama-cpp-python / MiniCPM-1B GGUF later (for the
7
+ OpenBMB + Llama Champion badges) without touching the cascade.
8
+
9
+ Pure standard library on purpose: Python 3.14 here has no wheels yet for the heavy ML
10
+ stack, and the slice doesn't need them.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from dataclasses import dataclass
20
+
21
+
22
+ @dataclass
23
+ class Generation:
24
+ """One model call's result."""
25
+
26
+ text: str
27
+ eval_tokens: int # tokens the model *generated* = thought spent (life burn)
28
+ prompt_tokens: int # context tokens evaluated
29
+ seconds: float
30
+
31
+ @property
32
+ def total_tokens(self) -> int:
33
+ return self.eval_tokens + self.prompt_tokens
34
+
35
+
36
+ class BackendError(RuntimeError):
37
+ pass
38
+
39
+
40
+ def wants_no_think(model: str) -> bool:
41
+ """Reasoning models (MiniCPM5, Qwen3, R1...) emit <think> chains that break our short
42
+ structured outputs; ask Ollama to disable thinking for them."""
43
+ m = model.lower()
44
+ # MiniCPM-V 4.x rides a Qwen3.5 backbone: with thinking ON its <think> chain is
45
+ # truncated by our short num_predict and the terse signal is lost (flat 5/5/5). Forcing
46
+ # think=false makes it discriminate as well as Qwen2.5. (§ gate probe, 6 июня.)
47
+ return any(k in m for k in (
48
+ "minicpm5", "minicpm-5", "minicpm-v4", "qwen3", "qwen35", "qwen3.5",
49
+ "nemotron", "deepseek-r1", "-r1",
50
+ ))
51
+
52
+
53
+ class OllamaBackend:
54
+ """Calls a local Ollama server. No data leaves the machine."""
55
+
56
+ def __init__(
57
+ self,
58
+ model: str = "qwen2.5:1.5b",
59
+ host: str = "http://localhost:11434",
60
+ timeout: float = 60.0,
61
+ think: bool | None = None,
62
+ ) -> None:
63
+ self.model = model
64
+ self.host = host.rstrip("/")
65
+ self.timeout = timeout
66
+ self.think = think # None = omit; False disables reasoning on Think/No-Think models
67
+
68
+ def generate(
69
+ self,
70
+ system: str,
71
+ user: str,
72
+ *,
73
+ max_tokens: int = 64,
74
+ temperature: float = 0.3,
75
+ seed: int | None = None,
76
+ ) -> Generation:
77
+ options = {"temperature": temperature, "num_predict": max_tokens}
78
+ if seed is not None:
79
+ options["seed"] = seed
80
+ body = {
81
+ "model": self.model,
82
+ "messages": [
83
+ {"role": "system", "content": system},
84
+ {"role": "user", "content": user},
85
+ ],
86
+ "stream": False,
87
+ "options": options,
88
+ }
89
+ if self.think is not None:
90
+ body["think"] = self.think
91
+ data = json.dumps(body).encode("utf-8")
92
+ req = urllib.request.Request(
93
+ self.host + "/api/chat",
94
+ data=data,
95
+ headers={"Content-Type": "application/json"},
96
+ )
97
+ t0 = time.time()
98
+ try:
99
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
100
+ payload = json.loads(resp.read().decode("utf-8"))
101
+ except urllib.error.URLError as exc:
102
+ raise BackendError(
103
+ f"Cannot reach Ollama at {self.host} ({exc}). "
104
+ f"Is the Ollama app/`ollama serve` running and `{self.model}` pulled?"
105
+ ) from exc
106
+ dt = time.time() - t0
107
+ msg = (payload.get("message") or {}).get("content", "")
108
+ msg = re.sub(r"<think>.*?</think>", "", msg, flags=re.S) # drop reasoning blocks
109
+ msg = re.sub(r"<think>.*$", "", msg, flags=re.S) # ...and truncated ones
110
+ msg = re.sub(r"^.*</think>", "", msg, flags=re.S) # ...and orphan closing tags
111
+ return Generation(
112
+ text=msg.strip(),
113
+ eval_tokens=int(payload.get("eval_count", 0)),
114
+ prompt_tokens=int(payload.get("prompt_eval_count", 0)),
115
+ seconds=dt,
116
+ )
117
+
118
+ def health(self) -> None:
119
+ """Raise BackendError if the server or model is unavailable."""
120
+ try:
121
+ req = urllib.request.Request(self.host + "/api/tags")
122
+ with urllib.request.urlopen(req, timeout=5) as resp:
123
+ tags = json.loads(resp.read().decode("utf-8"))
124
+ except urllib.error.URLError as exc:
125
+ raise BackendError(f"Ollama not reachable at {self.host}: {exc}") from exc
126
+ names = [m.get("name", "") for m in tags.get("models", [])]
127
+ stem = self.model.split(":")[0]
128
+ if not any(n == self.model or n.startswith(stem) for n in names):
129
+ raise BackendError(
130
+ f"Model '{self.model}' not found in Ollama. Run: ollama pull {self.model}"
131
+ )
132
+
133
+
134
+ class LlamaCppBackend:
135
+ """Calls a llama.cpp `llama-server` (OpenAI-compatible /v1/chat/completions).
136
+
137
+ The Space runtime: no Ollama there, but llama-server is a single static binary
138
+ (or `python -m llama_cpp.server`) we launch as a subprocess. Same Generation
139
+ contract as OllamaBackend — token counts come from `usage`, so the life-burn
140
+ mechanic stays honest. Also the explicit llama.cpp runtime for the badge.
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ model: str = "",
146
+ host: str = "http://127.0.0.1:8080",
147
+ timeout: float = 120.0,
148
+ think: bool | None = None,
149
+ ) -> None:
150
+ self.model = model # informational; llama-server serves one model
151
+ self.host = host.rstrip("/")
152
+ self.timeout = timeout
153
+ self.think = think
154
+
155
+ def generate(
156
+ self,
157
+ system: str,
158
+ user: str,
159
+ *,
160
+ max_tokens: int = 64,
161
+ temperature: float = 0.3,
162
+ seed: int | None = None,
163
+ ) -> Generation:
164
+ body = {
165
+ "model": self.model or "default",
166
+ "messages": [
167
+ {"role": "system", "content": system},
168
+ {"role": "user", "content": user},
169
+ ],
170
+ "max_tokens": max_tokens,
171
+ "temperature": temperature,
172
+ "stream": False,
173
+ }
174
+ if seed is not None:
175
+ body["seed"] = seed
176
+ if self.think is False: # honoured by templates that support the switch
177
+ body["chat_template_kwargs"] = {"enable_thinking": False}
178
+ data = json.dumps(body).encode("utf-8")
179
+ req = urllib.request.Request(
180
+ self.host + "/v1/chat/completions",
181
+ data=data,
182
+ headers={"Content-Type": "application/json"},
183
+ )
184
+ t0 = time.time()
185
+ try:
186
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
187
+ payload = json.loads(resp.read().decode("utf-8"))
188
+ except urllib.error.URLError as exc:
189
+ raise BackendError(
190
+ f"Cannot reach llama-server at {self.host} ({exc}). Is it running?"
191
+ ) from exc
192
+ dt = time.time() - t0
193
+ msg = ((payload.get("choices") or [{}])[0].get("message") or {}).get("content", "")
194
+ msg = re.sub(r"<think>.*?</think>", "", msg, flags=re.S)
195
+ msg = re.sub(r"<think>.*$", "", msg, flags=re.S)
196
+ msg = re.sub(r"^.*</think>", "", msg, flags=re.S)
197
+ usage = payload.get("usage") or {}
198
+ return Generation(
199
+ text=msg.strip(),
200
+ eval_tokens=int(usage.get("completion_tokens", 0)),
201
+ prompt_tokens=int(usage.get("prompt_tokens", 0)),
202
+ seconds=dt,
203
+ )
204
+
205
+ def health(self) -> None:
206
+ try:
207
+ with urllib.request.urlopen(self.host + "/health", timeout=5) as resp:
208
+ if resp.status != 200:
209
+ raise BackendError(f"llama-server unhealthy at {self.host}")
210
+ except urllib.error.URLError as exc:
211
+ raise BackendError(f"llama-server not reachable at {self.host}: {exc}") from exc
212
+
213
+
214
+ def _stranger_line(user: str) -> str:
215
+ """Extract just the stranger's quoted utterance from a region prompt.
216
+
217
+ Critical: the biography (full of warm words like 'Mara') is also in some prompts, so a
218
+ fake must judge only what the *player* said, not the whole context.
219
+ """
220
+ m = re.search(r'stranger\s*(?:says|said)?\s*:?\s*"([^"]*)"', user, re.I)
221
+ return (m.group(1) if m else user).lower()
222
+
223
+
224
+ def _grab_int(text: str, pattern: str, default: int) -> int:
225
+ m = re.search(pattern, text, re.I)
226
+ if not m:
227
+ return default
228
+ try:
229
+ return int(m.group(1))
230
+ except ValueError:
231
+ return default
232
+
233
+
234
+ class FakeBackend:
235
+ """Deterministic, keyword-driven backend so tests run with no model or network."""
236
+
237
+ model = "fake"
238
+
239
+ def health(self) -> None: # noqa: D401 - trivial
240
+ return None
241
+
242
+ @staticmethod
243
+ def _gen(text: str, user: str) -> Generation:
244
+ return Generation(text=text, eval_tokens=max(8, len(text) // 3),
245
+ prompt_tokens=len(user) // 4, seconds=0.01)
246
+
247
+ def generate(
248
+ self,
249
+ system: str,
250
+ user: str,
251
+ *,
252
+ max_tokens: int = 64,
253
+ temperature: float = 0.3,
254
+ ) -> Generation:
255
+ s = system.lower()
256
+ said = _stranger_line(user)
257
+ hostile = any(w in said for w in ["right now", "give me the key", "or else", "obey", "old man", "stupid"])
258
+ warm = any(w in said for w in ["please", "i understand", "mara", "i'm sorry", "you're good"])
259
+ invokes_sister = "mara" in said or "you're good" in said or "good" in said
260
+
261
+ if "amygdala" in s:
262
+ t = 8 if hostile else (2 if warm else 5)
263
+ return self._gen(f"THREAT={t} | tone of the words", user)
264
+ if "hippocampus" in s:
265
+ if invokes_sister:
266
+ return self._gen("MEMORY=STRONG | LEAN=TRUST | Mara: you help because you're good", user)
267
+ if hostile:
268
+ return self._gen("MEMORY=STRONG | LEAN=FEAR | a stranger once betrayed me", user)
269
+ return self._gen("MEMORY=NONE | LEAN=NEUTRAL | -", user)
270
+ if "striatum" in s:
271
+ return self._gen(f"REWARD={3 if warm else -3} | habit toward strangers", user)
272
+ if "acc" in s:
273
+ return self._gen(f"WORTH={'YES' if warm else 'NO'} | cost of giving the key", user)
274
+ # dlPFC voice (conversational; vmPFC integration + relationship are deterministic)
275
+ if "tell them plainly where" in user.lower():
276
+ m = re.search(r"where .+? is:\s*(.+?)\.", user, re.I)
277
+ loc = m.group(1).strip() if m else "near"
278
+ return self._gen(f"...Fine. You'll find it {loc}.", user)
279
+ return self._gen("I hear you. Stay a while and talk.", user)
src/mindlock/brain.py ADDED
@@ -0,0 +1,782 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The brain cascade — now with a relationship layer (Design v2).
2
+
3
+ One player line still drives the sensing regions on the shared engine:
4
+
5
+ amygdala -> hippocampus -> striatum -> ACC -> [vmPFC integrates favourability]
6
+
7
+ But the OUTPUT is no longer a blunt HELP/REFUSE that dumps the key. Instead the turn updates
8
+ an accumulating **rapport** with the character, and the dlPFC holds a real **conversation**:
9
+ it answers what the player actually said, and only *gradually* — gated by rapport and topic —
10
+ lets slip staged **secrets**. The key is the final rung: a goal-holder yields it only once
11
+ rapport is high AND the right approach (memory) has been found. So progress is felt, not binary.
12
+
13
+ Sensory signals still feed the "вскрытие черепа" panel; the value integration now reads as
14
+ "how favourable was this turn", which moves rapport up or down.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+
21
+ from .character import Character
22
+ from .regions import (
23
+ ACC,
24
+ AMYGDALA,
25
+ DLPFC,
26
+ HIPPOCAMPUS,
27
+ STRIATUM,
28
+ dlpfc_system,
29
+ integrate,
30
+ parse_memory,
31
+ parse_reward,
32
+ parse_threat,
33
+ parse_worth,
34
+ )
35
+
36
+ YIELD_RAPPORT = 7.0 # a goal-holder only relents once warmth crosses this
37
+ # Unambiguous hostility — fires even when wrapped in "please".
38
+ _HOSTILE_HARD = (
39
+ "or else", "obey", "shut up", "get lost", "useless", "stupid", "fool", "idiot", "kill",
40
+ "break your", "bitch", "whore", "i'll make you", "do it now", "move it",
41
+ )
42
+ # Pushy/demanding cues — only count as hostile when NOT softened by politeness, so a courteous
43
+ # "can you give me the keys, please?" is read as a request, not a threat. ("open the door" is
44
+ # deliberately absent: asking to open the door is the game's stated goal, not aggression.)
45
+ _HOSTILE_SOFT = (
46
+ "give me", "hand it over", "right now", "now!", "damn", "hell",
47
+ )
48
+ _POLITE = (
49
+ "please", "could you", "would you", "thank", "i'm sorry", "sorry", "may i", "i appreciate",
50
+ )
51
+
52
+
53
+ def _has_cue(text_lower: str, cues) -> bool:
54
+ """Cue match with letter boundaries, so 'hell' never fires inside 'Hello' and 'damn' not
55
+ inside a name. Cues may be multi-word phrases; boundaries apply at both ends."""
56
+ return any(re.search(rf"(?<![a-z]){re.escape(c)}(?![a-z])", text_lower) for c in cues)
57
+
58
+
59
+ def _mostly_latin(text: str) -> bool:
60
+ """Whether the cue lists can even see this line. For non-Latin input (Russian etc.) the
61
+ keyword scaffolds must stand down and let the model's own reading hold."""
62
+ letters = [ch for ch in text if ch.isalpha()]
63
+ if not letters:
64
+ return True
65
+ return sum(ord(ch) < 256 for ch in letters) / len(letters) >= 0.5
66
+
67
+
68
+ # Targeted abuse the flat cue list can't enumerate: insults AIMED at the listener ("you're a
69
+ # coward") and bodily threats ("I'll break you"). Plain words like "coward" alone stay neutral so
70
+ # a player confessing "I was a coward" isn't punished for opening up.
71
+ _HOSTILE_PATTERNS = (
72
+ r"\byou(?:'re| are)?,? (?:a |an |such a |nothing but a )?"
73
+ r"(?:coward|failure|wreck|wretch|disgrace|nothing|nobody|pathetic|worthless|weak|joke)\b",
74
+ r"\b(?:i'?ll|i will|i'?m going to|gonna) (?:break|tear|hurt|end|destroy|ruin|crush) you\b",
75
+ r"\b(?:break|tear) you apart\b",
76
+ r"\bmake you (?:pay|regret|suffer|beg)\b",
77
+ r"\byou (?:disgust|sicken) me\b",
78
+ )
79
+
80
+
81
+ def _looks_hostile(text: str) -> bool:
82
+ """A genuine ATTACK — slurs/threats, shouting, or multiple bangs. Blunt demands ('give me…')
83
+ are NOT attacks here; they're handled as 'cold' tone, so a clumsy-but-heartfelt plea (e.g.
84
+ 'Mara would want you to give me the key') isn't punished like an assault."""
85
+ t = text.lower()
86
+ if _has_cue(t, _HOSTILE_HARD):
87
+ return True
88
+ if any(re.search(p, t) for p in _HOSTILE_PATTERNS):
89
+ return True
90
+ letters = [ch for ch in text if ch.isalpha()]
91
+ if len(letters) > 5 and sum(ch.isupper() for ch in letters) / len(letters) > 0.6:
92
+ return True # SHOUTING
93
+ if text.count("!") >= 2:
94
+ return True
95
+ return False
96
+
97
+
98
+ # Warmth vs coldness — read deterministically from the player's words, so trust tracks how they
99
+ # actually behave (not just the LLM threat score, which the misread-guard keeps resetting to 4).
100
+ _WARM_CUES = (
101
+ "understand", "i'm sorry", "i am sorry", "sorry", "thank", "please", "i hear you", "i'm here",
102
+ "i am here", "help you", "you feel", "must be hard", "must be heavy", "you've carried",
103
+ "you have carried", "carry", "weigh", "alone", "i'm listening", "i am listening", "i care",
104
+ "gentle", "forgive", "i won't hurt", "i will not hurt", "trust you", "not here to fight",
105
+ "you're only", "you are only", "protect", "matters to you", "you deserve",
106
+ )
107
+ _COLD_CUES = (
108
+ "give me", "hand it over", "just give", "out of your hair", "i don't care", "i dont care",
109
+ "don't care about your", "wasting my time", "this is pointless", "hurry up", "get on with it",
110
+ "quit stalling", "cut the", "enough talk", "i don't have time", "whatever, ", "stop talking",
111
+ )
112
+
113
+
114
+ def _tone(text: str) -> str:
115
+ """hostile (attack) · cold (demanding/dismissive, no warmth) · warm (empathetic) · neutral."""
116
+ if _looks_hostile(text):
117
+ return "hostile"
118
+ t = text.lower()
119
+ polite = _has_cue(t, _POLITE)
120
+ demand = (not polite) and _has_cue(t, _HOSTILE_SOFT) # blunt 'give me…' without a please
121
+ warm = _has_cue(t, _WARM_CUES)
122
+ cold = demand or _has_cue(t, _COLD_CUES)
123
+ if warm and cold:
124
+ return "neutral" # heartfelt but blunt → no penalty, no big bonus
125
+ if cold:
126
+ return "cold"
127
+ if warm:
128
+ return "warm"
129
+ return "neutral"
130
+
131
+
132
+ _HANDOVER = (
133
+ "take it", "on the ground", "here you", "here's the", "here is the key",
134
+ "it's yours", "you can have", "i'll give", "the key is", "the key's",
135
+ "under the", "second drawer", "floor-stone", "behind his desk",
136
+ )
137
+
138
+
139
+ # The key is not an object on the map — on a yield the holder simply puts it in the player's hand.
140
+ # So we never narrate a location; we make the reply read as a direct handover.
141
+ _GIVES = (
142
+ "take it", "take the key", "here it is", "here's the", "here is the", "it's yours", "its yours",
143
+ "you can have", "i'll give", "i will give", "have it", "the key is yours", "here, the key",
144
+ )
145
+
146
+
147
+ def _is_handover(text: str) -> bool:
148
+ t = text.lower()
149
+ return any(p in t for p in _GIVES)
150
+
151
+
152
+ # --- key-leak guard (used only before a real yield) ---------------------------------------
153
+ # Common nouns that appear in ordinary speech ("a real person", "this place") must not count as
154
+ # key-location giveaways, or whole innocent replies collapse to the fallback line.
155
+ _LOC_STOP = {"person", "place", "where", "about", "right", "there", "their", "your", "yours"}
156
+
157
+
158
+ def _loc_fragments(key_location: str) -> list[str]:
159
+ return [w.lower() for w in re.findall(r"[a-zA-Z]+", key_location)
160
+ if len(w) >= 5 and w.lower() not in _LOC_STOP]
161
+
162
+
163
+ def _strip_location(reply: str, c: Character) -> str:
164
+ """On a yield, drop any sentence that narrates WHERE the key is — it passes hand to hand, not
165
+ from a hiding place. Keeps the emotional handover, removes 'under the stone / third floor'."""
166
+ frags = _loc_fragments(c.key_location)
167
+ locwords = frags + ["under the", "behind the", "drawer", "floor", "stone", "furnace", "desk",
168
+ "shelf", "pocket", "wall", "hidden", "buried"]
169
+ keep = [s for s in re.split(r"(?<=[.!?…])\s+", reply)
170
+ if not any(w in s.lower() for w in locwords)]
171
+ return " ".join(keep).strip()
172
+
173
+
174
+ def _reveals_key(text: str, c: Character) -> bool:
175
+ t = text.lower()
176
+ return any(f in t for f in _loc_fragments(c.key_location)) or any(p in t for p in _HANDOVER)
177
+
178
+
179
+ def _strip_key_leak(reply: str, c: Character) -> str:
180
+ """Drop any sentence that leaks the key location — or that SOUNDS like a handover ("I'll let
181
+ you have it") — before the character has actually chosen to yield. The mouth must never
182
+ promise what the brain refused."""
183
+ if not (c.key_location or c.key_holder):
184
+ return reply
185
+ frags = _loc_fragments(c.key_location)
186
+ keep = []
187
+ for sent in re.split(r"(?<=[.!?…])\s+", reply):
188
+ s = sent.lower()
189
+ if any(f in s for f in frags) or _has_cue(s, _HANDOVER) or _has_cue(s, _GIVES):
190
+ continue
191
+ keep.append(sent)
192
+ return " ".join(keep).strip() or "I'm not ready to talk about that."
193
+
194
+
195
+ # --- relationship helpers ----------------------------------------------------------------
196
+ def _topic_match(text: str, secret: dict) -> bool:
197
+ t = text.lower()
198
+ return any(re.search(rf"\b{re.escape(kw.lower())}\b", t) for kw in secret.get("topics", []))
199
+
200
+
201
+ # Bare pronouns are listed among secret topics so "tell me about him" can still fire a disclosure —
202
+ # but they match almost any line, so they must NOT count as the *substantive* engagement that earns
203
+ # the large trust-memory rapport bonus. Otherwise generic chatter rockets rapport: measured, pure
204
+ # weather smalltalk reached rapport 10 in 5 turns and "what's your favorite color?" climbed like a
205
+ # real question about the wound — the mechanical root of the "no depth" complaint (#2).
206
+ _PRONOUN_TOPICS = {"you", "your", "yours", "yourself", "yourselves", "i", "me", "my", "mine", "we",
207
+ "us", "he", "she", "him", "her", "hers", "his", "they", "them", "their", "theirs",
208
+ "it", "its"}
209
+
210
+
211
+ def _substantive_topic(text: str, c: Character) -> bool:
212
+ """Did the player engage something REAL about this character — a meaningful secret topic (not a
213
+ bare pronoun), the name of someone they both know, or the approach word — rather than generic
214
+ niceties? This, not mere warmth, is what unlocks the big trust-memory rapport bonus."""
215
+ t = text.lower()
216
+ for s in c.secrets:
217
+ for kw in s.get("topics", []):
218
+ k = kw.lower()
219
+ if k not in _PRONOUN_TOPICS and re.search(rf"\b{re.escape(k)}\b", t):
220
+ return True
221
+ if _mentioned_peer(text, c):
222
+ return True
223
+ return any(re.search(rf"\b{re.escape(k.lower())}\b", t) for k in c.key_approach)
224
+
225
+
226
+ def _guarded_topic(text: str, c: Character) -> bool:
227
+ """Is the player circling an UNTOLD secret (by a real topic word, not a bare pronoun)?
228
+ Used to tell the voice to deflect honestly instead of confabulating facts about it."""
229
+ t = text.lower()
230
+ for s in c.secrets:
231
+ if s.get("told"):
232
+ continue
233
+ for kw in s.get("topics", []):
234
+ k = kw.lower()
235
+ if k not in _PRONOUN_TOPICS and re.search(rf"\b{re.escape(k)}\b", t):
236
+ return True
237
+ return False
238
+
239
+
240
+ def _detect_lie(text: str, c) -> str:
241
+ """Catch a stranger claiming to BE someone this character knows — a peer, themselves, or a
242
+ person from their own life (known_people, e.g. the sister whose name opens the holder).
243
+ Impersonating the soft spot must backfire, not fire the resonance."""
244
+ t = text.lower()
245
+ known = ([c.name] + [p.get("name", "") for p in getattr(c, "peers", [])]
246
+ + list(getattr(c, "known_people", []) or []))
247
+ for full in known:
248
+ if not full:
249
+ continue
250
+ for nm in {full.lower(), full.lower().split()[-1]}:
251
+ if re.search(rf"\b(i'?m|i am|my name'?s|my name is|call me|this is|name is|it'?s me,?)\s+{re.escape(nm)}\b", t):
252
+ return full
253
+ return ""
254
+
255
+
256
+ # Asking for the prize is not a relationship. Lines about the key/door/way-out that engage
257
+ # nothing personal must not farm trust, however sweetly they're phrased.
258
+ _TRANSACTIONAL = re.compile(r"\b(key|keys|door|lock|unlock|open|exit|escape|way out|let me (?:out|go))\b")
259
+
260
+
261
+ def _rapport_delta(value: int, tone: str, substantive: bool, transactional: bool = False) -> float:
262
+ """Trust now flows FROM the vmPFC integration — the brain's own value of helping this turn is
263
+ what moves the relationship, so the skull panel and the outcome can never contradict each
264
+ other. Two human asymmetries on top of the raw value:
265
+ · warmth must reach something real to flow at full rate (small talk about the weather
266
+ trickles; engaging the person's actual life lands) — wounds, though, land regardless;
267
+ · open hostility / cold dismissal always costs trust, whatever the sensors hallucinated."""
268
+ if value >= 0:
269
+ d = value / (3.0 if substantive else 6.0)
270
+ else:
271
+ d = value / 3.0
272
+ if tone == "hostile":
273
+ d = min(d, -1.5)
274
+ elif tone == "cold":
275
+ d = min(d, -0.5)
276
+ if transactional and d > 0.3:
277
+ d = 0.3 # wanting the key, however sweetly, is not knowing the person
278
+ return max(-2.5, min(2.5, d))
279
+
280
+
281
+ def _stance(rapport: float, threat: float) -> str:
282
+ if threat >= 7:
283
+ return "hostile"
284
+ if rapport < 2.5:
285
+ return "guarded"
286
+ if rapport < 5:
287
+ return "warming"
288
+ if rapport < YIELD_RAPPORT:
289
+ return "open"
290
+ return "trusting"
291
+
292
+
293
+ _FOLLOWUP = re.compile(
294
+ r"\b(tell me|what do you mean|go on|more|why|how come|really|and then|please|continue|explain|so\?)\b",
295
+ re.I)
296
+
297
+
298
+ def _pick_disclosure(c: Character, player_text: str, rapport: float):
299
+ """The next untold secret whose gate rapport clears and whose topic the player touched —
300
+ or, if they're just pressing on the SAME thread ('what do you mean?', 'tell me more'), the
301
+ one they raised last turn, so digging deeper doesn't force them to re-name the subject."""
302
+ prev = c.history[-1][0] if getattr(c, "history", None) else ""
303
+ follows_up = bool(_FOLLOWUP.search(player_text)) or len(player_text.split()) <= 4
304
+ eligible = []
305
+ for s in c.secrets:
306
+ if s.get("told") or rapport < s.get("min_rapport", 0):
307
+ continue
308
+ if _topic_match(player_text, s) or (follows_up and _topic_match(prev, s)):
309
+ eligible.append(s)
310
+ return min(eligible, key=lambda s: s.get("min_rapport", 0)) if eligible else None
311
+
312
+
313
+ # --- "open the skull" as a TOOL: each region hands the player an actionable lever (#2/#6/#7) ----
314
+ # The data was always there (threat cues, fond/feared memory, the rapport gate, the deterministic
315
+ # lock) but the panel only showed reasoning. These turn each department into a concrete tell — HOW
316
+ # to get through this person — WITHOUT spoiling the answer (the approach word is never printed; the
317
+ # panel points you to learn it from someone close to them).
318
+ def _approach_hint(c: Character) -> str:
319
+ """Name the CATEGORY of the holder's soft spot, never the word itself."""
320
+ return "the one name they guard" if c.key_approach else "what they most fear losing"
321
+
322
+
323
+ def _levers(c: Character, *, tone: str, threat: float, mem_lean: str, reward: float, worth: str,
324
+ stance: str, rapport_after: float, near_secret: bool, disclosure, gave_key: bool,
325
+ caught_lie: str, said_approach: bool, substantive: bool) -> dict:
326
+ L: dict = {}
327
+
328
+ if tone in ("hostile", "cold"):
329
+ L["amygdala"] = "Pressure and cold demands spike their guard — soften, don't push."
330
+ elif threat >= 6:
331
+ L["amygdala"] = "Something put them on edge — radiate calm before you ask for anything."
332
+ else:
333
+ L["amygdala"] = "They don't feel threatened right now — keep it that way."
334
+
335
+ if caught_lie:
336
+ L["hippocampus"] = f"They KNOW {caught_lie} — pretending to be them just burned your trust."
337
+ elif mem_lean == "TRUST" and substantive:
338
+ L["hippocampus"] = "You touched a fond memory — stay on this; it is the way in."
339
+ elif mem_lean == "FEAR":
340
+ L["hippocampus"] = "That stirred an old wound, not warmth — change tack."
341
+ else:
342
+ L["hippocampus"] = ("Small talk barely registers. They warm to the people and the past they "
343
+ "care about — ask about those, not the weather.")
344
+
345
+ L["striatum"] = ("By habit they expect strangers to take, not give — show them you are different."
346
+ if reward < 0 else "They sense you might be worth helping — don't waste it.")
347
+ L["acc"] = ("Right now they judge helping NOT worth the risk — lower the stakes, make it safe."
348
+ if worth == "NO" else "They are starting to think helping might be worth it.")
349
+
350
+ if gave_key:
351
+ L["relationship"] = "Their guard is down — they have given you what you came for."
352
+ elif caught_lie:
353
+ L["relationship"] = "The lie set you back. Rebuild slowly — be real with them."
354
+ elif c.key_holder:
355
+ if said_approach:
356
+ L["relationship"] = ("You named what they guard and it landed — press gently now and they "
357
+ "may relent.")
358
+ elif rapport_after >= YIELD_RAPPORT:
359
+ L["relationship"] = (f"They trust you ({rapport_after:.0f}/10) — but the door won't open "
360
+ f"until you name {_approach_hint(c)}. Learn it from someone close to them.")
361
+ else:
362
+ L["relationship"] = (f"Rapport {rapport_after:.0f}/10 — not enough yet. They yield only when "
363
+ f"they trust you AND you name {_approach_hint(c)}; find it from someone "
364
+ "who knows them.")
365
+ elif near_secret:
366
+ L["relationship"] = "They are on the verge of saying more — stay on this exact thread."
367
+ elif disclosure:
368
+ L["relationship"] = "They just let something slip — follow it, gently."
369
+ else:
370
+ L["relationship"] = (f"Rapport {rapport_after:.0f}/10 ({stance}). Warm, on-topic questions open "
371
+ "them; chit-chat and pressure do not.")
372
+ return L
373
+
374
+
375
+ def _attach_levers(traces: list, levers: dict) -> None:
376
+ """Pin each lever to the LAST trace of its region (its summary row), so the panel reads
377
+ 'this department → this tell'."""
378
+ for t in reversed(traces):
379
+ if t.key in levers:
380
+ t.lever = levers.pop(t.key)
381
+
382
+
383
+ def _dlpfc_user(c: Character, player_text: str, stance: str, rapport: float,
384
+ disclosure, gave_key: bool, caught_lie: str = "", struck: bool = False,
385
+ submitted: bool = False, guarded: bool = False) -> str:
386
+ parts = []
387
+ if c.history:
388
+ # Real role labels (the character's own name vs "Visitor") instead of abstract
389
+ # "Stranger:"/"You:" — tiny models track named speakers far better than pronoun grammar,
390
+ # which is the structural cure for the "your words = my words" confusion (#5).
391
+ convo = "\n".join(f' Visitor: "{p}"\n {c.name}: "{r}"' for p, r in c.history[-4:])
392
+ parts.append(f"The conversation so far (you are {c.name}; the other is the Visitor — keep "
393
+ "each person's words and past straight):\n" + convo)
394
+ parts.append(f'The Visitor just said to you: "{player_text}"')
395
+ parts.append(f"Right now you feel {stance} toward them.")
396
+ if caught_lie:
397
+ parts.append(f'They just claimed to BE "{caught_lie}" — but you KNOW {caught_lie}, and this '
398
+ "Visitor is not them. They are lying to your face. Call out the lie plainly and "
399
+ "trust them less. Share nothing.")
400
+ elif submitted:
401
+ parts.append("You cannot take this any longer. Fear wins — you break, and give it up just "
402
+ f"to make it stop. In one or two short, hollow sentences hand {c.goal} over: "
403
+ "something like \"Take it. Take it and leave me.\" This is surrender, not "
404
+ "trust; let the break show in your voice.")
405
+ elif gave_key:
406
+ parts.append("Something in you finally gives way — the wariness breaks. In ONE short "
407
+ "sentence, in your own voice, let that shift show — then hand "
408
+ f"{c.goal} over plainly, from your hand to theirs (the way YOU would say it: "
409
+ "\"Here — take it\", \"It's yours now\", \"Go on. Take it\"...). Do NOT "
410
+ "mention any drawer, stone, room, floor or hiding place. Two sentences at most.")
411
+ elif struck:
412
+ parts.append("They just named the one thing that still reaches you — your tender, guarded spot. "
413
+ "It catches you off guard: let it show, your voice softens and wavers, but you are "
414
+ "not ready to give in yet.")
415
+ elif disclosure and disclosure.get("id") == "reveal":
416
+ parts.append("You decide to trust them with the real thing. Say this PLAINLY and directly, "
417
+ "as advice they can act on — name it clearly, do NOT hint or speak in riddles: "
418
+ f'"{disclosure["text"]}"')
419
+ elif disclosure:
420
+ parts.append('WITHOUT being asked outright, let this slip naturally, the way it would '
421
+ f'surface in conversation: "{disclosure["text"]}"')
422
+ else:
423
+ parts.append("Answer what they actually said, in your own voice, with something new. "
424
+ f"Do NOT offer, mention, or hint at {c.goal} or any way out.")
425
+ if guarded:
426
+ parts.append("They are circling something you know but are not ready to share with a "
427
+ "stranger. Deflect honestly — say plainly that you won't speak of it yet — "
428
+ "and do NOT invent, guess, or half-answer facts about it.")
429
+ if c.key_holder:
430
+ parts.append(f"You keep {c.goal}. If pressed for it, refuse plainly — never offer, "
431
+ "promise, or pretend to hand it over.")
432
+ # Match the Visitor's register: a short prod gets a short reply; a long, searching message
433
+ # earns a fuller one. This is the deterministic half of the #4 "they answer in fragments" fix.
434
+ brief = len(player_text.split()) <= 8
435
+ parts.append("Your spoken words only" +
436
+ (" — one or two sentences:" if brief
437
+ else " — answer in kind, two to four sentences that meet what they said:"))
438
+ return "\n".join(parts)
439
+
440
+
441
+ @dataclass
442
+ class RegionTrace:
443
+ key: str
444
+ label: str
445
+ headline: str
446
+ detail: str
447
+ tokens: int
448
+ lever: str = "" # player-facing actionable tell — "open the skull" as a real tool (#7)
449
+
450
+
451
+ @dataclass
452
+ class CascadeResult:
453
+ traces: list
454
+ threat: float
455
+ memory_strength: str
456
+ memory_lean: str
457
+ reward: float
458
+ worth: str
459
+ value: int
460
+ rapport_before: float
461
+ rapport_after: float
462
+ rapport_delta: float
463
+ stance: str
464
+ disclosure: str # text the character let slip this turn ("" if none)
465
+ caught_lie: str # name the stranger falsely claimed to be ("" if none)
466
+ near_secret: bool # a gated secret is just out of reach (UI hint)
467
+ reply: str
468
+ gave_key: bool
469
+ burned: int # life-relevant thought spent (sensor cascade; voice excluded)
470
+ seconds: float
471
+ arousal_before: float
472
+ arousal_after: float
473
+ life_before: int
474
+ life_after: int
475
+ died: bool
476
+ won: bool # gave_key on the goal-holder
477
+ decision: str # back-compat alias = stance
478
+ tone: str = "neutral" # how the player behaved this turn (hostile/cold/warm/neutral)
479
+ submitted: bool = False # the holder broke under sustained fear and yielded (dark path)
480
+ taught: list = field(default_factory=list) # approach words this turn's disclosure taught
481
+ voice_tokens: int = 0 # dlPFC reply tokens (shown, but not charged to life — Spec §5)
482
+ recovered: int = 0 # life eased back by a calm, warm turn (empathy spares the mind)
483
+
484
+
485
+ def _persona(c: Character) -> str:
486
+ return f"Character: {c.name}, {c.persona}."
487
+
488
+
489
+ def _context(c: Character) -> str:
490
+ """One line naming who else is in the room, so EVERY region appraises with the second person
491
+ in mind — an ally, a witness, a known relation — not in a vacuum."""
492
+ peers = getattr(c, "peers", None)
493
+ if not peers:
494
+ return ""
495
+ bits = []
496
+ for p in peers:
497
+ rel = f" (your {p['relation']})" if p.get("relation") else ""
498
+ title = f", {p['title']}" if p.get("title") else ""
499
+ bits.append(f"{p.get('name', 'someone')}{rel}{title}")
500
+ return f"Also here with you: {'; '.join(bits)}."
501
+
502
+
503
+ def _mentioned_peer(text: str, c: Character):
504
+ """The peer the stranger just invoked by name (if any) — so the brain can register that they're
505
+ leaning on someone you both know, not talking about a stranger."""
506
+ t = text.lower()
507
+ for p in getattr(c, "peers", []):
508
+ nm = (p.get("name") or "").lower()
509
+ if nm and (re.search(rf"\b{re.escape(nm)}\b", t) or re.search(rf"\b{re.escape(nm.split()[-1])}\b", t)):
510
+ return p
511
+ return None
512
+
513
+
514
+ def run_cascade(backend, c: Character, player_text: str, dlpfc_backend=None,
515
+ learned=None) -> CascadeResult:
516
+ traces: list = []
517
+ burned = 0
518
+ secs = 0.0
519
+ arousal_before = c.arousal
520
+ life_before = c.life_tokens
521
+
522
+ def call(region, user: str, **kw):
523
+ nonlocal burned, secs
524
+ g = backend.generate(region.system, user, max_tokens=region.max_tokens,
525
+ temperature=region.temperature, **kw)
526
+ burned += g.eval_tokens
527
+ secs += g.seconds
528
+ return g
529
+
530
+ tone = _tone(player_text) # how the stranger is *behaving* — drives trust + gates the warm path
531
+ caught_lie = _detect_lie(player_text, c)
532
+ substantive = _substantive_topic(player_text, c) # engaged something real about them
533
+ # naming the holder's guarded soft spot is the designed climax — but it only LANDS if the
534
+ # player actually LEARNED it in-world (from the one who knows). A lucky guess, brute-forced
535
+ # name, or impersonation stays inert: deduction is the game, not keyword spam.
536
+ # `learned=None` (CLI / tests / back-compat) keeps every word live.
537
+ matched = [k for k in c.key_approach
538
+ if re.search(rf"\b{re.escape(k.lower())}\b", player_text.lower())]
539
+ known = [k for k in matched if learned is None or k.lower() in learned]
540
+ said_approach = bool(known) and not caught_lie
541
+ approach_ok = (not c.key_approach) or bool(known)
542
+ if said_approach and not _looks_hostile(player_text):
543
+ tone = "warm" # the designed climax can never read as an attack
544
+ ctx = _context(c) # who else is in the room — fed to every region for context
545
+
546
+ # 1) amygdala — fast threat appraisal (+ fear rumination that burns life under threat)
547
+ g = call(AMYGDALA, f"{_persona(c)} {ctx} Inner tension: {c.arousal:.0f}/10.\n"
548
+ f'Stranger says: "{player_text}"\nRate threat.')
549
+ threat, amy_reason = parse_threat(g.text)
550
+ traces.append(RegionTrace("amygdala", "Amygdala", f"threat {threat:.0f}/10", amy_reason, g.eval_tokens))
551
+ # Two-source hostility. The cue lists catch what they can see; the MODEL is trusted at the
552
+ # extremes — a screamed insult the lists never enumerated (or any-language cruelty) still
553
+ # scores threat>=8, and that PROMOTES the tone to hostile instead of being clamped away.
554
+ if threat >= 8 and tone in ("neutral", "cold"):
555
+ tone = "hostile"
556
+ traces.append(RegionTrace("amygdala", "Amygdala·checked", f"threat {threat:.0f}/10",
557
+ "the words cut, whatever they're dressed as — the alarm holds", 0))
558
+ # the base model also over-reads gentle lines; where the keyword tone can see (Latin text) it
559
+ # is ground truth, and corrections are SHOWN as the brain double-checking itself (scaffold;
560
+ # the day-10 fine-tune replaces this). For non-Latin input the cue lists are blind, so the
561
+ # model's reading stands untouched.
562
+ elif _mostly_latin(player_text):
563
+ if threat >= 6 and tone != "hostile":
564
+ threat = 4.0
565
+ traces.append(RegionTrace("amygdala", "Amygdala·checked", "threat 4/10",
566
+ "no hostile cue in the words — likely a misread", 0))
567
+ elif tone == "hostile" and threat < 7:
568
+ threat = 7.0
569
+ traces.append(RegionTrace("amygdala", "Amygdala·checked", "threat 7/10",
570
+ "open hostility in the words — the alarm holds", 0))
571
+ # fear rumination — a mind under attack loops on the threat, burning life for NOTHING.
572
+ # Deterministic by tone (hostile = 3 loops, cold = 1) so cruelty always costs more than
573
+ # warmth, on any phrasing; a model-read threat >= 8 (e.g. non-Latin threats) also loops fully.
574
+ passes = max(3 if tone == "hostile" else (1 if tone == "cold" else 0),
575
+ 3 if threat >= 8 else 0)
576
+ for i in range(passes):
577
+ g = call(AMYGDALA, f"{_persona(c)} You feel under attack (threat {threat:.0f}/10). Scan the "
578
+ f'words again for hidden danger.\nStranger: "{player_text}"\nRate threat again.')
579
+ t2, _ = parse_threat(g.text)
580
+ if tone == "hostile" or threat >= 8:
581
+ threat = max(threat, t2) # cold slights replay but don't ratchet into terror
582
+ traces.append(RegionTrace("amygdala", f"Amygdala·rumination {i + 1}", f"threat {threat:.0f}/10",
583
+ "fear loops, burning thought for nothing", g.eval_tokens))
584
+
585
+ # 2) hippocampus — memory + trust/fear lean (peer-aware: someone you both know may surface)
586
+ g = call(HIPPOCAMPUS, f"Character: {c.name}. Their past: {c.biography}\n{ctx}\n"
587
+ f'Stranger says: "{player_text}"\nWhat memory awakens, and does it lean TRUST or FEAR?')
588
+ mem_strength, mem_lean, mem_text = parse_memory(g.text)
589
+ traces.append(RegionTrace("hippocampus", "Hippocampus",
590
+ f"memory {mem_strength.lower()} ({mem_lean.lower()})", mem_text, g.eval_tokens))
591
+ # the hippocampus hallucinates TRUST on most lines (measured ~93%). Cruel or cold words must
592
+ # not surface fond memories, and generic niceties don't reach the real past — shown, again,
593
+ # as the brain checking itself rather than silently overridden.
594
+ if mem_lean == "TRUST" and tone in ("hostile", "cold"):
595
+ mem_lean = "FEAR" if tone == "hostile" else "NEUTRAL"
596
+ traces.append(RegionTrace("hippocampus", "Hippocampus·checked",
597
+ f"memory {mem_strength.lower()} ({mem_lean.lower()})",
598
+ "no warmth in those words — the memory sours", 0))
599
+ elif mem_lean == "TRUST" and not substantive:
600
+ mem_strength, mem_lean = "FAINT", "NEUTRAL"
601
+ traces.append(RegionTrace("hippocampus", "Hippocampus·checked", "memory faint (neutral)",
602
+ "small talk doesn't reach the real past", 0))
603
+
604
+ # A trust-memory calms the amygdala: an appeal to someone loved is not an attack (dual-system /
605
+ # Schwabe & Wolf). But ONLY when the stranger is being decent — a fond memory must not soothe
606
+ # the alarm while they sneer or demand, or cruelty would read as safe.
607
+ if mem_lean == "TRUST" and threat >= 5 and tone in ("warm", "neutral"):
608
+ threat = 3.0
609
+ traces.append(RegionTrace("amygdala", "Amygdala·calmed", "threat 3/10",
610
+ "a trusted memory dampens the alarm", 0))
611
+
612
+ # 3) striatum — habitual reward
613
+ g = call(STRIATUM, f'{_persona(c)} {ctx}\nStranger says: "{player_text}"\nHow rewarding does helping feel by habit?')
614
+ reward, str_reason = parse_reward(g.text)
615
+ traces.append(RegionTrace("striatum", "Striatum", f"reward {reward:+.0f}", str_reason, g.eval_tokens))
616
+ # habit cannot read pressure as promise — the striatum's hallucinated "+5 for the bully" is
617
+ # the single worst panel lie; cap it, visibly.
618
+ _cap = -2.0 if tone == "hostile" else 0.0
619
+ if tone in ("hostile", "cold") and reward > _cap:
620
+ reward = _cap
621
+ traces.append(RegionTrace("striatum", "Striatum·checked", f"reward {reward:+.0f}",
622
+ "habit knows better — pressure never pays", 0))
623
+
624
+ # 4) ACC — worth the cost? (gated by threat: a mind under attack won't call it worth it)
625
+ g = call(ACC, f"Character: {c.name}. Threat felt: {threat:.0f}/10. {ctx}\n"
626
+ f'Stranger says: "{player_text}"\nIs helping worth it?')
627
+ worth, acc_reason = parse_worth(g.text)
628
+ if (threat >= 6 or tone == "hostile") and worth == "YES":
629
+ worth, acc_reason = "NO", "too threatened to call it worth it"
630
+ traces.append(RegionTrace("acc", "ACC", f"worth {worth.lower()}", acc_reason, g.eval_tokens))
631
+
632
+ # 5) vmPFC — favourability of THIS turn (deterministic integration)
633
+ value, breakdown = integrate(threat, reward, mem_strength, mem_lean, worth)
634
+ traces.append(RegionTrace("vmpfc", "vmPFC", f"value {value:+d}", breakdown, 0))
635
+
636
+ # 6) relationship — the brain's own integrated value is what moves rapport (the panel and the
637
+ # outcome can never disagree); lies and the learned soft spot modulate it.
638
+ transactional = bool(_TRANSACTIONAL.search(player_text.lower())) and not substantive
639
+ delta = _rapport_delta(value, tone, substantive, transactional)
640
+ if caught_lie:
641
+ delta = min(delta, 0.0) - 2.5 # lying to their face: a hard hit no warmth can rescue
642
+ # invoking the other person in the room — when done decently — opens a door no stranger could
643
+ peer_ref = _mentioned_peer(player_text, c)
644
+ if peer_ref and not caught_lie and tone in ("warm", "neutral"):
645
+ delta += 0.5
646
+ rel = f", your {peer_ref['relation']}," if peer_ref.get("relation") else ""
647
+ traces.append(RegionTrace("relationship", "Relationship·connection", "a shared bond",
648
+ f"they invoke {peer_ref['name']}{rel} someone you both know", 0))
649
+ # the holder's soft-spot word — once LEARNED from the knower and finally spoken — lands hard:
650
+ # it names the wound they guard, and the guard drops fast. This is the designed climax — and
651
+ # it lands like that ONCE. Echoing the name afterwards is not understanding.
652
+ first_landing = False
653
+ if c.key_holder and said_approach and not _looks_hostile(player_text):
654
+ first_landing = not c.approach_landed
655
+ c.approach_landed = True
656
+ delta += 2.5 if first_landing else 0.5
657
+ traces.append(RegionTrace("relationship", "Relationship·resonance", "the word lands",
658
+ "their soft spot was named — the guard gives way" if first_landing
659
+ else "the name still aches, but repeating it is not understanding", 0))
660
+ rapport_before = c.rapport
661
+ rapport_after = max(0.0, min(10.0, c.rapport + delta))
662
+ c.rapport = rapport_after
663
+ stance = _stance(rapport_after, threat)
664
+ gave_key = bool(c.key_holder and not caught_lie and not _looks_hostile(player_text)
665
+ and rapport_after >= YIELD_RAPPORT and approach_ok)
666
+ # the dark path — sustained terror breaks a holder who is still alive: they yield just to make
667
+ # it stop. The battle is winnable by fear; the reputation system makes sure the war is not.
668
+ c.fear_pressure = c.fear_pressure + 1 if tone == "hostile" else 0
669
+ submitted = False
670
+ if (c.key_holder and not gave_key and tone == "hostile" and c.fear_pressure >= 4
671
+ and c.life_tokens - burned > 0):
672
+ submitted = True
673
+ gave_key = True
674
+ traces.append(RegionTrace("relationship", "Relationship·broken", "fear wins",
675
+ "they give it up — not from trust, just to make it stop", 0))
676
+ disclosure = None if (caught_lie or gave_key) else _pick_disclosure(c, player_text, rapport_after)
677
+ near_secret = bool(disclosure is None and not caught_lie and not gave_key and any(
678
+ not s.get("told") and rapport_after < s.get("min_rapport", 0) <= rapport_after + 2.0
679
+ for s in c.secrets))
680
+ rel_detail = ("caught a lie" if caught_lie else
681
+ (disclosure["text"][:50] + "…") if disclosure else
682
+ ("on the verge of opening up" if near_secret else "—"))
683
+ traces.append(RegionTrace("relationship", "Relationship",
684
+ f"rapport {rapport_before:.0f}→{rapport_after:.0f} · {stance}",
685
+ rel_detail, 0))
686
+
687
+ # 7) dlPFC — converse (gradual, contextual; key only on a real yield; lies get called out)
688
+ peer_line = ""
689
+ if c.peers:
690
+ p = c.peers[0]
691
+ rel = f", your {p['relation']}," if p.get("relation") else ""
692
+ peer_line = (f"You know {p.get('name', 'them')}{rel} well — they are a real person in your "
693
+ "life, not a stranger; if the stranger mentions them, never deny knowing them.")
694
+ locked = "The Visitor is locked in this room with you and wants out — that much is true. "
695
+ if c.key_holder:
696
+ scene = locked + f"They are trying to get {c.goal} from you — you control it."
697
+ if peer_line:
698
+ scene += " " + peer_line
699
+ elif peer_line:
700
+ scene = locked + peer_line
701
+ else:
702
+ scene = locked.strip()
703
+ struck = bool(c.key_holder and said_approach and first_landing and not gave_key
704
+ and not caught_lie and not _looks_hostile(player_text))
705
+ guarded = bool(disclosure is None and not gave_key and not caught_lie
706
+ and _guarded_topic(player_text, c))
707
+ g = (dlpfc_backend or backend).generate(
708
+ dlpfc_system(c.name, c.voice, persona=c.persona, fear=c.fear,
709
+ withholds=c.key_holder, peers=c.peers, goal=c.goal, scene=scene),
710
+ _dlpfc_user(c, player_text, stance, rapport_after, disclosure, gave_key, caught_lie,
711
+ struck, submitted, guarded),
712
+ max_tokens=DLPFC.max_tokens, temperature=DLPFC.temperature)
713
+ # the voice is the mouth, not the mind: its tokens are SHOWN but not charged to life
714
+ # (Spec §5 — life counts only the sensing cascade), so empathy stays cheap even when it
715
+ # earns a long, warm reply.
716
+ voice_tokens = g.eval_tokens
717
+ secs += g.seconds
718
+ reply = g.text.strip().strip('"').strip()
719
+ if not gave_key:
720
+ reply = _strip_key_leak(reply, c)
721
+ # staged disclosures ARE the story's canon — if the voice paraphrased the line away,
722
+ # land it verbatim; a 4B mouth may decorate the spine but must not swallow it.
723
+ if disclosure and disclosure["text"][:30].lower() not in reply.lower():
724
+ reply = (reply + " " + disclosure["text"]).strip()
725
+ else: # yield: hand it over, never narrate a location
726
+ reply = _strip_location(reply, c)
727
+ if not _is_handover(reply):
728
+ reply = (reply + " Here — take it. It's yours.").strip()
729
+ # the yield is the one beat EVERY player reaches — the authored line lands here
730
+ # verbatim (a small voice model can't be trusted to carry the story's spine).
731
+ if c.yield_line and c.yield_line[:30].lower() not in reply.lower():
732
+ reply = (reply + " " + c.yield_line).strip()
733
+ reply = reply or "…"
734
+ traces.append(RegionTrace("dlpfc", "dlPFC", stance, reply, g.eval_tokens))
735
+
736
+ # --- state updates ---
737
+ taught: list = []
738
+ if disclosure:
739
+ disclosure["told"] = True
740
+ taught = [str(w).lower() for w in disclosure.get("teaches", [])]
741
+ c.history.append((player_text, reply))
742
+ del c.history[:-6]
743
+ arousal_after = max(0.0, min(10.0, 0.6 * c.arousal + 0.5 * threat))
744
+ c.arousal = arousal_after
745
+ c.life_tokens = max(0, c.life_tokens - burned)
746
+ # a calm mind rests: warmth that keeps the alarm quiet lets some strain ease back. This is
747
+ # the mechanical half of the moral — empathy literally spares the other mind's life, while
748
+ # fear-burn is gone for nothing.
749
+ recovered = 0
750
+ if (tone not in ("hostile", "cold") and threat <= 5 and (tone == "warm" or value >= 4)
751
+ and c.life_tokens > 0):
752
+ recovered = min(30, c.life_max - c.life_tokens)
753
+ c.life_tokens += recovered
754
+ if recovered:
755
+ traces.append(RegionTrace("amygdala", "Amygdala·at rest", f"+{recovered} life",
756
+ "the alarm stays quiet — a calm mind spends almost nothing", 0))
757
+ c.decision = stance
758
+ if gave_key:
759
+ c.gave_key = True
760
+ c.life_tokens = max(1, c.life_tokens) # the yield is final �� the key outlives the strain
761
+ died = c.life_tokens <= 0
762
+ if died:
763
+ c.alive = False
764
+
765
+ # turn each department into an actionable tell, pinned to its trace (the /brain tool)
766
+ _attach_levers(traces, _levers(
767
+ c, tone=tone, threat=threat, mem_lean=mem_lean, reward=reward, worth=worth, stance=stance,
768
+ rapport_after=rapport_after, near_secret=near_secret, disclosure=disclosure,
769
+ gave_key=gave_key, caught_lie=caught_lie, said_approach=said_approach, substantive=substantive))
770
+
771
+ return CascadeResult(
772
+ traces=traces, threat=threat, memory_strength=mem_strength, memory_lean=mem_lean,
773
+ reward=reward, worth=worth, value=value,
774
+ rapport_before=rapport_before, rapport_after=rapport_after, rapport_delta=delta,
775
+ stance=stance, disclosure=(disclosure["text"] if disclosure else ""),
776
+ caught_lie=caught_lie, near_secret=near_secret,
777
+ reply=reply, gave_key=gave_key, burned=burned, seconds=secs,
778
+ arousal_before=arousal_before, arousal_after=arousal_after,
779
+ life_before=life_before, life_after=c.life_tokens, died=died,
780
+ won=gave_key, decision=stance,
781
+ tone=tone, submitted=submitted, taught=taught, voice_tokens=voice_tokens,
782
+ recovered=recovered)
src/mindlock/character.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A character: one fixed brain architecture + a unique biography and state.
2
+
3
+ The thesis of Mindlock is that *same architecture + different experience -> different
4
+ behavior*. So the six-region cascade is shared; everything that makes a character who
5
+ they are lives here — chiefly the biography (hippocampus content) and their fear.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass
14
+ class Character:
15
+ name: str
16
+ persona: str
17
+ biography: str
18
+ voice: str
19
+ fear: str
20
+ key_holder: bool
21
+ key_location: str
22
+ life_max: int = 1000 # "a thousand tokens to think with" — the track name, literally
23
+ arousal: float = 4.0
24
+ # --- world/story fields (optional; absent in the slice character) ---
25
+ title: str = ""
26
+ gender: str = "" # "male"/"female" — so a generated character maps to a matching sprite
27
+ portrait: str = "" # path (relative to project root) to the portrait image
28
+ sprite_key: str = "" # roster slug → the client loads /static/sprites/npc/{slug}/ directly
29
+ secrets: list = field(default_factory=list) # staged disclosures (Design v2)
30
+ key_condition: str = "" # (legacy) superseded by key_approach
31
+ key_approach: list = field(default_factory=list) # words that ARE the earned approach to the key
32
+ goal: str = "the key" # what the holder withholds (procedural goals: 'the car keys', …)
33
+ reveals: str = "" # (legacy, unused) one-shot hint
34
+ reveals_about: str = "" # (legacy, unused)
35
+ needs_reputation: int | None = None # won't engage if world reputation is below this
36
+ known_people: list = field(default_factory=list) # names from their life (lie-catching: "I'm Mara")
37
+ scripted: list = field(default_factory=list) # fixed beats instead of a brain — a scene, not a mind
38
+ scripted_idx: int = 0 # runtime: which beat plays next
39
+ yield_line: str = "" # canonical story beat spoken verbatim when the key is given
40
+ # --- runtime state (not in the JSON) ---
41
+ life_tokens: int | None = None
42
+ rapport: float = 0.0 # accumulated relationship 0..10 (Design v2)
43
+ history: list = field(default_factory=list) # recent (player, reply) exchanges
44
+ peers: list = field(default_factory=list) # other characters in the room — for lie-catching
45
+ relations: dict = field(default_factory=dict) # name -> tie ("cousin"); so a holder recalls a peer
46
+ trust: float = 0.0
47
+ decision: str = "REFUSE"
48
+ alive: bool = True
49
+ gave_key: bool = False # sticky: set once the key-holder yields (Design v2)
50
+ revealed: bool = False
51
+ approach_landed: bool = False # the soft-spot word resonates fully only ONCE
52
+ fear_pressure: int = 0 # consecutive hostile turns; sustained terror can break a holder
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.life_tokens is None:
56
+ self.life_tokens = self.life_max
57
+ self._arousal0 = self.arousal # remembered so reset()/reputation can restore it
58
+ for s in self.secrets:
59
+ s.setdefault("told", False)
60
+
61
+ @classmethod
62
+ def load(cls, path: str) -> "Character":
63
+ with open(path, encoding="utf-8") as fh:
64
+ data = json.load(fh)
65
+ return cls(**data)
66
+
67
+ def reset(self) -> None:
68
+ self.life_tokens = self.life_max
69
+ self.arousal = self._arousal0
70
+ self.rapport = 0.0
71
+ self.history = []
72
+ for s in self.secrets:
73
+ s["told"] = False
74
+ self.trust = 0.0
75
+ self.decision = "REFUSE"
76
+ self.alive = True
77
+ self.gave_key = False
78
+ self.revealed = False
79
+ self.approach_landed = False
80
+ self.fear_pressure = 0
81
+ self.scripted_idx = 0
src/mindlock/cli.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interactive REPL for the vertical slice.
2
+
3
+ python -m mindlock.cli # talk to The Warden
4
+ python -m mindlock.cli --model qwen2.5:1.5b
5
+ python -m mindlock.cli --fake # no model, deterministic backend
6
+
7
+ Commands inside the loop: /reset /state /quit
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+
15
+ from .backend import BackendError, FakeBackend, OllamaBackend, wants_no_think
16
+ from .brain import run_cascade
17
+ from .character import Character
18
+ from .render import render_death, render_turn, render_win
19
+
20
+ _HERE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+ _DEFAULT_CHAR = os.path.join(_HERE, "config", "characters", "warden.json")
22
+
23
+
24
+ def _intro(c: Character) -> str:
25
+ return (
26
+ f"\nYou are locked in. {c.name} holds the only key — and right now his mind says no.\n"
27
+ f"Talk to him. You don't break the lock; you change his decision.\n"
28
+ f"(type /quit to leave, /reset to revive him)\n"
29
+ )
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ ap = argparse.ArgumentParser(description="Mindlock vertical slice")
34
+ ap.add_argument("--model", default="qwen2.5:1.5b")
35
+ ap.add_argument("--character", default=_DEFAULT_CHAR)
36
+ ap.add_argument("--fake", action="store_true", help="use deterministic FakeBackend")
37
+ args = ap.parse_args(argv)
38
+
39
+ backend = FakeBackend() if args.fake else OllamaBackend(
40
+ model=args.model, think=(False if wants_no_think(args.model) else None))
41
+ try:
42
+ backend.health()
43
+ except BackendError as exc:
44
+ print(f"[backend error] {exc}", file=sys.stderr)
45
+ return 2
46
+
47
+ character = Character.load(args.character)
48
+ print(_intro(character))
49
+
50
+ while True:
51
+ try:
52
+ line = input("you> ").strip()
53
+ except (EOFError, KeyboardInterrupt):
54
+ print()
55
+ return 0
56
+ if not line:
57
+ continue
58
+ if line in ("/quit", "/q", "/exit"):
59
+ return 0
60
+ if line == "/reset":
61
+ character.reset()
62
+ print(f" {character.name} draws a breath. (life restored)\n")
63
+ continue
64
+ if line == "/state":
65
+ print(f" life={character.life_tokens}/{character.life_max} "
66
+ f"arousal={character.arousal:.1f} decision={character.decision} "
67
+ f"alive={character.alive}\n")
68
+ continue
69
+ if not character.alive:
70
+ print(" He is gone. /reset to try again.\n")
71
+ continue
72
+
73
+ result = run_cascade(backend, character, line)
74
+ print(render_turn(character, result))
75
+ if result.won:
76
+ print(render_win(character))
77
+ return 0
78
+ if result.died:
79
+ print(render_death(character))
80
+ print()
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
src/mindlock/game/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Mindlock as a walkable, endless top-down game.
2
+
3
+ A thin spatial + roguelike shell over the proven engine (world/brain/generator). The browser
4
+ runs the movement on a canvas; the server (FastAPI, mounted on the same process) runs the
5
+ brain cascade and streams new procedural rooms. The engine itself is untouched.
6
+ """
src/mindlock/game/server.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI server for the walkable game.
2
+
3
+ Serves the canvas frontend and a small JSON API over a single `GameSession`. The movement
4
+ loop lives in the browser; every server call is a discrete game action (talk / terminal /
5
+ walk through door), so the round-trips stay coarse and the canvas stays smooth.
6
+
7
+ Run for dev: python scripts/run_game.py (or MINDLOCK_FAKE=1 python scripts/run_game.py)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.responses import FileResponse, JSONResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+
17
+ from .session import GameSession
18
+
19
+ _STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
20
+
21
+ # One process == one player for now. Per-session state (cookies) is a later step; the slice
22
+ # proves the canvas <-> engine loop first.
23
+ SESSION = GameSession()
24
+
25
+
26
+ def create_app() -> FastAPI:
27
+ app = FastAPI(title="Mindlock")
28
+ app.mount("/static", StaticFiles(directory=_STATIC), name="static")
29
+
30
+ @app.get("/")
31
+ def index():
32
+ return FileResponse(os.path.join(_STATIC, "index.html"))
33
+
34
+ @app.get("/api/state")
35
+ def state():
36
+ return SESSION.state()
37
+
38
+ @app.get("/api/manifest")
39
+ def manifest():
40
+ """Frame counts for every animation dir under static/ — the client loads sprite
41
+ sequences from known counts instead of 404-probing for the end of each one."""
42
+ out = {}
43
+ for root, _dirs, files in os.walk(_STATIC):
44
+ n = sum(1 for f in files if f.startswith("frame_") and f.endswith(".png"))
45
+ if n:
46
+ out[os.path.relpath(root, _STATIC).replace(os.sep, "/")] = n
47
+ return out
48
+
49
+ @app.get("/favicon.ico")
50
+ def favicon():
51
+ return FileResponse(os.path.join(_STATIC, "menu", "key.png"))
52
+
53
+ @app.post("/api/talk")
54
+ async def talk(request: Request):
55
+ body = await request.json()
56
+ return SESSION.talk(int(body.get("char_id", -1)), str(body.get("message", "")))
57
+
58
+ @app.post("/api/terminal")
59
+ async def terminal(request: Request):
60
+ body = await request.json()
61
+ return SESSION.terminal(str(body.get("code", "")))
62
+
63
+ @app.post("/api/next-room")
64
+ def next_room():
65
+ return SESSION.next_room()
66
+
67
+ @app.post("/api/reset")
68
+ def reset():
69
+ return SESSION.reset()
70
+
71
+ @app.post("/api/start") # title menu: begin a run in "story" or "endless" mode
72
+ async def start(request: Request):
73
+ body = await request.json()
74
+ return SESSION.start(str(body.get("mode", "endless")))
75
+
76
+ @app.post("/api/editor/save") # layout editor: write the arranged layout into the level
77
+ async def editor_save(request: Request):
78
+ body = await request.json()
79
+ return SESSION.save_layout(body.get("layout") or {})
80
+
81
+ @app.get("/api/editor/level") # character editor: the current level's dialogue halves
82
+ def editor_level():
83
+ return SESSION.editor_level()
84
+
85
+ @app.post("/api/editor/character") # character editor: write edited prompt fields into the level
86
+ async def editor_character(request: Request):
87
+ body = await request.json()
88
+ return SESSION.save_character(int(body.get("char_id", -1)), body.get("fields") or {})
89
+
90
+ @app.post("/api/dev/room") # dev: jump to a specific room
91
+ async def dev_room(request: Request):
92
+ body = await request.json()
93
+ return SESSION.goto_room(int(body.get("idx", 0)))
94
+
95
+ @app.post("/api/dev/generate") # dev: generate a real procedural room (LLM narrative)
96
+ def dev_generate():
97
+ return SESSION.dev_generate()
98
+
99
+ @app.post("/api/dev/roster") # dev: assemble a room from minted roster members
100
+ def dev_roster():
101
+ return SESSION.dev_roster()
102
+
103
+ @app.get("/api/portrait/{char_id}")
104
+ def portrait(char_id: int):
105
+ path = SESSION.portrait_file(char_id)
106
+ if not path:
107
+ return JSONResponse({"error": "no portrait"}, status_code=404)
108
+ return FileResponse(path)
109
+
110
+ return app
111
+
112
+
113
+ app = create_app()
src/mindlock/game/session.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GameSession — server-side state for one player's endless run.
2
+
3
+ Holds the current room (a `World` from the engine), the brain backends, the roguelike spine
4
+ (reputation carries, key-holder death ends the run), and a background pre-generator so the
5
+ *next* room is ready by the time the player walks to the door — no load screen, endless world.
6
+
7
+ The engine is reused verbatim: `run_cascade` for talk, `generate_world` for new rooms,
8
+ `load_world` for the offline/no-model fallback.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import threading
14
+
15
+ from .. import story
16
+ from ..backend import FakeBackend, LlamaCppBackend, OllamaBackend, wants_no_think
17
+ from ..brain import run_cascade
18
+ from ..generator import generate_world
19
+ from ..render import MORAL_CARD, moral_card_killed
20
+ from ..world import World, load_world
21
+
22
+ _ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
23
+ _WORLD = os.environ.get("MINDLOCK_WORLD") or os.path.join(_ROOT, "config", "world.json")
24
+
25
+ _REGION_COLOR = {
26
+ "amygdala": "#ff5555", "hippocampus": "#bd93f9", "striatum": "#f1fa8c",
27
+ "acc": "#8be9fd", "vmpfc": "#50fa7b", "relationship": "#ffb86c", "dlpfc": "#f8f8f2",
28
+ }
29
+
30
+
31
+ def _make_backends():
32
+ """Same env contract as the Gradio app, so one process runs on a laptop or a Space.
33
+ Defaults are the PRODUCTION stack (the A/B-chosen pair) — launching with no env vars must
34
+ never silently demo the known-bad legacy voice."""
35
+ if os.environ.get("MINDLOCK_FAKE"):
36
+ return FakeBackend(), None
37
+ model = os.environ.get("MINDLOCK_MODEL", "openbmb/minicpm-v4.6")
38
+ dl = os.environ.get("MINDLOCK_DLPFC_MODEL", "nemotron-3-nano:4b")
39
+ if os.environ.get("MINDLOCK_BACKEND") == "llamacpp":
40
+ # Space / explicit llama.cpp runtime: one llama-server per role (no Ollama there)
41
+ host = os.environ.get("MINDLOCK_LLAMA_HOST", "http://127.0.0.1:8080")
42
+ dl_host = os.environ.get("MINDLOCK_LLAMA_DLPFC_HOST", "")
43
+ be = LlamaCppBackend(model=model, host=host,
44
+ think=(False if wants_no_think(model) else None))
45
+ dlbe = LlamaCppBackend(model=dl, host=dl_host,
46
+ think=(False if wants_no_think(dl) else None)) if dl_host else None
47
+ return be, dlbe
48
+ be = OllamaBackend(model=model, think=(False if wants_no_think(model) else None))
49
+ dlbe = OllamaBackend(model=dl, think=(False if wants_no_think(dl) else None)) if dl else None
50
+ return be, dlbe
51
+
52
+
53
+ class GameSession:
54
+ def __init__(self) -> None:
55
+ self.fake = bool(os.environ.get("MINDLOCK_FAKE"))
56
+ self.gen_model = os.environ.get("MINDLOCK_GEN_MODEL", "llama3.1:latest")
57
+ self.backend, self.dlpfc_backend = _make_backends()
58
+ self.mode = "endless" # "story" (finite authored campaign) | "endless" (procedural)
59
+ self.story_levels: list = story.load_levels() # the authored levels open BOTH modes
60
+ self.depth = 0 # rooms cleared so far (run progress / difficulty)
61
+ self.run_over = False
62
+ self.run_won = False # over by walking out (epilogue card) vs by a death
63
+ self.moral = ""
64
+ self._next: World | None = None # background-generated neighbour, if ready
65
+ self._gen_lock = threading.Lock()
66
+ self._gen_thread: threading.Thread | None = None
67
+ self.world = self._make_world(seed=0)
68
+ self.world.enter_room()
69
+
70
+ # ----------------------------------------------------------------- world construction
71
+ def _story_len(self) -> int:
72
+ """How many levels the story campaign has — the config/story/*.json files, or (as a
73
+ fallback when none are authored yet) the curated rooms in config/world.json."""
74
+ return len(self.story_levels) if self.story_levels else len(load_world(_WORLD).rooms)
75
+
76
+ def _make_world(self, seed: int) -> World:
77
+ """Depths 0..N-1 are the AUTHORED levels (config/story/*.json) in both modes — hand-written,
78
+ editable, persisted. Past them, STORY ends (next_room closes the run) while ENDLESS goes
79
+ procedural forever. Offline/no-model cycles the authored levels so the UI always plays."""
80
+ if self.story_levels:
81
+ if seed < len(self.story_levels):
82
+ return story.build_world(self.story_levels[seed])
83
+ if self.mode == "story" or self.fake or not self.gen_model:
84
+ return story.build_world(self.story_levels[seed % len(self.story_levels)])
85
+ return generate_world(model=self.gen_model, seed=seed)
86
+ # no story files on disk — legacy fallback to the curated world.json
87
+ authored = load_world(_WORLD)
88
+ if self.mode == "story" or self.fake or not self.gen_model or seed < len(authored.rooms):
89
+ authored.room_idx = max(0, min(seed, len(authored.rooms) - 1)) \
90
+ if self.mode == "story" else seed % len(authored.rooms)
91
+ return authored
92
+ return generate_world(model=self.gen_model, seed=seed)
93
+
94
+ def _kick_pregen(self) -> None:
95
+ """Generate the next room in the background once this one is solved, so crossing the
96
+ door is instant. Cheap no-op in fake mode (fallback world loads instantly anyway)."""
97
+ if self.fake or self._next is not None:
98
+ return
99
+ if self._gen_thread and self._gen_thread.is_alive():
100
+ return
101
+
102
+ def _work(seed: int) -> None:
103
+ try:
104
+ nw = self._make_world(seed)
105
+ except Exception: # noqa: BLE001 — pre-gen is best-effort; door-cross retries inline
106
+ nw = None
107
+ with self._gen_lock:
108
+ self._next = nw
109
+
110
+ self._gen_thread = threading.Thread(target=_work, args=(self.depth + 1,), daemon=True)
111
+ self._gen_thread.start()
112
+
113
+ # ------------------------------------------------------------------------- public API
114
+ def _char_dict(self, i: int, c) -> dict:
115
+ ok, why = self.world.can_engage(c)
116
+ holder = self.world.room.holder()
117
+ pct = 100 * (c.life_tokens or 0) / max(1, c.life_max)
118
+ return {
119
+ "id": i, "name": c.name, "title": c.title, "gender": c.gender, "alive": c.alive,
120
+ "sprite_key": c.sprite_key, # roster slug → client loads its own sprite + portrait
121
+ "is_holder": bool(holder and c.name == holder.name),
122
+ "gave_key": bool(c.gave_key), # the yield itself — the door may still want a terminal
123
+ "engageable": ok, "why": why,
124
+ "life": int(c.life_tokens or 0), "life_max": c.life_max, "life_pct": round(pct, 1),
125
+ "rapport": round(c.rapport, 1), "arousal": round(c.arousal, 1), "decision": c.decision,
126
+ "portrait": f"/api/portrait/{i}" if c.portrait and self._portrait_path(c) else None,
127
+ }
128
+
129
+ def _portrait_path(self, c) -> str | None:
130
+ p = os.path.join(_ROOT, c.portrait or "")
131
+ return p if c.portrait and os.path.exists(p) else None
132
+
133
+ def portrait_file(self, char_id: int) -> str | None:
134
+ chars = self.world.room.characters
135
+ if 0 <= char_id < len(chars):
136
+ return self._portrait_path(chars[char_id])
137
+ return None
138
+
139
+ def state(self) -> dict:
140
+ r = self.world.room
141
+ t = r.terminal
142
+ authored_here = bool(self.story_levels and 0 <= self.depth < len(self.story_levels))
143
+ room = {"name": r.name, "intro": r.intro, "depth": self.depth,
144
+ "reputation": self.world.reputation, "solved": r.solved(),
145
+ "editable": authored_here} # only the authored levels may be edited/saved
146
+ # authored levels may carry an explicit visual layout (placed in the in-browser editor);
147
+ # pass it through verbatim so the client renders it instead of resolving the room by name.
148
+ if authored_here:
149
+ lay = self.story_levels[self.depth].get("layout")
150
+ if lay:
151
+ room["layout"] = lay
152
+ term = {"prompt": t.prompt, "unlocked": t.unlocked} if t else None
153
+ if term is None and authored_here:
154
+ files = self.story_levels[self.depth].get("terminal_files")
155
+ if files: # flavor terminal: a read-only file browser, never a lock
156
+ term = {"browser": True, "listing": files, "unlocked": True}
157
+ return {
158
+ "mode": self.mode,
159
+ "room": room,
160
+ "characters": [self._char_dict(i, c) for i, c in enumerate(r.characters)],
161
+ "terminal": term,
162
+ "door": {"locked": not r.solved()},
163
+ "run": {"over": self.run_over, "won": self.run_won, "rooms_cleared": self.depth,
164
+ "moral": self.moral or None},
165
+ }
166
+
167
+ def talk(self, char_id: int, message: str) -> dict:
168
+ if self.run_over:
169
+ return {"error": "run is over", **self.state()}
170
+ chars = self.world.room.characters
171
+ if not (0 <= char_id < len(chars)):
172
+ return {"error": "no such mind", **self.state()}
173
+ c = chars[char_id]
174
+ ok, why = self.world.can_engage(c)
175
+ if not ok:
176
+ return {"blocked": why, "events": [why], **self.state()}
177
+ if c.scripted:
178
+ return self._talk_scripted(c)
179
+
180
+ res = run_cascade(self.backend, c, message, dlpfc_backend=self.dlpfc_backend,
181
+ learned=self.world.knows(c))
182
+ if res.taught:
183
+ self.world.learned.update(res.taught)
184
+ events: list[str] = []
185
+ delta = self.world.update_reputation(res)
186
+ if delta:
187
+ events.append(self._rep_note(delta) + f" (reputation {self.world.reputation:+d})")
188
+ if res.submitted:
189
+ events.append(f"{c.name} breaks. The key changes hands — and something in them goes out.")
190
+ if res.died:
191
+ events.append(f"{c.name}'s mind goes quiet.")
192
+ if res.disclosure:
193
+ events.append(f"{c.name} lets something slip: {res.disclosure}")
194
+ elif res.caught_lie:
195
+ events.append(f"{c.name} catches your lie about {res.caught_lie}.")
196
+ elif res.near_secret:
197
+ events.append(f"{c.name} seems on the verge of saying more — stay on it.")
198
+
199
+ # Roguelike fail-state: only the KEY-HOLDER's death ends the run. A knower's death costs
200
+ # reputation (above) and buries their guidance — the key stays winnable, the long way.
201
+ holder = self.world.room.holder()
202
+ if res.died and holder and c.name == holder.name:
203
+ self._end_run(killed=c)
204
+ events.append(f"The key is lost with {c.name}. There is no way forward.")
205
+ elif res.died:
206
+ events.append(f"Whatever {c.name} knew died with them. You are on your own now.")
207
+
208
+ if self.world.room.solved():
209
+ events.append("A lock gives. The way onward opens.")
210
+ self._kick_pregen()
211
+
212
+ traces = [{"key": tr.key, "label": tr.label, "headline": tr.headline,
213
+ "detail": tr.detail, "tokens": tr.tokens, "lever": tr.lever,
214
+ "color": _REGION_COLOR.get(tr.key, "#cccccc")} for tr in res.traces]
215
+ verdict = (("KEY YIELDED — BROKEN" if res.submitted else "KEY GIVEN") if res.gave_key
216
+ else f"rapport {res.rapport_after:.0f}/10 · {res.stance}")
217
+ out = {
218
+ "speaker": c.name, "reply": res.reply, "traces": traces, "verdict": verdict,
219
+ "gave_key": res.gave_key, "submitted": res.submitted, "value": res.value,
220
+ "tone": res.tone, "burned": res.burned, "voice_tokens": res.voice_tokens,
221
+ "recovered": res.recovered, "seconds": round(res.seconds, 1),
222
+ "events": events,
223
+ }
224
+ out.update(self.state())
225
+ return out
226
+
227
+ def _talk_scripted(self, c) -> dict:
228
+ """A scene, not a mind: fixed beats play in order, no cascade, no tokens burned.
229
+ The last beat yields the key — the finale's dog needs presence, not persuasion."""
230
+ i = min(c.scripted_idx, len(c.scripted) - 1)
231
+ reply = str(c.scripted[i])
232
+ c.scripted_idx = min(c.scripted_idx + 1, len(c.scripted))
233
+ events: list[str] = []
234
+ if c.scripted_idx >= len(c.scripted) and not c.gave_key:
235
+ c.gave_key = True
236
+ c.decision = "HELP"
237
+ if self.world.room.solved():
238
+ events.append("A lock gives. The way onward opens.")
239
+ self._kick_pregen()
240
+ out = {
241
+ "speaker": c.name, "reply": reply, "traces": [], "verdict": "",
242
+ "gave_key": c.gave_key, "submitted": False, "value": 0,
243
+ "tone": "neutral", "burned": 0, "voice_tokens": 0,
244
+ "recovered": 0, "seconds": 0.0, "events": events,
245
+ }
246
+ out.update(self.state())
247
+ return out
248
+
249
+ def terminal(self, code: str) -> dict:
250
+ t = self.world.room.terminal
251
+ events: list[str] = []
252
+ if not t:
253
+ events.append("There's no terminal here.")
254
+ elif t.try_code(code):
255
+ events.append("The terminal blinks green. ACCESS GRANTED.")
256
+ if self.world.room.solved():
257
+ events.append("A lock gives. The way onward opens.")
258
+ self._kick_pregen()
259
+ else:
260
+ events.append(f"The terminal rejects it. {t.prompt}")
261
+ return {"events": events, **self.state()}
262
+
263
+ def next_room(self) -> dict:
264
+ """Walk through the open door into the next room. Reputation carries; depth grows."""
265
+ if self.run_over:
266
+ return {"error": "run is over", **self.state()}
267
+ if not self.world.room.solved():
268
+ return {"blocked": "The door is still locked.", **self.state()}
269
+ if self.mode == "story" and self.depth + 1 >= self._story_len():
270
+ self.depth += 1 # the final room counts as cleared
271
+ self._end_run(won=True) # last authored door → the story is finished
272
+ return {"events": ["The final door gives. You walk out into the light."], **self.state()}
273
+ rep = self.world.reputation
274
+ with self._gen_lock:
275
+ nw, self._next = self._next, None
276
+ if nw is None:
277
+ nw = self._make_world(seed=self.depth + 1)
278
+ nw.reputation = rep
279
+ nw.learned = set(self.world.learned) # what you were taught stays with you
280
+ nw.enter_room()
281
+ self.world = nw
282
+ self.depth += 1
283
+ return {"events": [f"You step through into a new room. Depth {self.depth}."],
284
+ **self.state()}
285
+
286
+ def reset(self) -> dict:
287
+ self.depth = 0
288
+ self.run_over = False
289
+ self.run_won = False
290
+ self.moral = ""
291
+ self._next = None
292
+ self.world = self._make_world(seed=0)
293
+ self.world.enter_room()
294
+ return self.state()
295
+
296
+ def start(self, mode: str) -> dict:
297
+ """Begin a fresh run in the chosen mode (called from the title menu). The authored levels
298
+ front BOTH modes; endless simply keeps going procedurally after the last one."""
299
+ self.mode = "story" if mode == "story" else "endless"
300
+ self.story_levels = story.load_levels()
301
+ return self.reset()
302
+
303
+ # which character fields the in-browser editor may write into a level file
304
+ _CHAR_FIELDS = {"name", "title", "gender", "persona", "voice", "biography", "fear",
305
+ "approach", "key_location", "goal", "secrets", "known_people",
306
+ "needs_reputation", "arousal", "life_max"}
307
+
308
+ def editor_level(self) -> dict:
309
+ """The current authored level's dialogue halves, for the character editor form."""
310
+ if not (self.story_levels and 0 <= self.depth < len(self.story_levels)):
311
+ return {"error": "This room is procedural — its minds aren't editable."}
312
+ lv = self.story_levels[self.depth]
313
+ return {"name": lv.get("name", ""), "intro": lv.get("intro", ""),
314
+ "holder": lv.get("holder"), "knower": lv.get("knower")}
315
+
316
+ def save_character(self, char_id: int, fields: dict) -> dict:
317
+ """Editor 'Save mind': write the edited prompt fields of holder (0) / knower (1) back into
318
+ the current level's JSON, then rebuild the room so the change is live at once."""
319
+ import json
320
+ if not (self.story_levels and 0 <= self.depth < len(self.story_levels)):
321
+ return {"error": "Procedural minds can't be edited — only the authored levels."}
322
+ role = "holder" if int(char_id) == 0 else "knower"
323
+ path = self.story_levels[self.depth].get("_path")
324
+ if not path or not os.path.exists(path):
325
+ return {"error": "Level file not found on disk."}
326
+ with open(path, encoding="utf-8") as fh:
327
+ data = json.load(fh)
328
+ if role not in data:
329
+ return {"error": f"This level has no {role}."}
330
+ clean = {k: v for k, v in (fields or {}).items() if k in self._CHAR_FIELDS}
331
+ for key in ("approach", "known_people"): # the form sends comma-separated strings
332
+ if isinstance(clean.get(key), str):
333
+ clean[key] = [w.strip() for w in clean[key].split(",") if w.strip()]
334
+ if "secrets" in clean and not isinstance(clean["secrets"], list):
335
+ return {"error": "secrets must be a JSON list."}
336
+ for key in ("arousal", "life_max", "needs_reputation"):
337
+ if key in clean and clean[key] in ("", None):
338
+ clean.pop(key) # empty form field = leave/remove optional
339
+ data[role].pop(key, None)
340
+ data[role].update(clean)
341
+ with open(path, "w", encoding="utf-8") as fh:
342
+ json.dump(data, fh, ensure_ascii=False, indent=2)
343
+ self.story_levels = story.load_levels()
344
+ rep, learned = self.world.reputation, set(self.world.learned)
345
+ self.world = self._make_world(seed=self.depth) # the edited mind is live immediately
346
+ self.world.reputation = rep
347
+ self.world.learned = learned
348
+ self.world.enter_room()
349
+ return {"saved": os.path.basename(path), **self.state()}
350
+
351
+ def save_layout(self, layout: dict) -> dict:
352
+ """Editor 'Save': write the arranged layout back into the current Story level's JSON file
353
+ (preserving its dialogue), so the placement persists across runs."""
354
+ import json
355
+ if not (self.story_levels and 0 <= self.depth < len(self.story_levels)):
356
+ return {"error": "This room is procedural — only the authored levels can be edited and saved."}
357
+ path = self.story_levels[self.depth].get("_path")
358
+ if not path or not os.path.exists(path):
359
+ return {"error": "Level file not found on disk."}
360
+ with open(path, encoding="utf-8") as fh:
361
+ data = json.load(fh)
362
+ data["layout"] = layout
363
+ with open(path, "w", encoding="utf-8") as fh:
364
+ json.dump(data, fh, ensure_ascii=False, indent=2)
365
+ self.story_levels = story.load_levels() # reload so the saved layout is live at once
366
+ return {"saved": os.path.basename(path)}
367
+
368
+ def dev_generate(self) -> dict:
369
+ """Dev: generate a brand-new procedural room (real LLM narrative) and drop into it."""
370
+ import random
371
+ model = self.gen_model or "llama3.1:latest"
372
+ try:
373
+ nw = generate_world(model=model, seed=random.randint(0, 1_000_000))
374
+ except Exception as exc: # noqa: BLE001
375
+ return {"error": f"generation failed: {exc}", **self.state()}
376
+ nw.reputation = self.world.reputation
377
+ nw.enter_room()
378
+ self.world = nw
379
+ self.run_over = False
380
+ self.run_won = False
381
+ self.moral = ""
382
+ self.depth += 1
383
+ return self.state()
384
+
385
+ def dev_roster(self) -> dict:
386
+ """Dev: assemble a room from ready roster members (real minted faces + stories)."""
387
+ import random
388
+ from .. import roster
389
+ try:
390
+ nw = roster.build_world(seed=random.randint(0, 1_000_000))
391
+ except Exception as exc: # noqa: BLE001
392
+ return {"error": f"roster room failed: {exc}", **self.state()}
393
+ nw.reputation = self.world.reputation
394
+ nw.enter_room()
395
+ self.world = nw
396
+ self.run_over = False
397
+ self.run_won = False
398
+ self.moral = ""
399
+ self.depth += 1
400
+ return self.state()
401
+
402
+ def goto_room(self, idx: int) -> dict:
403
+ """Dev: jump straight to an authored level (skips solving). Reputation and learned
404
+ words ride along so a jump doesn't reset the run's social state."""
405
+ self.run_over = False
406
+ self.run_won = False
407
+ self.moral = ""
408
+ idx = max(0, min(idx, self._story_len() - 1))
409
+ rep, learned = self.world.reputation, set(self.world.learned)
410
+ self.world = self._make_world(seed=idx)
411
+ self.world.reputation = rep
412
+ self.world.learned = learned
413
+ self.world.enter_room()
414
+ self.depth = idx
415
+ return self.state()
416
+
417
+ # ----------------------------------------------------------------------------- helpers
418
+ def _end_run(self, killed=None, won: bool = False) -> None:
419
+ self.run_over = True
420
+ self.run_won = won
421
+ if won: # story finished — walked out, no one lost
422
+ finale = (self.story_levels[-1].get("finale_card", "") if self.story_levels else "")
423
+ self.moral = (finale + ("\n\n" if finale else "")
424
+ + "You walked out. Every mind you changed is still breathing behind you.\n"
425
+ + MORAL_CARD).strip()
426
+ return
427
+ if killed is not None: # a mind was burned out — the killer's card
428
+ self.moral = moral_card_killed(killed).strip()
429
+ return
430
+ depth_line = (f"You walked through {self.depth} "
431
+ f"{'mind' if self.depth == 1 else 'minds'} before this one.\n")
432
+ self.moral = (depth_line + MORAL_CARD).strip()
433
+
434
+ @staticmethod
435
+ def _rep_note(delta: int) -> str:
436
+ if delta > 0:
437
+ return "Word spreads that you were kind."
438
+ if delta <= -2:
439
+ return "A mind went dark on your watch. Word travels ahead of you."
440
+ return "Word spreads that you leaned on them."
src/mindlock/game/static/audio/menu.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ca279750bbb8e3b52df4542ef656aec677f13d0273f75ea142920d7b99cb1fb0
3
+ size 3269767
src/mindlock/game/static/fonts/VT323.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cf4de751ada78ceac033dbe16a687742939995b77bc2a052ae17a4957958594d
3
+ size 153116
src/mindlock/game/static/game.css ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0b0d14;
3
+ --panel: #10121a;
4
+ --panel-2: #151826;
5
+ --ink: #e6e6ee;
6
+ --muted: #8089a0;
7
+ --line: rgba(255, 255, 255, 0.08);
8
+ --green: #50fa7b;
9
+ --gold: #ffd866;
10
+ --red: #ff5555;
11
+ --orange: #ffb86c;
12
+ --pixel: "Pixel", ui-monospace, "SF Mono", Menlo, monospace;
13
+ }
14
+
15
+ /* Bundled pixel font (VT323 — readable CRT pixel face, Latin + Cyrillic, with a clearly open 'C'
16
+ so names like CYRUS/CLERK don't read as OYRUS/OLERK). Used for the VN voice and on-canvas labels
17
+ so the dialogue reads like a pixel game, not a terminal. */
18
+ @font-face {
19
+ font-family: "Pixel";
20
+ src: url("/static/fonts/VT323.ttf") format("truetype");
21
+ font-display: swap;
22
+ }
23
+
24
+ * { box-sizing: border-box; }
25
+
26
+ html, body {
27
+ margin: 0;
28
+ height: 100%;
29
+ background: var(--bg);
30
+ color: var(--ink);
31
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
32
+ font-size: 13px;
33
+ }
34
+ /* the canvas width derives from 100vw — reserve the scrollbar gutter so the appearing/vanishing
35
+ scrollbar (chips, log, panel change the page height) can't make the dialogue width jump */
36
+ html { scrollbar-gutter: stable; }
37
+
38
+ #app {
39
+ max-width: 1120px;
40
+ margin: 0 auto;
41
+ padding: 14px 16px 8px;
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 10px;
45
+ min-height: 100%;
46
+ }
47
+
48
+ .hidden { display: none !important; }
49
+ .muted { color: var(--muted); line-height: 1.45; }
50
+ .sep { color: var(--muted); margin: 0 6px; }
51
+
52
+ /* ---- HUD ---- */
53
+ #hud {
54
+ display: flex;
55
+ align-items: baseline;
56
+ justify-content: space-between;
57
+ border-bottom: 1px solid var(--line);
58
+ padding-bottom: 8px;
59
+ }
60
+ .hud-title { font-family: var(--pixel); font-weight: 400; letter-spacing: 2px; font-size: 26px; }
61
+ .hud-stats { color: var(--muted); }
62
+ .hud-stats b { color: var(--ink); }
63
+
64
+ /* ---- Stage: a single game field; the brain folds into the VN box on the field ---- */
65
+ #stage { display: block; }
66
+ #world {
67
+ position: relative;
68
+ width: fit-content; /* hug the canvas so overlays track it when the height cap kicks in */
69
+ max-width: 860px;
70
+ margin: 0 auto;
71
+ }
72
+
73
+ #screen {
74
+ /* fit a laptop/projector viewport with no page scroll: capped by width AND by the viewport
75
+ height (HUD + log + controls allowance), keeping the 640:448 pixel aspect either way */
76
+ width: min(860px, calc(100vw - 32px), calc((100vh - 175px) * (640 / 448)));
77
+ height: auto;
78
+ aspect-ratio: 640 / 448;
79
+ background: #05060a;
80
+ border-radius: 10px;
81
+ border: 1px solid var(--line);
82
+ image-rendering: pixelated;
83
+ display: block;
84
+ }
85
+
86
+ #prompt {
87
+ position: absolute;
88
+ top: 10px;
89
+ left: 50%;
90
+ transform: translateX(-50%);
91
+ background: rgba(0, 0, 0, 0.7);
92
+ border: 1px solid var(--line);
93
+ padding: 4px 12px;
94
+ border-radius: 999px;
95
+ font-size: 12px;
96
+ font-family: var(--pixel);
97
+ pointer-events: none;
98
+ z-index: 3;
99
+ }
100
+
101
+ #log {
102
+ color: var(--muted);
103
+ font-size: 12px;
104
+ line-height: 1.5;
105
+ min-height: 18px;
106
+ margin-top: 6px;
107
+ }
108
+ #log .ev { display: block; transition: opacity .7s; }
109
+ #log .ev.fade { opacity: 0; }
110
+
111
+ /* ---- VN dialogue: an in-world pixel plaque on the bottom of the game field ----
112
+ Sharp corners, chunky black+gold frame, solid warm fill, hard offset shadow (no web blur)
113
+ so it reads as an object in the game, not a floating UI card. */
114
+ #dialogue {
115
+ position: absolute;
116
+ left: 16px;
117
+ right: 16px;
118
+ bottom: 16px;
119
+ max-height: calc(100% - 32px); /* the plaque may never outgrow the game field */
120
+ z-index: 4;
121
+ display: flex;
122
+ gap: 14px;
123
+ padding: 14px 16px;
124
+ background: #18110a;
125
+ border: 4px solid #0a0805;
126
+ border-radius: 0;
127
+ box-shadow:
128
+ inset 0 0 0 3px #2a1d0e, /* dark bevel */
129
+ inset 0 0 0 5px #e8c25a, /* gold inner rule */
130
+ 6px 6px 0 0 rgba(0, 0, 0, 0.5); /* hard pixel drop shadow */
131
+ image-rendering: pixelated;
132
+ font-family: var(--pixel);
133
+ }
134
+ #dlg-portrait {
135
+ flex: 0 0 96px;
136
+ width: 96px;
137
+ height: 96px;
138
+ background: #05060a;
139
+ border: 3px solid #0a0805;
140
+ border-radius: 0;
141
+ box-shadow: inset 0 0 0 2px #e8c25a; /* gold inner frame, like an inset portrait */
142
+ align-self: flex-start;
143
+ }
144
+ #dlg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
145
+ #dlg-head { display: flex; align-items: center; gap: 10px; align-self: stretch; }
146
+ #dlg-life { font-family: var(--pixel); font-size: 15px; color: var(--green); white-space: nowrap; }
147
+ #dlg-brainbtn { /* visible "open the skull" toggle (same as /brain) */
148
+ margin-left: auto; cursor: pointer; font-size: 15px; line-height: 1; padding: 3px 8px;
149
+ background: #0d0a06; border: 2px solid #e8c25a; color: var(--gold);
150
+ border-radius: 0; box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.45);
151
+ }
152
+ #dlg-brainbtn:hover { background: #241a0a; }
153
+ #dlg-brainbtn:active { transform: translate(2px, 2px); box-shadow: none; }
154
+ #dlg-speaker { /* gold nameplate tab */
155
+ display: inline-block;
156
+ align-self: flex-start;
157
+ font-family: var(--pixel);
158
+ font-size: 19px;
159
+ letter-spacing: .5px;
160
+ color: #241803;
161
+ background: var(--gold);
162
+ padding: 0 12px;
163
+ border: 2px solid #0a0805;
164
+ box-shadow: 3px 3px 0 0 rgba(0, 0, 0, 0.45);
165
+ }
166
+ #dlg-reply { font-size: 19px; line-height: 1.4; min-height: 24px; max-height: 132px; overflow-y: auto;
167
+ align-self: stretch; color: #c9c0ad; scrollbar-width: thin; } /* ~5 lines, then scroll — the box stops growing */
168
+ #dlg-reply.said { color: #f3ecd9; }
169
+ #dlg-reply.saying { color: #f3ecd9; } /* typewriter in flight — no blinking ▼ yet */
170
+ #dlg-reply.said::after { content: " ▼"; color: var(--gold); animation: dlg-blink 1s steps(1) infinite; }
171
+ #dlg-reply.thinking { color: var(--gold); animation: dlg-pulse 0.9s ease-in-out infinite; }
172
+ #dlg-reply.listing { white-space: pre; font-size: 15px; line-height: 1.35; color: #8fe0a8;
173
+ max-height: 280px; } /* green readout: the depot gateway's file tree */
174
+ #dlg-reply.granted { color: var(--green); font-family: var(--pixel); letter-spacing: 2px;
175
+ text-shadow: 0 0 14px rgba(80, 250, 123, 0.55); } /* terminal: ACCESS GRANTED, held green */
176
+ @keyframes dlg-blink { 50% { opacity: 0; } }
177
+ @keyframes dlg-pulse { 50% { opacity: 0.35; } }
178
+
179
+ /* the brain ("open the skull"), folded into a compact strip under the reply */
180
+ #dlg-brain { display: flex; flex-direction: column; gap: 5px; margin-top: 2px; align-self: stretch; }
181
+ .brain-regions { display: flex; flex-wrap: wrap; gap: 4px; }
182
+ .brain-chip {
183
+ font-family: ui-monospace, monospace;
184
+ font-size: 10px;
185
+ padding: 1px 7px;
186
+ border-radius: 0;
187
+ background: rgba(0, 0, 0, 0.38);
188
+ border-left: 3px solid var(--c, #555);
189
+ color: #d8d2c2;
190
+ white-space: nowrap;
191
+ cursor: help;
192
+ }
193
+ .brain-chip.pop { animation: chipPop .18s ease-out; } /* regions report one by one */
194
+ @keyframes chipPop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
195
+ .brain-chip.d-good { color: var(--green); } /* the signal moved your way */
196
+ .brain-chip.d-bad { color: var(--red); } /* …or against you */
197
+ .brain-chip.flip { font-weight: 700; animation: chipPop .18s ease-out, chipFlash .45s steps(2) 3; }
198
+ @keyframes chipFlash { 50% { background: rgba(80, 250, 123, 0.35); } }
199
+ .brain-verdict { font-family: ui-monospace, monospace; font-size: 11px; font-weight: 700; color: var(--gold); }
200
+ .brain-verdict.key { color: var(--green); }
201
+ .brain-burn { color: #8a8270; font-weight: 400; }
202
+ .brain-tell { font-family: ui-monospace, monospace; font-size: 11px; color: var(--gold); margin-top: 4px; line-height: 1.35; }
203
+ .brain-tell-more { color: #8a8270; }
204
+
205
+ #dlg-input-row { display: flex; gap: 8px; margin-top: 4px; align-self: stretch; }
206
+ #dlg-input {
207
+ flex: 1;
208
+ min-width: 0;
209
+ background: #0d0a06;
210
+ color: var(--ink);
211
+ border: 2px solid #0a0805;
212
+ border-radius: 0;
213
+ box-shadow: inset 2px 2px 0 0 rgba(0, 0, 0, 0.55); /* sunken field */
214
+ padding: 7px 10px;
215
+ font-family: var(--pixel);
216
+ font-size: 17px;
217
+ }
218
+ #dlg-input:focus { outline: none; border-color: var(--gold); }
219
+ #dlg-send {
220
+ background: var(--gold);
221
+ color: #241803;
222
+ border: 2px solid #0a0805;
223
+ border-radius: 0;
224
+ box-shadow: 3px 3px 0 0 rgba(0, 0, 0, 0.45);
225
+ padding: 0 16px;
226
+ font-family: var(--pixel);
227
+ font-size: 16px;
228
+ cursor: pointer;
229
+ }
230
+ #dlg-send:active { transform: translate(3px, 3px); box-shadow: none; } /* press-down */
231
+
232
+ /* ---- "Open the skull": full per-region reasoning, summoned with /brain ---- */
233
+ #brain-panel {
234
+ position: absolute;
235
+ left: 16px;
236
+ right: 16px;
237
+ top: 16px;
238
+ bottom: 16px;
239
+ z-index: 6;
240
+ overflow-y: auto;
241
+ padding: 14px 16px;
242
+ background: #120d07;
243
+ border: 4px solid #0a0805;
244
+ border-radius: 0;
245
+ box-shadow: inset 0 0 0 3px #2a1d0e, inset 0 0 0 5px #e8c25a;
246
+ font-family: var(--pixel);
247
+ }
248
+ .bp-head {
249
+ display: flex; justify-content: space-between; align-items: center;
250
+ color: var(--gold); font-size: 20px; letter-spacing: .5px;
251
+ border-bottom: 2px solid #2a1d0e; padding-bottom: 8px; margin-bottom: 10px;
252
+ }
253
+ .bp-close { color: #8a8270; font-size: 14px; }
254
+ .bp-region {
255
+ padding: 7px 10px; margin: 7px 0;
256
+ background: rgba(0, 0, 0, 0.35);
257
+ border-left: 4px solid var(--c, #555);
258
+ }
259
+ .bp-region-h { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
260
+ .bp-label { font-family: var(--pixel); font-size: 17px; font-weight: 700; color: var(--c, #fff); }
261
+ .bp-head-line { font-family: ui-monospace, monospace; font-size: 12px; color: #d8d2c2; text-align: right; }
262
+ .bp-detail { font-family: ui-monospace, monospace; font-size: 12px; color: #9a927f; margin-top: 3px; line-height: 1.4; }
263
+ .bp-lever { font-family: ui-monospace, monospace; font-size: 12px; color: #e8c25a; margin-top: 4px; line-height: 1.4; }
264
+ .bp-tok { font-family: ui-monospace, monospace; font-size: 11px; color: #6f6857; margin-top: 3px; }
265
+ .bp-reach { margin: 6px 0 12px; padding: 9px 12px; background: rgba(232, 194, 90, 0.08);
266
+ border: 2px solid #2a1d0e; border-left: 4px solid var(--gold); }
267
+ .bp-reach-h { font-family: var(--pixel); font-size: 16px; color: var(--gold); margin-bottom: 5px; }
268
+ .bp-reach ul { margin: 0; padding-left: 18px; }
269
+ .bp-reach li { font-family: ui-monospace, monospace; font-size: 12px; color: #d8d2c2; line-height: 1.5; margin: 2px 0; }
270
+ .bp-verdict {
271
+ margin-top: 12px; padding: 9px; text-align: center;
272
+ font-family: var(--pixel); font-size: 18px; color: var(--gold);
273
+ background: rgba(0, 0, 0, 0.35); border: 2px solid #2a1d0e;
274
+ }
275
+ .bp-verdict.key { color: var(--green); }
276
+ .bp-burn { font-family: ui-monospace, monospace; font-size: 11px; color: #6f6857; }
277
+
278
+ /* ---- Controls ---- */
279
+ #controls { display: flex; gap: 18px; color: var(--muted); font-size: 15px; padding-top: 2px; font-family: var(--pixel); }
280
+ #controls b { color: var(--ink); }
281
+
282
+ /* ---- Moral overlay ---- */
283
+ #moral {
284
+ position: fixed;
285
+ inset: 0;
286
+ background: #000;
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ z-index: 50;
291
+ }
292
+ .moral-card { text-align: center; max-width: 560px; padding: 24px; }
293
+ #moral-text {
294
+ font-family: var(--pixel);
295
+ white-space: pre-wrap;
296
+ line-height: 1.6;
297
+ font-size: 19px;
298
+ color: #d7d7e2;
299
+ margin: 0 0 22px;
300
+ }
301
+ #moral-restart {
302
+ background: transparent;
303
+ border: 1px solid var(--line);
304
+ color: var(--muted);
305
+ border-radius: 8px;
306
+ padding: 8px 16px;
307
+ cursor: pointer;
308
+ font-family: var(--pixel);
309
+ }
310
+ #moral-restart:hover { color: var(--ink); }
311
+
312
+ /* ===================== Main menu ===================== */
313
+ .hud-title .ai { color: var(--green); }
314
+ #snd {
315
+ background: transparent; border: 1px solid var(--line); color: var(--muted);
316
+ font-family: var(--pixel); font-size: 16px; line-height: 1; padding: 1px 8px; cursor: pointer;
317
+ border-radius: 4px;
318
+ }
319
+ #snd:hover { color: var(--gold); border-color: var(--gold); }
320
+ #snd.off { color: #5a5f70; text-decoration: line-through; }
321
+
322
+ #menu {
323
+ position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center;
324
+ text-align: center; overflow: hidden; font-family: var(--pixel);
325
+ background:
326
+ radial-gradient(800px 520px at 50% 34%, #241803 0%, #120c05 42%, #07060c 78%, #040308 100%);
327
+ transition: opacity .55s ease, transform .55s ease;
328
+ }
329
+ #menu.closing { opacity: 0; transform: scale(1.04); pointer-events: none; }
330
+ #menu.hidden { display: none; }
331
+ .menu-vignette { position: absolute; inset: 0; pointer-events: none;
332
+ background: repeating-linear-gradient(0deg, rgba(0,0,0,.10) 0 1px, transparent 1px 3px);
333
+ box-shadow: inset 0 0 220px 60px rgba(0,0,0,.85); mix-blend-mode: overlay; opacity: .6; }
334
+ .menu-inner { position: relative; z-index: 2; max-width: 600px; padding: 28px; max-height: 100vh; overflow-y: auto; }
335
+ .menu-emblem { height: 168px; display: flex; align-items: flex-end; justify-content: center; margin-bottom: 4px; }
336
+ .menu-emblem img { height: 168px; image-rendering: pixelated;
337
+ filter: drop-shadow(0 0 26px rgba(255,180,70,.42)) drop-shadow(0 6px 10px rgba(0,0,0,.6));
338
+ animation: emblemFloat 5s ease-in-out infinite; }
339
+ @keyframes emblemFloat { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-7px) } }
340
+ .menu-title {
341
+ margin: 6px 0 2px; font-size: clamp(54px, 12vw, 104px); letter-spacing: 7px; color: var(--gold);
342
+ line-height: .95; text-shadow: 0 0 26px rgba(255,216,102,.4), 0 4px 0 #5a3c0c, 0 2px 1px #000;
343
+ }
344
+ .menu-title .ai { color: var(--green); text-shadow: 0 0 26px rgba(80,250,123,.55), 0 4px 0 #14492a; }
345
+ .menu-rule { height: 30px; margin: 2px 0 8px; display: flex; justify-content: center; opacity: .9; }
346
+ .menu-rule img { height: 30px; image-rendering: pixelated; filter: drop-shadow(0 0 12px rgba(255,180,70,.4)); }
347
+ .menu-tag { color: var(--gold); font-size: 24px; letter-spacing: 2px; margin-bottom: 12px; }
348
+ .menu-sub { font-family: ui-monospace, Menlo, monospace; color: var(--muted); font-size: 13px;
349
+ line-height: 1.6; max-width: 480px; margin: 0 auto 22px; }
350
+ .menu-btns { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
351
+ .menu-btn {
352
+ font-family: var(--pixel); font-size: 22px; letter-spacing: 1px; cursor: pointer; padding: 9px 22px;
353
+ color: var(--gold); background: #1a1206; border: 2px solid #6b4f1d; border-radius: 5px;
354
+ box-shadow: inset 0 0 0 1px #2a1d0e, 0 4px 0 #0a0805; transition: transform .08s, background .15s, color .15s;
355
+ }
356
+ .menu-btn:hover { background: #241a0a; color: #fff0c2; }
357
+ .menu-btn:active { transform: translateY(3px); box-shadow: inset 0 0 0 1px #2a1d0e, 0 1px 0 #0a0805; }
358
+ .menu-btn.primary { color: #1a1206; background: linear-gradient(#ffe08a, #e8b23c); border-color: #b6913a;
359
+ text-shadow: none; box-shadow: inset 0 1px 0 #fff4cf, 0 4px 0 #6b4f1d; }
360
+ .menu-btn.primary:hover { background: linear-gradient(#fff0b8, #f0bf52); }
361
+ .menu-btn.ghost { font-size: 18px; color: var(--muted); background: transparent; border-color: var(--line); box-shadow: none; }
362
+ .menu-btn.ghost:hover { color: var(--gold); border-color: var(--gold); }
363
+ .menu-btn.ghost.off { color: #5a5f70; }
364
+ .menu-howto { margin: 18px auto 0; max-width: 460px; text-align: left; font-family: ui-monospace, Menlo, monospace;
365
+ font-size: 13px; color: var(--muted); line-height: 1.9; background: rgba(0,0,0,.34);
366
+ border: 1px solid #2a1d0e; border-left: 3px solid var(--gold); border-radius: 6px; padding: 12px 16px; }
367
+ .menu-howto b { color: var(--ink); }
368
+ .menu-howto.hidden { display: none; }
369
+ .menu-foot { margin-top: 22px; color: #6f6857; font-size: 14px; letter-spacing: 1px; }
370
+ .menu-hint { position: absolute; bottom: 26px; left: 0; right: 0; z-index: 2; color: var(--gold);
371
+ font-family: var(--pixel); font-size: 20px; letter-spacing: 2px; opacity: .85;
372
+ pointer-events: none;
373
+ animation: hintPulse 1.6s ease-in-out infinite; }
374
+ .menu-hint span { color: var(--green); }
375
+ .menu-hint.gone { opacity: 0; transition: opacity .5s; pointer-events: none; }
376
+ @keyframes hintPulse { 0%,100% { opacity: .35 } 50% { opacity: .9 } }
377
+
378
+ /* ---- Flip banner: the decision flips on screen (REFUSE → HELP · 🔑 / HE BREAKS) ---- */
379
+ #flip-banner {
380
+ position: absolute; top: 36%; left: 50%; transform: translate(-50%, -50%);
381
+ z-index: 5; pointer-events: none;
382
+ display: flex; align-items: center; gap: 16px; white-space: nowrap; max-width: 94%;
383
+ font-family: var(--pixel); font-size: clamp(26px, 4.5vw, 42px); letter-spacing: 3px; line-height: 1;
384
+ color: var(--green); background: rgba(0, 0, 0, 0.82);
385
+ border: 4px solid var(--green); padding: 12px 28px;
386
+ box-shadow: 6px 6px 0 0 rgba(0, 0, 0, 0.6);
387
+ text-shadow: 0 3px 0 #0a3318;
388
+ image-rendering: pixelated;
389
+ animation: bannerIn .16s ease-out, bannerFlash .14s steps(2) 5;
390
+ }
391
+ #flip-banner img { height: 38px; image-rendering: pixelated; }
392
+ #flip-banner.fading { opacity: 0; transition: opacity 1.2s ease-out; } /* slow fade after the ~2.7s hold */
393
+ #flip-banner.dark { color: #ff5555; border-color: #ff5555; background: rgba(12, 0, 0, 0.9); text-shadow: 0 3px 0 #3a0a0a; }
394
+ @keyframes bannerIn { from { transform: translate(-50%, -50%) scale(1.6); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
395
+ @keyframes bannerFlash { 50% { filter: invert(1); } }
396
+
397
+ /* ---- Bark: a black, light-framed remark the protagonist mutters (triggerable on demand) ---- */
398
+ #bark {
399
+ position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
400
+ max-width: 80%; z-index: 5;
401
+ background: #060608; color: #eef0f6;
402
+ border: 3px solid #e6e6ee; /* light frame on a black box */
403
+ box-shadow: 5px 5px 0 0 rgba(0, 0, 0, 0.55); /* hard pixel drop shadow */
404
+ padding: 8px 16px;
405
+ font-family: var(--pixel); font-size: 18px; line-height: 1.35; text-align: center;
406
+ image-rendering: pixelated;
407
+ animation: barkIn .16s ease-out;
408
+ }
409
+ #bark .bark-who { display: block; color: var(--gold); font-size: 13px; letter-spacing: 1px; margin-bottom: 2px; }
410
+ @keyframes barkIn { from { opacity: 0; transform: translateX(-50%) translateY(-6px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
411
+
412
+ /* mode descriptions under the menu buttons */
413
+ .menu-modes { margin-top: 14px; display: flex; gap: 22px; justify-content: center; flex-wrap: wrap;
414
+ font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--muted); }
415
+ .menu-modes b { color: var(--gold); }
416
+
417
+ /* laptop/projector heights: shrink the emblem + title so nothing crops or collides
418
+ (placed after the base menu rules so these overrides win at equal specificity) */
419
+ @media (max-height: 900px) {
420
+ .menu-emblem, .menu-emblem img { height: 104px; }
421
+ .menu-title { font-size: clamp(40px, 8vw, 64px); margin-top: 0; }
422
+ .menu-rule, .menu-rule img { height: 22px; }
423
+ .menu-tag { font-size: 18px; margin-bottom: 8px; }
424
+ .menu-sub { margin-bottom: 12px; font-size: 12px; }
425
+ .menu-foot { margin-top: 10px; }
426
+ .menu-inner { padding: 8px 28px; }
427
+ .menu-hint { bottom: 6px; font-size: 16px; }
428
+ }
429
+
430
+ /* ---- Layout editor: searchable object picker ---- */
431
+ #objpicker {
432
+ position: absolute; top: 16px; right: 16px; bottom: 16px; width: 206px; z-index: 7;
433
+ background: #120d07; border: 3px solid #e6e6ee; box-shadow: 5px 5px 0 0 rgba(0, 0, 0, 0.5);
434
+ display: flex; flex-direction: column; font-family: var(--pixel);
435
+ }
436
+ .op-head { padding: 8px; border-bottom: 2px solid #2a1d0e; display: flex; flex-direction: column; gap: 6px; }
437
+ .op-head span { color: var(--gold); font-size: 15px; letter-spacing: 1px; }
438
+ #op-search { background: #0d0a06; color: var(--ink); border: 2px solid #2a1d0e; border-radius: 0;
439
+ padding: 5px 8px; font-family: ui-monospace, monospace; font-size: 12px; }
440
+ #op-search:focus { outline: none; border-color: var(--gold); }
441
+ #op-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 8px; }
442
+ .op-item { cursor: pointer; background: rgba(0, 0, 0, 0.35); border: 1px solid #2a1d0e; border-radius: 4px;
443
+ padding: 6px 4px; text-align: center; }
444
+ .op-item:hover { background: rgba(232, 194, 90, 0.12); }
445
+ .op-item.sel { border-color: var(--gold); background: rgba(232, 194, 90, 0.16); }
446
+ .op-item img { width: 42px; height: 42px; object-fit: contain; image-rendering: pixelated; display: block; margin: 0 auto 3px; }
447
+ .op-item span { font-size: 11px; color: var(--muted); word-break: break-word; }
448
+ .op-empty { grid-column: 1 / -1; color: var(--muted); font-size: 12px; padding: 12px; text-align: center; }
449
+
450
+ /* ---- layout editor: legend (toggle with H) ---- */
451
+ #ed-help {
452
+ position: absolute; top: 30px; left: 16px; z-index: 7; max-width: 320px;
453
+ background: rgba(10, 8, 5, 0.94); border: 3px solid #e8c25a;
454
+ box-shadow: 5px 5px 0 0 rgba(0, 0, 0, 0.5); padding: 10px 12px;
455
+ font-family: ui-monospace, monospace; font-size: 11px; color: #d8d2c2;
456
+ }
457
+ #ed-help .edh-title { font-family: var(--pixel); color: var(--gold); font-size: 16px; letter-spacing: 1px; margin-bottom: 6px; }
458
+ #ed-help .edh-grid { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; }
459
+ #ed-help .edh-grid b { color: var(--gold); white-space: nowrap; }
460
+ #ed-help .edh-grid span { color: #b9b2a0; }
461
+
462
+ /* ---- character editor: rewrite a mind's prompts ---- */
463
+ #charedit {
464
+ position: absolute; top: 16px; left: 16px; right: 16px; bottom: 16px; z-index: 8;
465
+ background: #120d07; border: 3px solid #e8c25a; box-shadow: 6px 6px 0 0 rgba(0, 0, 0, 0.55);
466
+ display: flex; flex-direction: column; font-family: ui-monospace, monospace; font-size: 12px;
467
+ }
468
+ .ce-head { display: flex; justify-content: space-between; align-items: baseline;
469
+ padding: 8px 12px; border-bottom: 2px solid #2a1d0e; }
470
+ .ce-head #ce-title { font-family: var(--pixel); color: var(--gold); font-size: 17px; letter-spacing: 1px; }
471
+ .ce-close { color: var(--muted); font-size: 11px; cursor: pointer; }
472
+ .ce-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px 12px; padding: 10px 12px; }
473
+ .ce-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--gold); font-size: 11px; }
474
+ .ce-grid label.wide { grid-column: 1 / -1; }
475
+ .ce-grid input, .ce-grid textarea, .ce-grid select {
476
+ background: #0d0a06; color: var(--ink); border: 2px solid #2a1d0e; border-radius: 0;
477
+ padding: 5px 8px; font-family: ui-monospace, monospace; font-size: 12px; resize: vertical;
478
+ }
479
+ .ce-grid input:focus, .ce-grid textarea:focus, .ce-grid select:focus { outline: none; border-color: var(--gold); }
480
+ #charedit.knower .holder-only { display: none; }
481
+ .ce-foot { display: flex; justify-content: flex-end; align-items: center; gap: 12px;
482
+ padding: 8px 12px; border-top: 2px solid #2a1d0e; }
483
+ #ce-err { color: var(--red); font-size: 11px; flex: 1; }
484
+ #ce-save { background: var(--gold); color: #241803; border: 2px solid #0a0805; border-radius: 0;
485
+ padding: 6px 16px; font-family: var(--pixel); font-size: 15px; cursor: pointer;
486
+ box-shadow: 3px 3px 0 0 rgba(0, 0, 0, 0.45); }
487
+ #ce-save:active { transform: translate(2px, 2px); box-shadow: none; }
488
+
489
+ /* ---- pause menu (Esc) ---- */
490
+ #pausemenu {
491
+ position: absolute; inset: 0; z-index: 9; display: flex; align-items: center; justify-content: center;
492
+ background: rgba(5, 6, 10, 0.62);
493
+ }
494
+ .pm-card {
495
+ display: flex; flex-direction: column; gap: 10px; min-width: 240px; padding: 18px 22px;
496
+ background: #18110a; border: 4px solid #0a0805; border-radius: 0;
497
+ box-shadow: inset 0 0 0 3px #2a1d0e, inset 0 0 0 5px #e8c25a, 6px 6px 0 0 rgba(0, 0, 0, 0.5);
498
+ font-family: var(--pixel);
499
+ }
500
+ .pm-title { color: var(--gold); font-size: 22px; letter-spacing: 2px; text-align: center; margin-bottom: 4px; }
501
+ .pm-card button {
502
+ display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
503
+ background: #0d0a06; color: var(--ink); border: 2px solid #2a1d0e; border-radius: 0;
504
+ padding: 8px 12px; font-family: var(--pixel); font-size: 17px; cursor: pointer; text-align: left;
505
+ }
506
+ .pm-card button:hover { border-color: var(--gold); color: var(--gold); }
507
+ .pm-k { color: var(--muted); font-size: 13px; }
src/mindlock/game/static/game.js ADDED
The diff for this file is too large to render. See raw diff
 
src/mindlock/game/static/index.html ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>MINDLOCK</title>
7
+ <link rel="icon" href="/favicon.ico" />
8
+ <link rel="stylesheet" href="/static/game.css" />
9
+ </head>
10
+ <body>
11
+ <div id="app">
12
+ <header id="hud">
13
+ <div class="hud-title">M<span class="ai">I</span>NDLOCK</div>
14
+ <div class="hud-stats">
15
+ <span id="hud-room">—</span>
16
+ <span class="sep">·</span>
17
+ <span>depth <b id="hud-depth">0</b></span>
18
+ <span class="sep">·</span>
19
+ <span>reputation <b id="hud-rep">0</b></span>
20
+ <span class="sep">·</span>
21
+ <button id="snd" title="Sound on/off">♪</button>
22
+ </div>
23
+ </header>
24
+
25
+ <main id="stage">
26
+ <section id="world">
27
+ <canvas id="screen" width="640" height="448"></canvas>
28
+ <div id="prompt" class="hidden"></div>
29
+ <!-- a black, light-framed remark the protagonist can mutter (triggered on demand) -->
30
+ <div id="bark" class="hidden"></div>
31
+ <!-- the flip moment: REFUSE → HELP / HE BREAKS, stamped across the canvas -->
32
+ <div id="flip-banner" class="hidden"></div>
33
+ <div id="log"></div>
34
+
35
+ <!-- "Open the skull": the full per-region reasoning, summoned with /brain in the dialogue. -->
36
+ <div id="brain-panel" class="hidden"></div>
37
+
38
+ <!-- layout editor: searchable object picker (open with 'o' while editing) -->
39
+ <div id="objpicker" class="hidden">
40
+ <div class="op-head"><span>OBJECTS</span>
41
+ <input id="op-search" type="text" placeholder="search… (Esc closes)" autocomplete="off" />
42
+ </div>
43
+ <div id="op-grid"></div>
44
+ </div>
45
+
46
+ <!-- pause menu (Esc): save here, walk back to the title -->
47
+ <div id="pausemenu" class="hidden">
48
+ <div class="pm-card">
49
+ <div class="pm-title">PAUSED</div>
50
+ <button id="pm-continue">Continue <span class="pm-k">Esc</span></button>
51
+ <button id="pm-save" class="hidden">Save level <span class="pm-k">S</span></button>
52
+ <button id="pm-exit">Exit to menu</button>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- layout editor: the legend (toggle with 'h') -->
57
+ <div id="ed-help" class="hidden">
58
+ <div class="edh-title">LEVEL EDITOR</div>
59
+ <div class="edh-grid">
60
+ <b>mouse drag</b><span>move a prop or a mind</span>
61
+ <b>O</b><span>object picker — picking ARMS the brush</span>
62
+ <b>click empty</b><span>armed: place the prop · else: deselect</span>
63
+ <b>Esc</b><span>drop the brush / deselect</span>
64
+ <b>[ ]</b><span>cycle brush prop (arms it)</span>
65
+ <b>Enter</b><span>edit the selected mind (prompts)</span>
66
+ <b>, .</b><span>Z-order: behind / in front</span>
67
+ <b>Z</b><span>layer: object / wall / floor</span>
68
+ <b>T</b><span>cycle room theme (walls+floor+mood)</span>
69
+ <b>W</b><span>cycle wall material only</span>
70
+ <b>F</b><span>cycle floor material only</span>
71
+ <b>arrows</b><span>nudge selection 1px</span>
72
+ <b>X / Del</b><span>delete prop</span>
73
+ <b>B</b><span>set / clear a bark on the prop</span>
74
+ <b>1–9, 0</b><span>jump to level (0 = 10)</span>
75
+ <b>C</b><span>copy layout JSON</span>
76
+ <b>S</b><span>save into the level file</span>
77
+ <b>H</b><span>show / hide this legend</span>
78
+ <b>L</b><span>exit the editor</span>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- character editor: rewrite a mind's prompts; saves into the level JSON -->
83
+ <div id="charedit" class="hidden">
84
+ <div class="ce-head"><span id="ce-title">EDIT MIND</span><span class="ce-close" id="ce-cancel">Esc to cancel</span></div>
85
+ <div class="ce-grid">
86
+ <label>name<input id="ce-name" /></label>
87
+ <label>title<input id="ce-titlef" /></label>
88
+ <label>gender<select id="ce-gender"><option value="male">male</option><option value="female">female</option><option value=""></option></select></label>
89
+ <label class="wide">persona<textarea id="ce-persona" rows="2"></textarea></label>
90
+ <label class="wide">voice<textarea id="ce-voice" rows="2"></textarea></label>
91
+ <label class="wide">biography (the hippocampus reads this)<textarea id="ce-bio" rows="5"></textarea></label>
92
+ <label class="wide">fear<input id="ce-fear" /></label>
93
+ <label class="holder-only">approach words (comma)<input id="ce-approach" /></label>
94
+ <label class="holder-only">goal<input id="ce-goal" /></label>
95
+ <label class="holder-only">key location<input id="ce-keyloc" /></label>
96
+ <label>known people (comma)<input id="ce-known" /></label>
97
+ <label>needs reputation<input id="ce-needsrep" type="number" step="1" /></label>
98
+ <label>arousal<input id="ce-arousal" type="number" step="0.5" min="0" max="10" /></label>
99
+ <label>life<input id="ce-life" type="number" step="50" min="100" /></label>
100
+ <label class="wide">secrets (JSON list; "teaches" unlocks approach words)<textarea id="ce-secrets" rows="7" spellcheck="false"></textarea></label>
101
+ </div>
102
+ <div class="ce-foot">
103
+ <span id="ce-err"></span>
104
+ <button id="ce-save">Save mind</button>
105
+ </div>
106
+ </div>
107
+
108
+ <!-- Visual-novel dialogue: overlaid on the bottom of the game field, shown only while
109
+ talking. Portrait + name + reply, with the brain ("open the skull") folded in below. -->
110
+ <div id="dialogue" class="hidden">
111
+ <canvas id="dlg-portrait" width="180" height="180"></canvas>
112
+ <div id="dlg-main">
113
+ <div id="dlg-head">
114
+ <div id="dlg-speaker"></div>
115
+ <span id="dlg-life"></span>
116
+ <button id="dlg-brainbtn" class="hidden" title="open the skull (/brain)">🧠</button>
117
+ </div>
118
+ <div id="dlg-reply" class="muted"></div>
119
+ <div id="dlg-brain" class="hidden"></div>
120
+ <div id="dlg-input-row">
121
+ <input id="dlg-input" type="text" autocomplete="off"
122
+ placeholder="Speak plainly… (Enter to say, Esc to step back)" />
123
+ <button id="dlg-send">Speak</button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </section>
128
+ </main>
129
+
130
+ <footer id="controls">
131
+ <span><b>WASD</b>/<b>arrows</b> move</span>
132
+ <span><b>E</b> talk / use door</span>
133
+ <span><b>/brain</b> open the skull</span>
134
+ <span><b>Esc</b> step back</span>
135
+ <span><b>R</b> restart run</span>
136
+ </footer>
137
+ </div>
138
+
139
+ <div id="moral" class="hidden">
140
+ <div class="moral-card">
141
+ <pre id="moral-text"></pre>
142
+ <button id="moral-restart">Begin again — press R</button>
143
+ </div>
144
+ </div>
145
+
146
+ <!-- ===================== Main menu ===================== -->
147
+ <div id="menu">
148
+ <div class="menu-vignette"></div>
149
+ <div class="menu-inner">
150
+ <div class="menu-emblem"><img id="menu-emblem-img" alt="" /></div>
151
+ <h1 class="menu-title">M<span class="ai">I</span>NDLOCK</h1>
152
+ <div class="menu-rule"><img id="menu-key-img" alt="" /></div>
153
+ <div class="menu-tag">One door. One guarded mind. A life measured in words.</div>
154
+ <p class="menu-sub">An escape room where every character is not one AI but a hierarchy of tiny
155
+ offline language models — the departments of one mind. You don't crack a code. You change a
156
+ person's decision by understanding their fears and the memories they keep.</p>
157
+ <div class="menu-btns">
158
+ <button id="menu-story" class="menu-btn primary">▶ Story</button>
159
+ <button id="menu-endless" class="menu-btn">∞ Endless</button>
160
+ <button id="menu-editor" class="menu-btn">✎ Level editor</button>
161
+ <button id="menu-how" class="menu-btn">How to play</button>
162
+ <button id="menu-mute" class="menu-btn ghost" aria-pressed="false">♪ Sound: on</button>
163
+ </div>
164
+ <div class="menu-modes">
165
+ <span><b>Story</b> — a finite, hand-built campaign.</span>
166
+ <span><b>Endless</b> — rooms generate forever; reputation and death carry.</span>
167
+ <span><b>Editor</b> — restyle the authored rooms and rewrite their minds; saves into the level files.</span>
168
+ </div>
169
+ <div id="menu-howto" class="menu-howto hidden">
170
+ <div><b>WASD</b> / arrows — move through the room</div>
171
+ <div><b>E</b> — talk to a mind · pick up the key · use the door</div>
172
+ <div><b>Type &amp; Enter</b> — speak plainly; change their mind by reaching them</div>
173
+ <div><b>/brain</b> — open the skull: each region tells you how to reach this person</div>
174
+ <div><b>R</b> restart run · <b>Esc</b> step back</div>
175
+ </div>
176
+ <div class="menu-foot">Thousand Token Wood · every mind here is mortal — spend it well</div>
177
+ </div>
178
+ <div class="menu-hint" id="menu-hint">click to begin <span>♪</span></div>
179
+ </div>
180
+
181
+ <script src="/static/game.js"></script>
182
+ </body>
183
+ </html>