Spaces:
Sleeping
Sleeping
Mindlock: 10-room story mode, llama.cpp brain cascade, custom front
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +11 -0
- README.md +28 -6
- app.py +114 -0
- config/characters/aldous.json +21 -0
- config/characters/lena.json +21 -0
- config/characters/sam.json +22 -0
- config/characters/warden.json +19 -0
- config/generated/scenario/characters/nalini.json +15 -0
- config/generated/scenario/characters/rohan.json +47 -0
- config/generated/scenario/world.json +13 -0
- config/minicpm5-nothink.Modelfile +22 -0
- config/roster/adriana-vasquez.json +20 -0
- config/roster/astrid-jensen.json +20 -0
- config/roster/julian-reyes.json +20 -0
- config/roster/kael-jensen.json +20 -0
- config/roster/kaelin-reyes.json +20 -0
- config/story-v1/01-the-holding-cell.json +309 -0
- config/story-v1/02-the-records-office.json +275 -0
- config/story-v1/03-the-boiler-room.json +102 -0
- config/story-v1/04-the-infirmary.json +45 -0
- config/story-v1/05-the-gatehouse.json +44 -0
- config/story/01-the-white-room.json +477 -0
- config/story/02-the-living-room.json +412 -0
- config/story/03-the-hallway.json +268 -0
- config/story/04-the-open-floor.json +318 -0
- config/story/05-the-studio.json +341 -0
- config/story/06-the-corner-office.json +370 -0
- config/story/07-the-platform.json +324 -0
- config/story/08-the-break-room.json +309 -0
- config/story/09-the-crossing.json +309 -0
- config/story/10-the-tunnel.json +242 -0
- config/world.json +21 -0
- requirements.txt +5 -0
- scripts/flux/out/aldous.png +3 -0
- scripts/flux/out/lena.png +3 -0
- scripts/flux/out/sam.png +3 -0
- scripts/flux/out/warden.png +3 -0
- src/mindlock/__init__.py +11 -0
- src/mindlock/backend.py +279 -0
- src/mindlock/brain.py +782 -0
- src/mindlock/character.py +81 -0
- src/mindlock/cli.py +84 -0
- src/mindlock/game/__init__.py +6 -0
- src/mindlock/game/server.py +113 -0
- src/mindlock/game/session.py +440 -0
- src/mindlock/game/static/audio/menu.mp3 +3 -0
- src/mindlock/game/static/fonts/VT323.ttf +3 -0
- src/mindlock/game/static/game.css +507 -0
- src/mindlock/game/static/game.js +0 -0
- 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
|
|
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
scripts/flux/out/lena.png
ADDED
|
Git LFS Details
|
scripts/flux/out/sam.png
ADDED
|
Git LFS Details
|
scripts/flux/out/warden.png
ADDED
|
Git LFS Details
|
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 & 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>
|