Alptraum commited on
Commit
3481136
·
verified ·
1 Parent(s): 329a598

The Committee — two cats judge your webcam (local MiniCPM + Llama via llama.cpp)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/settings.local.json +24 -0
  2. .dockerignore +12 -0
  3. .gitignore +24 -0
  4. Dockerfile +26 -0
  5. README.md +64 -4
  6. app.py +182 -0
  7. app_kitchen.py +77 -0
  8. arduino/desk_companion.ino +123 -0
  9. companion/__init__.py +27 -0
  10. companion/crowd.py +1190 -0
  11. companion/duo.py +207 -0
  12. companion/model.py +92 -0
  13. companion/personas.py +262 -0
  14. companion/personas_data.json +1322 -0
  15. companion/pet_persona.py +271 -0
  16. companion/runner.py +138 -0
  17. companion/sensors.py +242 -0
  18. companion/text.py +69 -0
  19. companion/thoughts.py +41 -0
  20. companion/ui.py +38 -0
  21. companion/vlm.py +385 -0
  22. requirements.txt +14 -0
  23. run.sh +34 -0
  24. server.py +278 -0
  25. theater/assets/aliens/anthropologist.png +0 -0
  26. theater/assets/aliens/art_critic.png +0 -0
  27. theater/assets/aliens/conspiracist.png +0 -0
  28. theater/assets/aliens/devout.png +0 -0
  29. theater/assets/aliens/doom_prophet.png +0 -0
  30. theater/assets/aliens/elder.png +0 -0
  31. theater/assets/aliens/foodie.png +0 -0
  32. theater/assets/aliens/heckler.png +0 -0
  33. theater/assets/aliens/merch_vendor.png +0 -0
  34. theater/assets/aliens/romantic.png +0 -0
  35. theater/assets/aliens/sportscaster.png +0 -0
  36. theater/assets/aliens/tourist.png +0 -0
  37. theater/assets/props/confetti.png +0 -0
  38. theater/assets/props/pulse_ring.png +0 -0
  39. theater/assets/props/static.png +0 -0
  40. theater/assets/props/tile_glow.png +0 -0
  41. theater/assets/props/torch.png +0 -0
  42. theater/duo.html +168 -0
  43. theater/index.html +202 -0
  44. theater/js/aliencard.js +242 -0
  45. theater/js/bubbles.js +76 -0
  46. theater/js/capture.js +239 -0
  47. theater/js/duo.js +157 -0
  48. theater/js/main.js +145 -0
  49. theater/js/scene3d.js +785 -0
  50. theater/js/thoughts.js +125 -0
.claude/settings.local.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:huggingface.co)",
5
+ "Bash(.venv/bin/python -)",
6
+ "Bash(echo \"exit: $?\")",
7
+ "WebSearch",
8
+ "Bash(lsof -nP -iTCP:7860 -sTCP:LISTEN)",
9
+ "Bash(awk '{print $2}')",
10
+ "Bash(xargs -I{} kill {})",
11
+ "Bash(echo \"port clear: $\\(lsof -nP -iTCP:7860 -sTCP:LISTEN)",
12
+ "Bash(curl -s -o /dev/null http://127.0.0.1:7860/api/duo)",
13
+ "Bash(break)",
14
+ "Bash(echo \"listeners: $\\(lsof -nP -iTCP:7860 -sTCP:LISTEN)",
15
+ "Bash(command -v node)",
16
+ "Bash(node --check theater/js/duo.js)",
17
+ "Bash(./run.sh)",
18
+ "Read(//private/tmp/**)",
19
+ "Bash(curl -s -X POST http://127.0.0.1:7860/api/frame -H 'Content-Type: application/json' -d \"{\\\\\"image\\\\\": \\\\\"$\\(cat /tmp/frame_desk.txt\\)\\\\\"}\")",
20
+ "Bash(curl -s http://127.0.0.1:7860/api/duo)",
21
+ "Bash(.venv/bin/python -c ' *)"
22
+ ]
23
+ }
24
+ }
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ models/
5
+ *.gguf
6
+ .git/
7
+ .gradio/
8
+ flagged/
9
+ *.log
10
+ arduino/
11
+ .env
12
+ .env.*
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ env/
7
+
8
+ # Models (downloaded GGUF weights — too big for git)
9
+ models/
10
+ *.gguf
11
+
12
+ # OS / editor
13
+ .DS_Store
14
+ .idea/
15
+ .vscode/
16
+
17
+ # HF / runtime
18
+ .gradio/
19
+ flagged/
20
+ *.log
21
+
22
+ # Secrets — NEVER commit (HF_TOKEN etc.); set these in the Space's Settings → Secrets
23
+ .env
24
+ .env.*
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The Committee — HF Space (docker SDK, app_port 7860).
2
+ # CPU-friendly: moondream2 eyes + Llama-3.2-3B brain via llama.cpp, with the
3
+ # funny stub bank as instant fallback so judges always get a lively response.
4
+ FROM python:3.11-slim
5
+
6
+ # build-essential is a safety net; the CPU wheel from the extra index normally
7
+ # means llama-cpp-python installs without compiling.
8
+ RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Run as a non-root user with a writable home (HF Spaces convention).
12
+ RUN useradd -m -u 1000 user
13
+ USER user
14
+ ENV HOME=/home/user \
15
+ PATH=/home/user/.local/bin:$PATH \
16
+ HF_HOME=/home/user/.cache/huggingface \
17
+ PORT=7860 \
18
+ VLM_AUTOFETCH=1
19
+
20
+ WORKDIR /home/user/app
21
+ COPY --chown=user requirements.txt .
22
+ RUN pip install --no-cache-dir --user -r requirements.txt
23
+ COPY --chown=user . .
24
+
25
+ EXPOSE 7860
26
+ CMD ["python", "theater_server.py"]
README.md CHANGED
@@ -1,10 +1,70 @@
1
  ---
2
  title: The Committee
3
- emoji: 🐠
4
  colorFrom: indigo
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: The Committee
3
+ emoji: 🐱
4
  colorFrom: indigo
5
+ colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ license: mit
10
  ---
11
 
12
+ # 🐱 The Committee
13
+
14
+ **Two house cats intercepted your webcam. They understand exactly what you're
15
+ doing. They are judging you for it.**
16
+
17
+ Point your camera at yourself and **Mochi** (a manic kitten who must attack
18
+ everything) and **Loaf** (an enormous, withering elder who lives for food) watch
19
+ through the window and react like cats — demanding food, plotting, holding
20
+ grudges, pouncing at the glass. Built for the Hugging Face **Build Small**
21
+ hackathon (Thousand Token Wood). Everything runs locally — no cloud APIs.
22
+
23
+ ## How it works
24
+
25
+ ```
26
+ your webcam ──(only on change, never stored)──▶ ① SEE: a vision model captions the frame
27
+ ② JUDGE: a small LLM voices Mochi & Loaf,
28
+ situation-aware (you appeared / left /
29
+ are eating / on your phone / in the dark)
30
+ and grudge-aware (running gags)
31
+ both run in-process via llama.cpp — no API
32
+ ```
33
+
34
+ - **Drag any image** onto the page and the cats will judge *it* (your lunch, your dog, a meme).
35
+ - **Dim your lights** → their pupils dilate like real cats. **A fresh verdict** → Mochi pounces.
36
+ - **Share their verdict** copies a postable caption.
37
+ - Open the **🧠 HIVEMIND** trace to watch the whole pipeline think.
38
+
39
+ ## Models (all local, via llama.cpp — no cloud)
40
+
41
+ | Role | Hosted Space (CPU) | Local demo (Apple Silicon) |
42
+ |---|---|---|
43
+ | 👁 Eyes (scene) | moondream2 (GGUF) | **MiniCPM-V 2.6** (OpenBMB) |
44
+ | 🧠 Brain (the cats) | Llama-3.2-3B (GGUF) | **MiniCPM3-4B** (OpenBMB) |
45
+
46
+ Total well under the 32B cap. Swap models with env vars
47
+ (`VLM_REPO` / `COMPANION_MODEL_REPO`). A deterministic stub keeps the cats funny
48
+ even before the models warm up.
49
+
50
+ Badges: **Off the Grid** (no cloud APIs) · **Llama Champion** (llama.cpp runtime)
51
+ · **Off-Brand** (custom three.js-free canvas/SVG frontend) · **OpenBMB** (MiniCPM-V + MiniCPM3).
52
+
53
+ ## Run locally
54
+
55
+ ```bash
56
+ pip install -r requirements.txt
57
+ ./run.sh # all-OpenBMB: MiniCPM-V eyes + MiniCPM3-4B brain
58
+ FAST=1 ./run.sh # moondream2 eyes + Llama-3.2-3B brain (snappier)
59
+ # open http://localhost:7860
60
+ ```
61
+
62
+ On Apple Silicon, install the Metal build of llama-cpp-python for fast local
63
+ inference (`--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal`).
64
+
65
+ ## Credits
66
+
67
+ Built with **Llama** (Meta) and **MiniCPM / MiniCPM-V** (OpenBMB), run via
68
+ [llama.cpp](https://github.com/ggml-org/llama.cpp) /
69
+ [llama-cpp-python](https://github.com/abetlen/llama-cpp-python). Webcam frames are
70
+ described on-device and never stored or uploaded.
app.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Desk Companion — a context-aware ASCII visual diary.
2
+
3
+ HF "Build Small" submission, Thousand Token Wood track. One small local model
4
+ (llama.cpp) senses the room — through YOUR browser's webcam/mic (light + sound
5
+ computed client-side; only two numbers reach the app), through a real Arduino
6
+ over serial, or through a simulated day — and writes a moody diary + ASCII art,
7
+ pushing a short line to the (virtual or physical) OLED screen.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ import gradio as gr
15
+
16
+ import threading
17
+
18
+ from companion import CompanionRunner, DeskPersona
19
+ from companion.runner import POLL_S
20
+ from companion.text import bar
21
+ from companion.ui import CSS, FORCE_DARK_JS, theme
22
+
23
+ WELCOME_ART = r"""
24
+ .--------------.
25
+ | hello, you |
26
+ | ( o_o ) |
27
+ | watching |
28
+ | your room |
29
+ '--------------'
30
+ """
31
+
32
+ EXPLAINER = """
33
+ **Your desk has a tiny spirit that keeps a diary.** It feels the room —
34
+ brightness, sound, the hour — and when something shifts (you switch the
35
+ lights off, the night gets long) it writes a short moody diary entry, draws
36
+ ASCII art on its little screen, and leaves you a two-line note. One small
37
+ 3B model, running locally. No cloud.
38
+
39
+ **Try it:** ① press **🎥 sense my room** and allow camera+mic (nothing is
40
+ recorded — light & sound levels are computed in your browser) → ② turn your
41
+ lights off → ③ watch the screen react. Too shy? Press **✎ write now** and
42
+ it writes about the room as-is.
43
+ """
44
+
45
+ # Runs once when the user presses "sense my room": asks for camera+mic, then
46
+ # keeps window._dc.{light,sound} updated twice a second. All processing stays
47
+ # in the browser — frames and audio never leave the page.
48
+ START_SENSING_JS = """
49
+ async () => {
50
+ if (window._dc && window._dc.started) return;
51
+ window._dc = {light: -1, sound: -1, started: true};
52
+ try {
53
+ const stream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
54
+ const video = document.createElement('video');
55
+ video.srcObject = stream; video.muted = true; video.playsInline = true;
56
+ await video.play();
57
+ const canvas = document.createElement('canvas');
58
+ canvas.width = 32; canvas.height = 24;
59
+ const ctx = canvas.getContext('2d', {willReadFrequently: true});
60
+ const ac = new AudioContext();
61
+ const analyser = ac.createAnalyser();
62
+ analyser.fftSize = 256;
63
+ ac.createMediaStreamSource(stream).connect(analyser);
64
+ const buf = new Uint8Array(analyser.fftSize);
65
+ setInterval(() => {
66
+ try {
67
+ ctx.drawImage(video, 0, 0, 32, 24);
68
+ const d = ctx.getImageData(0, 0, 32, 24).data;
69
+ let lum = 0;
70
+ for (let i = 0; i < d.length; i += 4)
71
+ lum += 0.2126*d[i] + 0.7152*d[i+1] + 0.0722*d[i+2];
72
+ window._dc.light = Math.round(lum / (d.length/4) / 255 * 100);
73
+ analyser.getByteTimeDomainData(buf);
74
+ let rms = 0;
75
+ for (let i = 0; i < buf.length; i++) {
76
+ const v = (buf[i] - 128) / 128; rms += v*v;
77
+ }
78
+ window._dc.sound = Math.round(Math.min(100, Math.sqrt(rms/buf.length) * 400));
79
+ } catch (e) { /* keep last values */ }
80
+ }, 500);
81
+ } catch (e) {
82
+ window._dc.started = false; // permission denied — stay on simulation
83
+ }
84
+ }
85
+ """
86
+
87
+ # Every poll tick: hand the latest browser readings to Python (-1 = not live).
88
+ READ_SENSORS_JS = """
89
+ (a, b) => {
90
+ const d = window._dc || {};
91
+ return [d.light ?? -1, d.sound ?? -1];
92
+ }
93
+ """
94
+
95
+
96
+ def build():
97
+ runner = CompanionRunner(DeskPersona(), mock_mode="day", browser=True)
98
+
99
+ def status_md():
100
+ r = runner.latest()
101
+ clk = r.clock if r else "--:--"
102
+ return runner.status_md() + f" · **desk clock** `{clk}`"
103
+
104
+ def gauges_md():
105
+ r = runner.latest()
106
+ if r is None:
107
+ return "```\nwaiting for the desk to wake up...\n```"
108
+ temp = "" if math.isnan(r.temp) else f"temp {r.temp:5.1f}C\n"
109
+ return ("```\n"
110
+ f"light {r.light:3d}% {bar(r.light)}\n"
111
+ f"sound {r.sound:3d}% {bar(r.sound)}\n"
112
+ f"{temp}```")
113
+
114
+ def art_text():
115
+ return runner.get("ascii") or WELCOME_ART
116
+
117
+ def screen_text():
118
+ return (runner.get("screen") or "...").replace("|", "\n")
119
+
120
+ def feed_md():
121
+ entries = runner.feed()
122
+ if not entries:
123
+ return "_warming up — the first entry writes itself in a few seconds..._"
124
+ return "\n\n---\n\n".join(
125
+ f"**`{e['clock']}`** · _{e['trigger']}_ \n{e['entry']} \n"
126
+ f"<sub>{e['context']}</sub>" for e in entries
127
+ )
128
+
129
+ def on_tick(light, sound):
130
+ runner.ingest_browser(light, sound)
131
+ return status_md(), gauges_md(), art_text(), screen_text(), feed_md()
132
+
133
+ # First entry writes itself shortly after boot so the page is never blank.
134
+ def first_entry():
135
+ for _ in range(20):
136
+ if runner.latest() is not None:
137
+ runner.generate(auto=True)
138
+ return
139
+ import time
140
+ time.sleep(0.5)
141
+ threading.Thread(target=first_entry, daemon=True).start()
142
+
143
+ with gr.Blocks(title="Desk Companion") as demo:
144
+ gr.Markdown("# ▚ desk companion")
145
+ gr.Markdown(EXPLAINER, elem_id="hero")
146
+ status = gr.Markdown(status_md())
147
+ with gr.Row():
148
+ with gr.Column(scale=1):
149
+ sense_btn = gr.Button("🎥 sense my room", variant="secondary")
150
+ gr.Markdown("### sensors")
151
+ gauges = gr.Markdown(gauges_md())
152
+ gr.Markdown("### screen (OLED)")
153
+ art = gr.Markdown(elem_id="oled", value=art_text())
154
+ screen = gr.Markdown(elem_id="screen", value=screen_text())
155
+ with gr.Accordion("hardware (optional Arduino)", open=False):
156
+ port = gr.Textbox(label="serial port",
157
+ placeholder="/dev/tty.usbmodem... (blank = no device)")
158
+ connect = gr.Button("connect")
159
+ write_btn = gr.Button("✎ write now", variant="primary")
160
+ with gr.Column(scale=2):
161
+ gr.Markdown("### diary")
162
+ feed = gr.Markdown(feed_md())
163
+
164
+ h_light = gr.Number(value=-1, visible=False)
165
+ h_sound = gr.Number(value=-1, visible=False)
166
+
167
+ sense_btn.click(None, js=START_SENSING_JS)
168
+ gr.Timer(POLL_S).tick(
169
+ on_tick,
170
+ inputs=[h_light, h_sound],
171
+ outputs=[status, gauges, art, screen, feed],
172
+ js=READ_SENSORS_JS,
173
+ )
174
+ write_btn.click(lambda: (runner.generate(False), art_text(), screen_text(), feed_md())[1:],
175
+ outputs=[art, screen, feed])
176
+ connect.click(runner.connect, inputs=[port], outputs=[status])
177
+
178
+ return demo
179
+
180
+
181
+ if __name__ == "__main__":
182
+ build().launch(css=CSS, theme=theme(), js=FORCE_DARK_JS)
app_kitchen.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kitchen Coach — a real-time stovetop / brew assistant.
2
+
3
+ HF "Build Small" submission, Backyard AI track. Same engine as Desk Companion:
4
+ one small local model (llama.cpp, ~3B) reads a temperature probe and, given the
5
+ dish you're making, prints the exact next action to a physical OLED — e.g.
6
+ 'T:92C | Drop lentils now'. Runs fully in mock mode (a simulated heating pot).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gradio as gr
12
+
13
+ from companion import CompanionRunner, KitchenPersona
14
+ from companion.runner import POLL_S
15
+ from companion.text import bar
16
+ from companion.ui import CSS, FORCE_DARK_JS, theme
17
+
18
+
19
+ def build():
20
+ runner = CompanionRunner(KitchenPersona(), mock_mode="heating",
21
+ goal="red lentil dahl")
22
+
23
+ def temp_md():
24
+ r = runner.latest()
25
+ if r is None:
26
+ return "```\nwaiting for the probe...\n```"
27
+ pct = int(max(0, min(100, r.temp))) # 0–100C maps cleanly to the bar
28
+ phase = runner.persona.phase(r.temp)
29
+ return ("```\n"
30
+ f"temp {r.temp:5.1f}C {bar(pct)}\n"
31
+ f"phase {phase}\n```")
32
+
33
+ def screen_text():
34
+ return (runner.get("screen") or "...").replace("|", "\n")
35
+
36
+ def feed_md():
37
+ entries = runner.feed()
38
+ if not entries:
39
+ return "_No guidance yet. Set the dish and start heating — or press **coach me**._"
40
+ return "\n\n---\n\n".join(
41
+ f"**`{e['context']}`** \n→ {e['entry']}" for e in entries
42
+ )
43
+
44
+ with gr.Blocks(title="Kitchen Coach") as demo:
45
+ gr.Markdown("# ♨ kitchen coach\nReal-time stovetop guidance from one tiny local model.")
46
+ status = gr.Markdown(runner.status_md())
47
+ with gr.Row():
48
+ with gr.Column(scale=1):
49
+ dish = gr.Textbox(label="what are you making?", value="red lentil dahl")
50
+ set_btn = gr.Button("set dish")
51
+ gr.Markdown("### probe")
52
+ temp = gr.Markdown(temp_md())
53
+ gr.Markdown("### screen (OLED)")
54
+ screen = gr.Markdown(elem_id="screen", value=screen_text())
55
+ with gr.Accordion("hardware", open=False):
56
+ port = gr.Textbox(label="serial port",
57
+ placeholder="/dev/tty.usbmodem... (blank = mock)")
58
+ connect = gr.Button("connect")
59
+ coach_btn = gr.Button("♨ coach me", variant="primary")
60
+ with gr.Column(scale=2):
61
+ gr.Markdown("### guidance")
62
+ feed = gr.Markdown(feed_md())
63
+
64
+ gr.Timer(POLL_S).tick(
65
+ lambda: (runner.status_md(), temp_md(), screen_text(), feed_md()),
66
+ outputs=[status, temp, screen, feed],
67
+ )
68
+ set_btn.click(runner.set_goal, inputs=[dish], outputs=[status])
69
+ coach_btn.click(lambda: (runner.generate(False), screen_text(), feed_md())[1:],
70
+ outputs=[screen, feed])
71
+ connect.click(runner.connect, inputs=[port], outputs=[status])
72
+
73
+ return demo
74
+
75
+
76
+ if __name__ == "__main__":
77
+ build().launch(css=CSS, theme=theme(), js=FORCE_DARK_JS)
arduino/desk_companion.ino ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Desk Companion — Arduino firmware
3
+ * --------------------------------------------------------------------------
4
+ * The "body": reads desk sensors, streams them to the laptop over USB serial,
5
+ * and renders whatever text the laptop sends back onto the OLED screen.
6
+ *
7
+ * The model NEVER runs here. This sketch is pure I/O.
8
+ *
9
+ * Wiring (Arduino Uno/Nano; adjust pins for ESP32):
10
+ * - Photoresistor (LDR) divider .... A0
11
+ * - Microphone/sound module (AO) ... A1 (optional; omit -> sends 0)
12
+ * - DHT22 / DHT11 data ............. D2 (optional; omit -> sends nan)
13
+ * - SSD1306 OLED 128x64 (I2C) ...... SDA/SCL (A4/A5 on Uno)
14
+ *
15
+ * Protocol:
16
+ * OUT (every ~1s): LIGHT:<0-100>,SOUND:<0-100>,TEMP:<float>\n
17
+ * IN (a line): up to 2 rows, '|' separates rows, e.g. Go to sleep,|builder.\n
18
+ *
19
+ * Libraries (install via Library Manager):
20
+ * Adafruit GFX, Adafruit SSD1306, DHT sensor library.
21
+ * If you don't have a DHT sensor, comment out USE_DHT below.
22
+ */
23
+
24
+ #include <Wire.h>
25
+ #include <Adafruit_GFX.h>
26
+ #include <Adafruit_SSD1306.h>
27
+
28
+ #define USE_DHT // <-- comment this line out if you have no DHT sensor
29
+
30
+ #ifdef USE_DHT
31
+ #include <DHT.h>
32
+ #define DHTPIN 2
33
+ #define DHTTYPE DHT22 // change to DHT11 if that's what you have
34
+ DHT dht(DHTPIN, DHTTYPE);
35
+ #endif
36
+
37
+ #define LDR_PIN A0
38
+ #define MIC_PIN A1
39
+
40
+ #define SCREEN_W 128
41
+ #define SCREEN_H 64
42
+ Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, -1);
43
+
44
+ unsigned long lastSend = 0;
45
+ const unsigned long SEND_INTERVAL = 1000; // ms
46
+
47
+ void setup() {
48
+ Serial.begin(9600);
49
+ #ifdef USE_DHT
50
+ dht.begin();
51
+ #endif
52
+ // 0x3C is the common I2C address for 128x64 SSD1306 boards.
53
+ if (display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
54
+ display.clearDisplay();
55
+ display.setTextSize(1);
56
+ display.setTextColor(SSD1306_WHITE);
57
+ display.setCursor(0, 0);
58
+ display.println(F("desk companion"));
59
+ display.println(F("waking up..."));
60
+ display.display();
61
+ }
62
+ }
63
+
64
+ int readPercent(int pin) {
65
+ // 10-bit ADC (0-1023) -> 0-100%.
66
+ return map(analogRead(pin), 0, 1023, 0, 100);
67
+ }
68
+
69
+ void sendReadings() {
70
+ int light = readPercent(LDR_PIN);
71
+ int sound = readPercent(MIC_PIN);
72
+
73
+ float temp = NAN;
74
+ #ifdef USE_DHT
75
+ temp = dht.readTemperature(); // Celsius
76
+ #endif
77
+
78
+ Serial.print("LIGHT:");
79
+ Serial.print(light);
80
+ Serial.print(",SOUND:");
81
+ Serial.print(sound);
82
+ Serial.print(",TEMP:");
83
+ if (isnan(temp)) Serial.print("nan");
84
+ else Serial.print(temp, 1);
85
+ Serial.print("\n");
86
+ }
87
+
88
+ void renderMessage(String msg) {
89
+ display.clearDisplay();
90
+ display.setTextSize(2); // big, screen-friendly text
91
+ display.setTextColor(SSD1306_WHITE);
92
+ display.setCursor(0, 8);
93
+ // '|' splits rows; print each on its own line.
94
+ int start = 0;
95
+ int y = 8;
96
+ while (start <= msg.length()) {
97
+ int bar = msg.indexOf('|', start);
98
+ String row = (bar == -1) ? msg.substring(start) : msg.substring(start, bar);
99
+ display.setCursor(0, y);
100
+ display.println(row);
101
+ y += 20;
102
+ if (bar == -1) break;
103
+ start = bar + 1;
104
+ }
105
+ display.display();
106
+ }
107
+
108
+ void loop() {
109
+ // 1) Stream sensor readings on a steady cadence.
110
+ if (millis() - lastSend >= SEND_INTERVAL) {
111
+ lastSend = millis();
112
+ sendReadings();
113
+ }
114
+
115
+ // 2) Whenever the laptop sends a line, show it on the OLED.
116
+ if (Serial.available() > 0) {
117
+ String incoming = Serial.readStringUntil('\n');
118
+ incoming.trim();
119
+ if (incoming.length() > 0) {
120
+ renderMessage(incoming);
121
+ }
122
+ }
123
+ }
companion/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Desk Companion — shared engine for small-model + sensor + screen products.
2
+
3
+ One small local model (llama.cpp) turns sensor readings into text/ASCII and
4
+ pushes a short line to a physical OLED. A *persona* configures prompts, trigger
5
+ and fallbacks per product; the sensor layer, model wrapper and runner are shared.
6
+ """
7
+
8
+ # OLED SSD1306 128x64 at text size 1 ≈ 21 cols x 8 rows.
9
+ # We target a little smaller so the art always fits with a border.
10
+ # Defined before submodule imports below — model.py reads these at import time.
11
+ OLED_COLS = 20
12
+ OLED_ROWS = 8
13
+
14
+ from .sensors import (
15
+ Reading, SensorSource, MockSensorSource, SerialSensorSource,
16
+ BrowserSensorSource, open_sensor_source,
17
+ )
18
+ from .model import Companion
19
+ from .personas import Persona, DeskPersona, KitchenPersona, PERSONAS
20
+ from .runner import CompanionRunner
21
+
22
+ __all__ = [
23
+ "Reading", "SensorSource", "MockSensorSource", "SerialSensorSource",
24
+ "BrowserSensorSource", "open_sensor_source", "Companion", "Persona",
25
+ "DeskPersona", "KitchenPersona", "PERSONAS", "CompanionRunner",
26
+ "OLED_COLS", "OLED_ROWS",
27
+ ]
companion/crowd.py ADDED
@@ -0,0 +1,1190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GLORBVISION crowd simulation (B3) — 120 alien agents with opinion contagion.
2
+
3
+ Implements BUILD SPEC v1.0 sections 3 (archetypes/seating), 4 (contagion math,
4
+ moods, factions, events, crowd API) and the B3 slice of 5 (batched LLM voicing
5
+ with stub-bank fallback).
6
+
7
+ Pure stdlib. Deterministic (random.Random(1337)). Thread-safe via one RLock.
8
+ ALL Companion (model.raw_chat) calls are serialized behind the provided
9
+ llm_lock — the internal RLock is NEVER held across an LLM call.
10
+
11
+ Tick is normatively 0.5 s (2 Hz); tick(dt) accumulates arbitrary dt and runs
12
+ whole 0.5 s sub-steps.
13
+
14
+ Smoke test: .venv/bin/python -m companion.crowd
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import os
22
+ import random
23
+ import re
24
+ import threading
25
+ import time
26
+ from collections import deque
27
+
28
+ # --------------------------------------------------------------------------
29
+ # Spec tables (section 1-3)
30
+ # --------------------------------------------------------------------------
31
+
32
+ TICK_SECONDS = 0.5
33
+ ROWS, SEATS = 8, 15
34
+ N_ALIENS = ROWS * SEATS # 120
35
+
36
+ TAG_ORDER = ("food", "drink", "person", "tech", "animal",
37
+ "motion", "dark", "absence", "novelty")
38
+
39
+ MOODS = ("calm", "excited", "annoyed", "scared", "delighted", "confused")
40
+
41
+ LIME, MAGENTA, AMBER = "#9dff5a", "#ff5ad6", "#ffb84d"
42
+
43
+ ARCHETYPES = {
44
+ # key: (bias over tags, k susceptibility, resting sentiment, body hue)
45
+ "devout": ({"drink": 1.0, "food": 0.4}, 0.50, 0.0, LIME),
46
+ "heckler": ({"person": -0.4, "novelty": -0.2}, 0.25, -0.2, AMBER),
47
+ "anthropologist": ({"novelty": 0.8, "tech": 0.3}, 0.45, 0.0, MAGENTA),
48
+ "conspiracist": ({"absence": -0.6, "animal": -0.3, "dark": -0.2}, 0.35, -0.1, AMBER),
49
+ "sportscaster": ({"motion": 0.9, "person": 0.3}, 0.60, 0.0, LIME),
50
+ "foodie": ({"food": 1.0, "drink": 0.5}, 0.50, 0.0, MAGENTA),
51
+ "romantic": ({"person": 0.8, "animal": 0.5}, 0.60, 0.1, MAGENTA),
52
+ "doom_prophet": ({"tech": -0.6, "dark": -0.4}, 0.40, -0.1, AMBER),
53
+ "tourist": ({"novelty": 0.5, "person": 0.4, "animal": 0.4, "food": 0.3,
54
+ "drink": 0.3, "tech": 0.3, "motion": 0.3}, 0.80, 0.3, LIME),
55
+ "art_critic": ({"absence": 0.8, "dark": 0.6, "food": -0.3, "drink": -0.3,
56
+ "person": -0.3, "motion": -0.3}, 0.20, -0.3, AMBER),
57
+ "elder": ({"drink": 0.6, "person": 0.2, "motion": -0.4}, 0.15, 0.0, LIME),
58
+ "merch_vendor": ({"novelty": 0.9, "person": 0.2}, 0.55, 0.0, MAGENTA),
59
+ }
60
+
61
+ STOPWORDS = {"the", "a", "an", "is", "at", "on", "in", "of", "and", "to", "with", "its"}
62
+
63
+ # Keyword -> tag (covers both raw VLM nouns and Codex names so B3 works with
64
+ # either; B4 normally hands us Codex-substituted text).
65
+ _TAG_KEYWORDS = {
66
+ "food": ("food", "snack", "eating", "eats", "sandwich", "fruit", "pellet",
67
+ "pellets", "meal", "chew"),
68
+ "drink": ("mug", "cup", "coffee", "tea", "chalice", "bottle", "water",
69
+ "ocean", "drink", "drinks", "drinking", "sip", "sips", "steam"),
70
+ "person": ("person", "man", "woman", "face", "human", "protagonist",
71
+ "people", "he", "she", "someone"),
72
+ "tech": ("phone", "smartphone", "television", "keyboard", "typing",
73
+ "types", "laptop", "altar", "clatter", "glasses", "armor",
74
+ "headphones", "muzzles", "screen", "computer"),
75
+ "animal": ("cat", "overlord", "dog", "bark", "pet", "kitten", "puppy"),
76
+ "dark": ("dim", "dark", "shadow", "shadows", "darkness", "unlit"),
77
+ }
78
+ _MOTION_STEMS = ("reach", "stand", "wav", "mov", "gestur", "stretch", "lean",
79
+ "walk", "turn", "lift", "rais")
80
+ _ABSENCE_WORDS = ("empty", "absent", "absence", "nobody", "vacant", "gone")
81
+
82
+ EVENT_DURATIONS = { # in ticks (0.5 s each)
83
+ "new_scene": 4, "noticed": 8, "chant": 12, "gasp": 8,
84
+ "ovation": 12, "wave": 10, "boo": 8, "intermission": 10 ** 9,
85
+ }
86
+ EVENT_COOLDOWN_TICKS = 24 # 12 s; new_scene + noticed (+ post-intermission wave) exempt
87
+
88
+ NOTICED_BUBBLE = "IT SEES US. ACT NATURAL."
89
+
90
+ VOICE_SYSTEM_PROMPT = (
91
+ "You write reaction subtitles for an alien audience watching a human's "
92
+ "webcam as TV. One line per numbered alien. Each line under 8 words, "
93
+ "comedic, no quotes, no emoji, stay in each alien's voice. Use ONLY these "
94
+ "Earth names: mug=Sacred Steam Chalice, phone=Smaller Television, "
95
+ "keyboard=Clatter Altar, glasses=eye armor, headphones=ear muzzles, "
96
+ "water bottle=personal ocean, cat=Liquid Overlord, dog=Bark Unit, "
97
+ "food=fuel pellets, chair=Throne of Sitting, human=the Protagonist."
98
+ )
99
+
100
+ # "N ARCHETYPE: text" but models also emit "N. ARCHETYPE: text" / "N) text" —
101
+ # capture the number leniently; the archetype prefix is stripped separately.
102
+ _VOICE_LINE_RE = re.compile(r"^\s*(\d+)\s*[:.)\-]?\s*(.+)$")
103
+ _VOICE_PREFIX_RE = re.compile(r"^[A-Za-z _-]{2,24}(?:\s*\([^)]{0,40}\))?:\s*")
104
+
105
+ STUB_LINES = {
106
+ "devout": [
107
+ "THE CHALICE RISES. WE ARE BLESSED.",
108
+ "Another cycle in the Protagonist's light.",
109
+ "The steam carries our prayers.",
110
+ "Kneel. The rite begins.",
111
+ "Blessed be the Clatter Altar.",
112
+ "I have seen the foam. It is good.",
113
+ ],
114
+ "heckler": [
115
+ "Booo. My larva emotes better.",
116
+ "Rerun. Seen it. Next channel.",
117
+ "The Protagonist forgot its lines again.",
118
+ "Refund my attention span.",
119
+ "Even the Throne looks bored.",
120
+ "This meat sack peaked in cycle 12.",
121
+ ],
122
+ "anthropologist": [
123
+ "Note: the eye armor stores its dreams.",
124
+ "Fascinating grooming display. Possibly mating.",
125
+ "The shrug: ritual of plausible deniability.",
126
+ "Field note: Protagonist molts hourly.",
127
+ "The Chalice is clearly ceremonial.",
128
+ "Documenting this for the archive.",
129
+ ],
130
+ "conspiracist": [
131
+ "Same Chalice as cycle 12. WAKE UP.",
132
+ "What is BEHIND the Smaller Television?",
133
+ "The feed loops. I have proof.",
134
+ "They WANT us to watch this.",
135
+ "The Liquid Overlord reports to someone.",
136
+ "Notice the cables. NOTICE THEM.",
137
+ ],
138
+ "sportscaster": [
139
+ "HE REACHES— HE STRETCHES— UNBELIEVABLE!",
140
+ "What a play by the Protagonist!",
141
+ "The Chalice lift! Textbook form!",
142
+ "Replay that blink! REPLAY IT!",
143
+ "Folks, this is peak Earth Feed.",
144
+ "AND THE CROWD GOES WILD!",
145
+ ],
146
+ "foodie": [
147
+ "Fuel pellet, dry-aged in pocket. Bold.",
148
+ "Notes of lint and despair. Daring.",
149
+ "The Chalice pairs poorly with typing.",
150
+ "Undersalted. I can tell from here.",
151
+ "A brave plating choice: no plate.",
152
+ "Two stars. Ambitious crumbs.",
153
+ ],
154
+ "romantic": [
155
+ "The way it holds the Chalice... sobbing.",
156
+ "It blinked at us. I felt that.",
157
+ "Protagonist and Throne: meant to be.",
158
+ "Such tender clatter. True love.",
159
+ "I ship it with the lamp.",
160
+ "My hearts. All of them. Aching.",
161
+ ],
162
+ "doom_prophet": [
163
+ "The Clatter Altar summons something.",
164
+ "It always begins with the Chalice.",
165
+ "The dark comes. It always comes.",
166
+ "Mark this cycle. Remember the steam.",
167
+ "The Smaller Television watches back.",
168
+ "Doom. Comfortable, scheduled doom.",
169
+ ],
170
+ "tourist": [
171
+ "First time at Earth Feed!! Amazing!!",
172
+ "Is that THE Throne of Sitting??",
173
+ "Telling my whole pod about this.",
174
+ "Can it see my souvenir hat?",
175
+ "Best vacation cycle EVER.",
176
+ "Wow!! It moved!! WOW!!",
177
+ ],
178
+ "art_critic": [
179
+ "Derivative. The Chalice arc again.",
180
+ "Finally. Nothing. Cinema.",
181
+ "The lighting does all the work.",
182
+ "Season one was braver.",
183
+ "An empty frame would say more.",
184
+ "One star. For the lamp.",
185
+ ],
186
+ "elder": [
187
+ "It has not hydrated in 40 cycles.",
188
+ "Somebody beep its mother.",
189
+ "In my day the feed was grainier.",
190
+ "Sit straight, Protagonist. The spine.",
191
+ "We have seen ten thousand mugs.",
192
+ "Patience. The Chalice will rise.",
193
+ ],
194
+ "merch_vendor": [
195
+ "CHALICE FOAM HATS! Get your hats!",
196
+ "Protagonist plush, limited, two zorbs!",
197
+ "Clatter Altar keychains, going fast!",
198
+ "Fresh fuel pellets! Probably edible!",
199
+ "Poke-proof ponchos, half off!",
200
+ "NEW SCENE MERCH! HOT OFF THE LOOM!",
201
+ ],
202
+ }
203
+
204
+
205
+ # --------------------------------------------------------------------------
206
+ # Society upgrade banks (personas / opinions / rumors) — all deterministic
207
+ # --------------------------------------------------------------------------
208
+
209
+ PERSONAS_JSON = os.path.join(os.path.dirname(os.path.abspath(__file__)),
210
+ "personas_data.json")
211
+ DEFAULT_OPINION = "no strong feelings about the Protagonist yet"
212
+
213
+ # Opinion-of-the-Protagonist template banks; picked by (id + memory length)
214
+ # modulo — zero RNG, zero LLM. {ref} = the alien's obsession when the scene
215
+ # hits it, else a generic stand-in.
216
+ _OPINION_RISING = (
217
+ "warming up to the Protagonist since the {ref} incident",
218
+ "secretly starting to root for the Protagonist",
219
+ "suspects the Protagonist is improving. suspicious",
220
+ )
221
+ _OPINION_FALLING = (
222
+ "losing patience with the Protagonist after the {ref} business",
223
+ "officially disappointed in the Protagonist",
224
+ "drafting a strongly worded zorbgram about the Protagonist",
225
+ )
226
+ _OPINION_POS = (
227
+ "a confirmed Protagonist fan — the {ref} sealed it",
228
+ "would defend the Protagonist before any tribunal",
229
+ )
230
+ _OPINION_NEG = (
231
+ "remains thoroughly unimpressed by the Protagonist",
232
+ "convinced the Protagonist is wildly overrated",
233
+ )
234
+ _OPINION_FLAT = (
235
+ "still undecided about the Protagonist",
236
+ "watching the Protagonist strictly for the {ref}",
237
+ )
238
+
239
+ RUMOR_TEMPLATES = (
240
+ "the Protagonist feeds the {x} FIRST",
241
+ "the Protagonist whispers to the {x} at night",
242
+ "the {x} is a prop. ALL of it is props",
243
+ "the Protagonist keeps a SECOND {x} hidden",
244
+ "the Protagonist won the {x} in a bet",
245
+ )
246
+ _RUMOR_SUBJECTS = ("Liquid Overlord", "Sacred Steam Chalice", "Clatter Altar",
247
+ "Smaller Television", "Throne of Sitting", "Bark Unit")
248
+ # Telephone-game mutations applied on every 10th infection.
249
+ RUMOR_MUTATIONS = (
250
+ lambda t: t.replace("the Protagonist", "the so-called Protagonist", 1),
251
+ lambda t: t + ", ALLEGEDLY",
252
+ lambda t: "apparently " + t,
253
+ lambda t: t.replace("FIRST", "FIRST and LAST")
254
+ if "FIRST" in t else t + "!!",
255
+ lambda t: t.upper(),
256
+ )
257
+ RUMOR_MAX_LEN = 50 # "(psst: " + 50 + ")" fits a 60-char bubble
258
+ RUMOR_LIFETIME_TICKS = 120 # ~60 s, then it fades
259
+ RUMOR_HOP_P = 0.15 # per carrier per tick
260
+ RUMOR_WHISPER_P = 0.05 # per carrier per tick, max 2 concurrent
261
+ RUMOR_SCENE_SEED_P = 0.2 # ~every 5th new_scene
262
+
263
+
264
+ def _clamp(x, lo=-1.0, hi=1.0):
265
+ return lo if x < lo else hi if x > hi else x
266
+
267
+
268
+ def _tokens(text: str) -> set[str]:
269
+ return {w for w in re.findall(r"[a-z']+", text.lower()) if w not in STOPWORDS}
270
+
271
+
272
+ def extract_tags(description: str) -> set[str]:
273
+ """Keyword-match a (Codex-substituted or raw) scene string into the fixed tag set."""
274
+ toks = _tokens(description)
275
+ tags: set[str] = set()
276
+ for tag, words in _TAG_KEYWORDS.items():
277
+ if toks & set(words):
278
+ tags.add(tag)
279
+ if any(t.startswith(stem) for t in toks for stem in _MOTION_STEMS):
280
+ tags.add("motion")
281
+ if (toks & set(_ABSENCE_WORDS)) or "person" not in tags:
282
+ tags.add("absence")
283
+ return tags
284
+
285
+
286
+ # --------------------------------------------------------------------------
287
+ # Agent
288
+ # --------------------------------------------------------------------------
289
+
290
+ class _Alien:
291
+ __slots__ = ("id", "row", "seat", "archetype", "color", "bias", "k", "rest",
292
+ "s", "mood", "inj", "hist", "neighbors",
293
+ "rest_offset", "rest_offset_until", "last_spoke_round",
294
+ "name", "quirk", "obsession", "relationship", "tic",
295
+ "memory", "opinion")
296
+
297
+ def __init__(self, aid, row, seat, archetype, rng):
298
+ bias, k, rest, color = ARCHETYPES[archetype]
299
+ self.id, self.row, self.seat = aid, row, seat
300
+ self.archetype, self.color = archetype, color
301
+ self.bias, self.k, self.rest = bias, k, rest
302
+ self.s = _clamp(rest + rng.gauss(0, 0.08))
303
+ self.mood = "calm"
304
+ self.inj = 0.0
305
+ self.hist = [self.s] * 5 # ring buffer of last sentiments (oldest first)
306
+ self.neighbors: list[int] = []
307
+ self.rest_offset = 0.0
308
+ self.rest_offset_until = -1
309
+ self.last_spoke_round = -10
310
+ # Persona (overwritten from personas_data.json when present).
311
+ self.name = f"Blorp #{aid}"
312
+ self.quirk = ""
313
+ self.obsession = ""
314
+ self.relationship = None
315
+ self.tic = ""
316
+ # Rolling memory of (scene, said, sentiment) + evolving one-liner.
317
+ self.memory: deque = deque(maxlen=6)
318
+ self.opinion = DEFAULT_OPINION
319
+
320
+ def pull(self, tags: set[str]) -> float:
321
+ return _clamp(sum(w for t, w in self.bias.items() if t in tags))
322
+
323
+
324
+ # --------------------------------------------------------------------------
325
+ # Seating (section 3, deterministic with Random(1337))
326
+ # --------------------------------------------------------------------------
327
+
328
+ def _build_seating(rng: random.Random) -> dict[tuple[int, int], str]:
329
+ taken: dict[tuple[int, int], str] = {}
330
+
331
+ def place(seats, arch):
332
+ for rs in seats:
333
+ taken[rs] = arch
334
+
335
+ # devout: 10 front-center seats (rows 0-1, seats 4-10)
336
+ place([(0, s) for s in range(4, 11)] + [(1, 6), (1, 7), (1, 8)], "devout")
337
+ # hecklers: back row 7, left side
338
+ place([(7, s) for s in range(10)], "heckler")
339
+ # elders: rows 0-1 flanks
340
+ place([(0, s) for s in (0, 1, 2, 3, 11, 12, 13, 14)] + [(1, 0), (1, 14)], "elder")
341
+ # art critics: the four back corners +/-1 (skip occupied, widen until 10 placed)
342
+ crit: list[tuple[int, int]] = []
343
+ anchors = [(7, 14), (6, 0), (6, 14), (7, 0)]
344
+ radius = 0
345
+ while len(crit) < 10 and radius < SEATS:
346
+ for r, c in anchors:
347
+ for dc in ([0] if radius == 0 else [-radius, radius]):
348
+ rs = (r, c + dc)
349
+ if (0 <= rs[1] < SEATS and rs not in taken
350
+ and rs not in crit and len(crit) < 10):
351
+ crit.append(rs)
352
+ radius += 1
353
+ place(crit, "art_critic")
354
+
355
+ # remaining 8 archetypes in shuffled blocks of 2 (pairs sit adjacent ~50%)
356
+ rest = ["anthropologist", "conspiracist", "sportscaster", "foodie",
357
+ "romantic", "doom_prophet", "tourist", "merch_vendor"]
358
+ blocks = [a for a in rest for _ in range(5)] # 5 blocks of 2 each = 10 seats
359
+ rng.shuffle(blocks)
360
+ free = [(r, s) for r in range(ROWS) for s in range(SEATS) if (r, s) not in taken]
361
+ free.sort(key=lambda rs: rs[0] * SEATS + rs[1])
362
+ it = iter(free)
363
+ for arch in blocks:
364
+ taken[next(it)] = arch
365
+ taken[next(it)] = arch
366
+ return taken
367
+
368
+
369
+ # --------------------------------------------------------------------------
370
+ # CrowdSim
371
+ # --------------------------------------------------------------------------
372
+
373
+ class CrowdSim:
374
+ """120-agent opinion-contagion crowd. See module docstring."""
375
+
376
+ def __init__(self, model=None, llm_lock: threading.Lock | None = None,
377
+ thought_cb=None):
378
+ self._model = model
379
+ self._llm_lock = llm_lock or threading.Lock()
380
+ self._lock = threading.RLock()
381
+ self._rng = random.Random(1337)
382
+ # Optional trace hook: thought_cb(kind, title, body). Never allowed
383
+ # to break the sim.
384
+ cb = thought_cb or (lambda *a, **k: None)
385
+ def _safe_cb(kind, title, body=""):
386
+ try:
387
+ cb(kind, title, body)
388
+ except Exception:
389
+ pass
390
+ self._thought = _safe_cb
391
+ # Voiced bubbles waiting their turn (comic-timing rollout, 1/tick).
392
+ self._voice_queue: list[tuple[int, str]] = []
393
+
394
+ seating = _build_seating(self._rng)
395
+ self._aliens = [
396
+ _Alien(r * SEATS + s, r, s, seating[(r, s)], self._rng)
397
+ for r in range(ROWS) for s in range(SEATS)
398
+ ]
399
+ for a in self._aliens: # neighbors: L, R, ahead, behind
400
+ for dr, ds in ((0, -1), (0, 1), (-1, 0), (1, 0)):
401
+ r, s = a.row + dr, a.seat + ds
402
+ if 0 <= r < ROWS and 0 <= s < SEATS:
403
+ a.neighbors.append(r * SEATS + s)
404
+ self._load_personas()
405
+
406
+ self._tick_count = 0
407
+ self._dt_acc = 0.0
408
+ self._tags: set[str] = set()
409
+ self._scene = ""
410
+ self._last_scene_words: set[str] | None = None
411
+ self._ticks_since_scene = 10 ** 9
412
+
413
+ self._event: str | None = None
414
+ self._event_until = -1
415
+ self._last_event_start = -10 ** 9
416
+
417
+ self._noticed_cluster: set[int] = set()
418
+ self._noticed_sample_until = -1
419
+
420
+ self._bubbles: dict[int, tuple[str, int]] = {} # id -> (text, expire_tick)
421
+
422
+ self._light = -1
423
+ self._sound = -1
424
+ self._light_hist: list[tuple[float, int]] = []
425
+
426
+ self._faction_snapshot = self._factions_unlocked()
427
+ self._growth_faction = "love"
428
+ self._speak_round = 0
429
+ self._intermission_pending_wave = False
430
+
431
+ # Hot seat: ids of the previous scene's speakers (sticky ~60%).
432
+ self._hot: set[int] = set()
433
+ # Rumor: one at a time, telephone-game spread seat to seat.
434
+ self._rumor_text: str | None = None
435
+ self._rumor_carriers: set[int] = set()
436
+ self._rumor_until = -1
437
+ self._rumor_infections = 0
438
+
439
+ def _load_personas(self):
440
+ """Attach personas from personas_data.json; graceful when absent.
441
+ Accepts a list (positional or with 'id' fields) or an id-keyed dict,
442
+ optionally wrapped in {'personas': ...} / {'aliens': ...}."""
443
+ data = None
444
+ try:
445
+ with open(PERSONAS_JSON, encoding="utf-8") as f:
446
+ data = json.load(f)
447
+ except Exception:
448
+ data = None
449
+ if isinstance(data, dict):
450
+ data = data.get("personas") or data.get("aliens") or data
451
+ entries: dict[int, dict] = {}
452
+ if isinstance(data, list):
453
+ for i, e in enumerate(data):
454
+ if isinstance(e, dict):
455
+ try:
456
+ entries[int(e.get("id", i))] = e
457
+ except (TypeError, ValueError):
458
+ entries[i] = e
459
+ elif isinstance(data, dict):
460
+ for k, e in data.items():
461
+ if isinstance(e, dict):
462
+ try:
463
+ entries[int(k)] = e
464
+ except (TypeError, ValueError):
465
+ pass
466
+ loaded = 0
467
+ for a in self._aliens:
468
+ e = entries.get(a.id)
469
+ if not e:
470
+ continue
471
+ a.name = str(e.get("name") or a.name)[:40]
472
+ a.quirk = str(e.get("quirk") or "")
473
+ a.obsession = str(e.get("obsession") or "")
474
+ a.relationship = e.get("relationship")
475
+ a.tic = str(e.get("tic") or "")
476
+ loaded += 1
477
+ if loaded:
478
+ self._thought("event", "persona census loaded",
479
+ f"{loaded} personas from personas_data.json")
480
+
481
+ # ---------------- public spec API ----------------
482
+
483
+ def tick(self, dt: float = TICK_SECONDS):
484
+ """Advance the sim; runs one 0.5 s sub-step per accumulated 0.5 s of dt."""
485
+ with self._lock:
486
+ self._dt_acc += max(0.0, float(dt))
487
+ while self._dt_acc >= TICK_SECONDS - 1e-9:
488
+ self._dt_acc -= TICK_SECONDS
489
+ self._tick_once()
490
+
491
+ def on_scene(self, description: str) -> bool:
492
+ """Novelty-gate a (Codex-substituted) scene string; on accept, apply it
493
+ and run ONE batched raw_chat voicing call. Returns True if accepted."""
494
+ description = (description or "").strip()
495
+ if not description:
496
+ return False
497
+ words = _tokens(description)
498
+ with self._lock:
499
+ if self._last_scene_words is not None and words and self._last_scene_words:
500
+ union = words | self._last_scene_words
501
+ novelty = 1.0 - len(words & self._last_scene_words) / len(union)
502
+ else:
503
+ novelty = 1.0
504
+ if novelty < 0.4:
505
+ return False
506
+ self._last_scene_words = words
507
+ tags = extract_tags(description)
508
+ tags.add("novelty")
509
+ self._voice_queue.clear() # new scene supersedes unspoken lines
510
+ # NOTE: distinct from _speak_round (hot-seat sampling counter) —
511
+ # this one only advances per ACCEPTED scene, for the stale-voice guard.
512
+ self._voice_round = getattr(self, "_voice_round", 0) + 1
513
+ round_no = self._voice_round
514
+ self.apply_scene(description, tags)
515
+ speakers = self.sample_speakers(8)
516
+ scene, event = self._scene, self._event
517
+ love, meh, hate = self._faction_tuple()
518
+ self._voice(scene, event, (love, meh, hate), speakers, round_no)
519
+ return True
520
+
521
+ def apply_scene(self, scene: str, tags: set[str]):
522
+ """Set pulse + first reactors, fire new_scene (and intermission/wave)."""
523
+ with self._lock:
524
+ was_intermission = self._event == "intermission"
525
+ self._scene = scene
526
+ self._tags = set(tags) & set(TAG_ORDER)
527
+ self._ticks_since_scene = 0
528
+ self._faction_snapshot, prev = self._factions_unlocked(), self._faction_snapshot
529
+ self._growth_faction = max(
530
+ ("love", "meh", "hate"),
531
+ key=lambda f: self._faction_snapshot[f] - prev[f])
532
+
533
+ # first reactors: 16 seats weighted toward rows 0-2
534
+ weights = [4.0 if a.row <= 2 else 1.0 for a in self._aliens]
535
+ chosen: set[int] = set()
536
+ while len(chosen) < 16:
537
+ chosen.add(self._rng.choices(range(N_ALIENS), weights=weights)[0])
538
+ for aid in chosen:
539
+ a = self._aliens[aid]
540
+ a.inj += 0.5 * a.pull(self._tags)
541
+
542
+ if was_intermission and "absence" not in self._tags:
543
+ self._start_event("wave", exempt=True) # spec: immediately fire wave
544
+ else:
545
+ self._start_event("new_scene", exempt=True)
546
+ # gossip: ~every 5th new scene spawns a rumor
547
+ if self._rng.random() < RUMOR_SCENE_SEED_P:
548
+ self._seed_rumor("a new scene set tongues wagging")
549
+
550
+ def poke(self):
551
+ """NOTICED: epicenter + Chebyshev-2 fear cluster; always fires."""
552
+ with self._lock:
553
+ epi = self._aliens[self._rng.randrange(N_ALIENS)]
554
+ cluster = {
555
+ a.id for a in self._aliens
556
+ if max(abs(a.row - epi.row), abs(a.seat - epi.seat)) <= 2
557
+ }
558
+ for aid in cluster:
559
+ a = self._aliens[aid]
560
+ a.inj += -0.6
561
+ if a.archetype == "conspiracist":
562
+ a.rest_offset = -0.3
563
+ a.rest_offset_until = self._tick_count + 40
564
+ self._noticed_cluster = cluster
565
+ self._noticed_sample_until = self._tick_count + 20 # sampling override window
566
+ self._start_event("noticed", exempt=True)
567
+ self.set_bubble(epi.id, NOTICED_BUBBLE, guaranteed=True)
568
+ self._seed_rumor("being NOTICED demands an explanation")
569
+ return {"ok": True}
570
+
571
+ def sense(self, light: int, sound: int):
572
+ """Browser ambiance numbers; -1 = not live. Feeds the gasp trigger."""
573
+ with self._lock:
574
+ self._light, self._sound = int(light), int(sound)
575
+ now = time.monotonic()
576
+ if light >= 0:
577
+ self._light_hist.append((now, int(light)))
578
+ self._light_hist = [(t, v) for t, v in self._light_hist if now - t <= 5.0]
579
+
580
+ def _interest(self, a: _Alien, tags: set[str]) -> float:
581
+ """How much this alien cares about the current scene: archetype tag
582
+ bias + a bonus when their personal obsession is on screen."""
583
+ v = abs(a.pull(tags))
584
+ if a.obsession and ((extract_tags(a.obsession) - {"absence"}) & tags):
585
+ v += 0.5
586
+ return v
587
+
588
+ def sample_speakers(self, n: int = 8) -> list[dict]:
589
+ """HOT-SEAT sampling: ~60% of last scene's speakers stay (if they still
590
+ care), the rest filled interest-weighted, plus 1-2 uniform wildcards."""
591
+ with self._lock:
592
+ self._speak_round += 1
593
+ rnd = self._speak_round
594
+ tags = self._tags
595
+ eligible = [a for a in self._aliens if a.last_spoke_round < rnd - 1]
596
+ picks: list[_Alien] = []
597
+ arch_count: dict[str, int] = {}
598
+
599
+ def add(a: _Alien | None) -> bool:
600
+ if a is None or len(picks) >= n or a in picks:
601
+ return False
602
+ if arch_count.get(a.archetype, 0) >= 3:
603
+ return False
604
+ picks.append(a)
605
+ arch_count[a.archetype] = arch_count.get(a.archetype, 0) + 1
606
+ return True
607
+
608
+ def pool_of(base) -> list[_Alien]:
609
+ return [a for a in base if a not in picks
610
+ and arch_count.get(a.archetype, 0) < 3]
611
+
612
+ # 1) stickiness: previous speakers stay hot with p=0.6 when their
613
+ # interest in the new scene is nonzero (arc continuity); always
614
+ # leave >= 2 seats for fresh blood.
615
+ for aid in sorted(self._hot):
616
+ if len(picks) >= max(0, n - 2):
617
+ break
618
+ a = self._aliens[aid]
619
+ if self._interest(a, tags) > 0 and self._rng.random() < 0.6:
620
+ add(a)
621
+
622
+ # 2) noticed override: pad to 5 from the fear cluster
623
+ if self._tick_count < self._noticed_sample_until and n >= 8:
624
+ cluster = pool_of([a for a in eligible
625
+ if a.id in self._noticed_cluster])
626
+ while len(picks) < 5 and cluster:
627
+ w = [abs(a.s) + 0.05 for a in cluster]
628
+ a = self._rng.choices(cluster, weights=w)[0]
629
+ add(a)
630
+ cluster.remove(a)
631
+
632
+ # 3) reserve 1-2 uniform wildcard seats, fill the rest by interest
633
+ wild = 1 + (1 if self._rng.random() < 0.5 else 0)
634
+ while len(picks) < n - wild:
635
+ pool = pool_of(eligible) or pool_of(self._aliens)
636
+ if not pool:
637
+ break
638
+ w = [self._interest(a, tags) + 0.05 for a in pool]
639
+ add(self._rng.choices(pool, weights=w)[0])
640
+ while len(picks) < n: # wildcards: anyone, uniform
641
+ pool = pool_of(eligible) or pool_of(self._aliens)
642
+ if not pool:
643
+ break
644
+ add(self._rng.choice(pool))
645
+
646
+ for a in picks:
647
+ a.last_spoke_round = rnd
648
+ self._hot = {a.id for a in picks}
649
+ return [{"id": a.id, "archetype": a.archetype,
650
+ "mood": a.mood, "sentiment": round(a.s, 3),
651
+ "name": a.name, "tic": a.tic, "opinion": a.opinion,
652
+ "last_said": a.memory[-1]["said"] if a.memory else ""}
653
+ for a in picks]
654
+
655
+ def set_bubble(self, alien_id: int, text: str, guaranteed: bool = False):
656
+ with self._lock:
657
+ text = (text or "").strip().strip('"\'*').strip()
658
+ if not text or not (0 <= alien_id < N_ALIENS):
659
+ return
660
+ text = text[:60]
661
+ self._prune_bubbles()
662
+ if alien_id not in self._bubbles and len(self._bubbles) >= 8:
663
+ if not guaranteed:
664
+ return
665
+ oldest = min(self._bubbles, key=lambda k: self._bubbles[k][1])
666
+ del self._bubbles[oldest]
667
+ ttl = self._rng.randint(12, 20) # ~6-10 s
668
+ self._bubbles[alien_id] = (text, self._tick_count + ttl)
669
+
670
+ def inject(self, alien_id: int, amount: float):
671
+ with self._lock:
672
+ if 0 <= alien_id < N_ALIENS:
673
+ self._aliens[alien_id].inj += float(amount)
674
+
675
+ def state(self) -> dict:
676
+ """Exact /api/state shape minus scene/live/thinking (B2 merges those)."""
677
+ with self._lock:
678
+ self._prune_bubbles()
679
+ ev = self._event if self._tick_count < self._event_until else None
680
+ love, meh, hate = self._faction_tuple()
681
+ return {
682
+ "event": ev,
683
+ "light": self._light,
684
+ "sound": self._sound,
685
+ "factions": {"love": love, "meh": meh, "hate": hate},
686
+ "rumor": self._rumor_text,
687
+ "aliens": [
688
+ {
689
+ "id": a.id, "row": a.row, "seat": a.seat,
690
+ "archetype": a.archetype, "color": a.color,
691
+ "mood": a.mood, "sentiment": round(a.s, 3),
692
+ "bubble": self._bubbles.get(a.id, (None,))[0],
693
+ "name": a.name,
694
+ }
695
+ for a in self._aliens
696
+ ],
697
+ }
698
+
699
+ def alien_card(self, alien_id: int) -> dict | None:
700
+ """Full dossier on one alien for the click-card UI (thread-safe)."""
701
+ with self._lock:
702
+ if not 0 <= int(alien_id) < N_ALIENS:
703
+ return None
704
+ a = self._aliens[int(alien_id)]
705
+ return {
706
+ "id": a.id, "name": a.name, "archetype": a.archetype,
707
+ "quirk": a.quirk, "obsession": a.obsession,
708
+ "relationship": a.relationship, "tic": a.tic,
709
+ "opinion": a.opinion,
710
+ "memory": [dict(m) for m in a.memory],
711
+ "mood": a.mood, "sentiment": round(a.s, 3),
712
+ }
713
+
714
+ # ---------------- internals ----------------
715
+
716
+ def _prune_bubbles(self):
717
+ dead = [k for k, (_, exp) in self._bubbles.items() if self._tick_count >= exp]
718
+ for k in dead:
719
+ del self._bubbles[k]
720
+
721
+ @staticmethod
722
+ def _faction_of(s: float) -> str:
723
+ return "love" if s > 0.25 else "hate" if s < -0.25 else "meh"
724
+
725
+ def _factions_unlocked(self) -> dict[str, int]:
726
+ f = {"love": 0, "meh": 0, "hate": 0}
727
+ for a in self._aliens:
728
+ f[self._faction_of(a.s)] += 1
729
+ return f
730
+
731
+ def _faction_tuple(self):
732
+ f = self._factions_unlocked()
733
+ return f["love"], f["meh"], f["hate"]
734
+
735
+ def _start_event(self, name: str, exempt: bool = False):
736
+ if not exempt:
737
+ if self._tick_count < self._event_until:
738
+ return
739
+ if self._tick_count - self._last_event_start < EVENT_COOLDOWN_TICKS:
740
+ return
741
+ self._event = name
742
+ self._event_until = self._tick_count + EVENT_DURATIONS[name]
743
+ self._last_event_start = self._tick_count
744
+ self._thought("event", f"crowd event: {name.upper()}",
745
+ f"tick {self._tick_count}; factions love/meh/hate = "
746
+ f"{self.state()['factions']}")
747
+
748
+ def _tick_once(self):
749
+ tags = self._tags
750
+ snap = [a.s for a in self._aliens]
751
+ pulse = max(0.0, 1.0 - self._ticks_since_scene / 8.0)
752
+ for a in self._aliens:
753
+ nbr = sum(snap[j] for j in a.neighbors) / len(a.neighbors)
754
+ pull = a.pull(tags)
755
+ rest = a.rest + (a.rest_offset if self._tick_count < a.rest_offset_until else 0.0)
756
+ ds = (0.30 * a.k * (nbr - a.s)
757
+ + 0.12 * pulse * pull
758
+ + 0.02 * (rest - a.s)
759
+ + a.inj
760
+ + self._rng.gauss(0, 0.03))
761
+ a.inj = 0.0
762
+ a.s = _clamp(a.s + ds)
763
+ for a in self._aliens:
764
+ a.hist.append(a.s)
765
+ if len(a.hist) > 5:
766
+ a.hist.pop(0)
767
+ self._tick_count += 1
768
+ self._ticks_since_scene += 1
769
+
770
+ noticed_active = (self._event == "noticed"
771
+ and self._tick_count < self._event_until)
772
+ for a in self._aliens:
773
+ a.mood = self._mood_of(a, noticed_active)
774
+
775
+ self._prune_bubbles()
776
+ # Roll out one queued voice per tick (~0.5s apart) — comic timing.
777
+ if self._voice_queue:
778
+ aid, text = self._voice_queue.pop(0)
779
+ self.set_bubble(aid, text)
780
+ self._check_events()
781
+ self._tick_rumor()
782
+ self._maybe_murmur()
783
+
784
+ # ---------------- memory & opinion (deterministic, no LLM) ----------------
785
+
786
+ def _remember(self, a: _Alien, said: str):
787
+ """Append to the alien's rolling memory and refresh their one-line
788
+ opinion of the Protagonist. Caller holds self._lock."""
789
+ a.memory.append({"scene": (self._scene or "")[:80],
790
+ "said": said[:60], "sentiment": round(a.s, 3)})
791
+ sents = [m["sentiment"] for m in a.memory]
792
+ trend = sents[-1] - sents[0]
793
+ avg = sum(sents) / len(sents)
794
+ hit = bool(a.obsession
795
+ and (extract_tags(a.obsession) - {"absence"}) & self._tags)
796
+ ref = a.obsession if (hit and a.obsession) else "whole spectacle"
797
+ if trend > 0.15:
798
+ bank = _OPINION_RISING
799
+ elif trend < -0.15:
800
+ bank = _OPINION_FALLING
801
+ elif avg > 0.25:
802
+ bank = _OPINION_POS
803
+ elif avg < -0.25:
804
+ bank = _OPINION_NEG
805
+ else:
806
+ bank = _OPINION_FLAT
807
+ a.opinion = bank[(a.id + len(a.memory)) % len(bank)].format(ref=ref)
808
+
809
+ # ---------------- rumor mill (telephone game) ----------------
810
+
811
+ @staticmethod
812
+ def _fit_rumor(text: str) -> str:
813
+ """Cap rumor length at a WORD boundary — mid-word slices read as bugs
814
+ ("...personal ocean hidd")."""
815
+ if len(text) <= RUMOR_MAX_LEN:
816
+ return text
817
+ cut = text[:RUMOR_MAX_LEN]
818
+ sp = cut.rfind(" ")
819
+ return (cut[:sp] if sp >= 12 else cut).rstrip(" ,.!")
820
+
821
+ def _seed_rumor(self, reason: str):
822
+ """Start a rumor about the Protagonist (one at a time). Caller holds
823
+ self._lock."""
824
+ if self._rumor_text is not None:
825
+ return
826
+ seed = self._aliens[self._rng.randrange(N_ALIENS)]
827
+ x = (seed.obsession if 0 < len(seed.obsession) <= 24
828
+ else self._rng.choice(_RUMOR_SUBJECTS))
829
+ text = self._fit_rumor(self._rng.choice(RUMOR_TEMPLATES).format(x=x))
830
+ self._rumor_text = text
831
+ self._rumor_carriers = {seed.id} | set(seed.neighbors)
832
+ self._rumor_infections = len(self._rumor_carriers)
833
+ self._rumor_until = self._tick_count + RUMOR_LIFETIME_TICKS
834
+ self._thought("event", "rumor seeded",
835
+ f"{reason} — {seed.name} starts it: \"{text}\" "
836
+ f"({len(self._rumor_carriers)} carriers)")
837
+
838
+ def _clear_rumor(self):
839
+ self._rumor_text = None
840
+ self._rumor_carriers = set()
841
+ self._rumor_until = -1
842
+
843
+ def _tick_rumor(self):
844
+ """One spread step: hop seat-to-seat, mutate every 10th infection,
845
+ occasionally whisper, die out on timeout or full saturation."""
846
+ if self._rumor_text is None:
847
+ return
848
+ if self._tick_count >= self._rumor_until:
849
+ self._thought("event", "rumor fades",
850
+ f"\"{self._rumor_text}\" died at "
851
+ f"{len(self._rumor_carriers)} carriers")
852
+ self._clear_rumor()
853
+ return
854
+ carriers = self._rumor_carriers
855
+ fresh: set[int] = set()
856
+ for cid in sorted(carriers):
857
+ if self._rng.random() < RUMOR_HOP_P:
858
+ nb = self._rng.choice(self._aliens[cid].neighbors)
859
+ if nb not in carriers:
860
+ fresh.add(nb)
861
+ for nb in sorted(fresh):
862
+ carriers.add(nb)
863
+ self._rumor_infections += 1
864
+ if self._rumor_infections % 10 == 0: # telephone-game drift
865
+ mut = RUMOR_MUTATIONS[
866
+ self._rng.randrange(len(RUMOR_MUTATIONS))]
867
+ self._rumor_text = self._fit_rumor(mut(self._rumor_text))
868
+ # whisper bubbles "(psst: ...)" — at most 2 visible at once
869
+ psst = sum(1 for txt, _ in self._bubbles.values()
870
+ if txt.startswith("(psst"))
871
+ for cid in sorted(carriers):
872
+ if psst >= 2:
873
+ break
874
+ if self._rng.random() < RUMOR_WHISPER_P:
875
+ self.set_bubble(cid, f"(psst: {self._rumor_text})")
876
+ psst += 1
877
+ if len(carriers) >= N_ALIENS:
878
+ self._thought("event", "rumor became canon",
879
+ f"all {N_ALIENS} aliens now believe: "
880
+ f"\"{self._rumor_text}\"")
881
+ self._clear_rumor()
882
+
883
+ def _maybe_murmur(self):
884
+ """Idle chatter between scenes: when the theater goes quiet, a random
885
+ alien occasionally mutters a stub line — keeps the crowd alive even
886
+ before any camera frame arrives."""
887
+ if len(self._bubbles) >= 3 or self._rng.random() > 0.12:
888
+ return
889
+ a = self._rng.choice(self._aliens)
890
+ lines = STUB_LINES.get(a.archetype)
891
+ if lines:
892
+ text = self._rng.choice(lines)
893
+ self.set_bubble(a.id, text)
894
+ self._remember(a, text)
895
+ self._thought("event", f"{a.name} the {a.archetype} murmurs (idle theater)", text)
896
+
897
+ def _mood_of(self, a: _Alien, noticed_active: bool) -> str:
898
+ h = a.hist
899
+ delta = a.s - h[0]
900
+ if (noticed_active and a.id in self._noticed_cluster) or \
901
+ (a.s < -0.5 and delta < -0.25):
902
+ return "scared"
903
+ if a.s > 0.5:
904
+ return "delighted"
905
+ if a.s > 0.2 and abs(delta) > 0.15:
906
+ return "excited"
907
+ if a.s < -0.2:
908
+ return "annoyed"
909
+ if abs(a.s) <= 0.2:
910
+ nbr_s = [self._aliens[j].s for j in a.neighbors]
911
+ d1, d2, d3 = h[4] - h[3], h[3] - h[2], h[2] - h[1]
912
+ flips = (d1 * d2 < 0) and (d2 * d3 < 0)
913
+ if (max(nbr_s) - min(nbr_s) > 0.8) or flips:
914
+ return "confused"
915
+ return "calm"
916
+
917
+ def _check_events(self):
918
+ if self._tick_count < self._event_until:
919
+ # intermission ends only when absence clears (apply_scene handles wave)
920
+ return
921
+ if self._event is not None:
922
+ self._event = None
923
+ # priority: intermission > chant > gasp > ovation > wave > boo
924
+ if "absence" in self._tags:
925
+ self._start_event("intermission")
926
+ return
927
+ if "drink" in self._tags and sum(
928
+ 1 for a in self._aliens
929
+ if a.archetype == "devout" and a.s > 0.5) >= 6:
930
+ self._start_event("chant")
931
+ return
932
+ if self._gasp_triggered():
933
+ self._start_event("gasp")
934
+ return
935
+ mean_s = sum(a.s for a in self._aliens) / N_ALIENS
936
+ love, _, _ = self._faction_tuple()
937
+ if mean_s > 0.55 and love >= 70:
938
+ self._start_event("ovation")
939
+ return
940
+ if self._wave_triggered():
941
+ self._start_event("wave")
942
+ return
943
+ if mean_s < -0.4:
944
+ self._start_event("boo")
945
+
946
+ def _gasp_triggered(self) -> bool:
947
+ if self._light >= 0 and len(self._light_hist) >= 2:
948
+ cur = self._light_hist[-1][1]
949
+ if max(v for _, v in self._light_hist) - cur >= 40:
950
+ return True
951
+ drops = sum(1 for a in self._aliens
952
+ if len(a.hist) >= 3 and a.hist[2] - a.hist[4] > 0.35)
953
+ return drops >= 40
954
+
955
+ def _wave_triggered(self) -> bool:
956
+ for r in range(ROWS):
957
+ run = 0
958
+ for s in range(SEATS):
959
+ run = run + 1 if self._aliens[r * SEATS + s].s > 0.6 else 0
960
+ if run >= 10:
961
+ return True
962
+ return False
963
+
964
+ # ---------------- LLM voicing (section 5) ----------------
965
+
966
+ def _voice(self, scene: str, event: str | None, factions, speakers: list[dict],
967
+ round_no: int = 0):
968
+ """ONE batched raw_chat per accepted scene; stub bank backfills gaps.
969
+ Called WITHOUT self._lock held; raw_chat serialized via llm_lock.
970
+ ``round_no`` guards against a slow reply landing after a NEWER scene
971
+ was accepted — stale lines are dropped, not misattributed."""
972
+ if not speakers:
973
+ return
974
+ love, meh, hate = factions
975
+
976
+ def _entry(i: int, sp: dict) -> str:
977
+ parts = [f"{i + 1} {sp.get('name', '?')} "
978
+ f"({sp['archetype'].upper()}, {sp['mood']})"]
979
+ if sp.get("tic"):
980
+ parts.append(f"tic:{sp['tic']}")
981
+ if sp.get("last_said"):
982
+ parts.append(f"memory:'{sp['last_said']}'")
983
+ if sp.get("opinion"):
984
+ parts.append(f"opinion:{sp['opinion']}")
985
+ return " ".join(parts)
986
+
987
+ audience = "\n".join(_entry(i, sp) for i, sp in enumerate(speakers))
988
+ user = (f"SCENE: {scene}\n"
989
+ f"EVENT: {event or 'none'} | CROWD: love {love} / meh {meh} / hate {hate}\n"
990
+ f"AUDIENCE:\n{audience}\n"
991
+ f'One reaction line per number, format "N ARCHETYPE: text":')
992
+ self._thought("voice", f"voicing {len(speakers)} aliens for new scene",
993
+ f"PROMPT (user part):\n{user}")
994
+ raw = ""
995
+ if self._model is not None:
996
+ try:
997
+ with self._llm_lock:
998
+ raw = self._model.raw_chat(VOICE_SYSTEM_PROMPT, user,
999
+ max_tokens=256, temperature=0.9) or ""
1000
+ except Exception:
1001
+ raw = ""
1002
+ self._thought("voice",
1003
+ "LLM replied" if raw else "no model — stub line bank",
1004
+ raw[:1200] if raw else "raw_chat returned empty; canned "
1005
+ "archetype lines will backfill every speaker")
1006
+ lines: dict[int, str] = {}
1007
+ for line in raw.splitlines():
1008
+ m = _VOICE_LINE_RE.match(line)
1009
+ if not m:
1010
+ continue
1011
+ num = int(m.group(1))
1012
+ if 1 <= num <= len(speakers) and num not in lines:
1013
+ text = m.group(2).strip().strip('"\'*').strip()
1014
+ # Drop a leading "DEVOUT:" / "ART CRITIC:" echo of the format.
1015
+ text = _VOICE_PREFIX_RE.sub("", text).strip()
1016
+ if text:
1017
+ lines[num] = text[:60]
1018
+ parsed = len(lines)
1019
+ with self._lock:
1020
+ if round_no and round_no != getattr(self, "_voice_round", 0):
1021
+ self._thought("voice", "stale voicing dropped",
1022
+ "a newer scene arrived while the model was "
1023
+ "writing; these lines belong to the old one")
1024
+ return
1025
+ for i, sp in enumerate(speakers):
1026
+ text = lines.get(i + 1) or self._rng.choice(
1027
+ STUB_LINES[sp["archetype"]])
1028
+ # Comic timing: first voice lands now, the rest roll out one
1029
+ # per tick (~0.5s apart) via the queue drained in _tick_once.
1030
+ if i == 0:
1031
+ self.set_bubble(sp["id"], text)
1032
+ else:
1033
+ self._voice_queue.append((sp["id"], text))
1034
+ a = self._aliens[sp["id"]]
1035
+ self._remember(a, text) # memory -> running gags next scene
1036
+ pull = a.pull(self._tags)
1037
+ base = pull if pull != 0 else a.s
1038
+ a.inj += 0.4 * math.copysign(1.0, base) if base != 0 else 0.0
1039
+ self._thought("voice",
1040
+ f"bubbles assigned: {parsed} from model, "
1041
+ f"{len(speakers) - parsed} from stub bank",
1042
+ "\n".join(f"{sp['archetype']}: "
1043
+ f"{(lines.get(i + 1) or '(stub)')}"
1044
+ for i, sp in enumerate(speakers)))
1045
+
1046
+
1047
+ # Spec section 4 names the class `Crowd`; keep both importable.
1048
+ Crowd = CrowdSim
1049
+
1050
+
1051
+ # --------------------------------------------------------------------------
1052
+ # Smoke test
1053
+ # --------------------------------------------------------------------------
1054
+
1055
+ if __name__ == "__main__":
1056
+ sim = CrowdSim(model=None)
1057
+
1058
+ def check_shape(st):
1059
+ assert set(st) == {"event", "light", "sound", "factions", "aliens",
1060
+ "rumor"}, st.keys()
1061
+ assert st["rumor"] is None or (isinstance(st["rumor"], str) and st["rumor"])
1062
+ assert set(st["factions"]) == {"love", "meh", "hate"}
1063
+ assert sum(st["factions"].values()) == N_ALIENS
1064
+ assert len(st["aliens"]) == N_ALIENS
1065
+ base_keys = {"id", "row", "seat", "archetype", "color",
1066
+ "mood", "sentiment", "bubble"}
1067
+ bubbles = 0
1068
+ for i, a in enumerate(st["aliens"]):
1069
+ assert base_keys <= set(a) <= base_keys | {"name"}, set(a)
1070
+ assert isinstance(a.get("name", ""), str)
1071
+ assert a["id"] == i and a["id"] == a["row"] * SEATS + a["seat"]
1072
+ assert a["archetype"] in ARCHETYPES
1073
+ assert a["color"] in (LIME, MAGENTA, AMBER)
1074
+ assert a["mood"] in MOODS
1075
+ assert -1.0 <= a["sentiment"] <= 1.0
1076
+ if a["bubble"] is not None:
1077
+ bubbles += 1
1078
+ assert len(a["bubble"]) <= 60
1079
+ assert bubbles <= 8, bubbles
1080
+ return bubbles
1081
+
1082
+ check_shape(sim.state())
1083
+ per_arch = {}
1084
+ for a in sim.state()["aliens"]:
1085
+ per_arch[a["archetype"]] = per_arch.get(a["archetype"], 0) + 1
1086
+ assert all(v == 10 for v in per_arch.values()), per_arch
1087
+
1088
+ accepted = sim.on_scene("The Protagonist lifts the Sacred Steam Chalice and drinks.")
1089
+ assert accepted
1090
+ b1 = check_shape(sim.state())
1091
+ assert b1 >= 1, "no bubbles after first scene"
1092
+ for _ in range(30):
1093
+ sim.tick(0.5)
1094
+ check_shape(sim.state())
1095
+
1096
+ # near-duplicate scene must be rejected (novelty < 0.4)
1097
+ assert not sim.on_scene("The Protagonist lifts the Sacred Steam Chalice and drinks again.")
1098
+
1099
+ accepted = sim.on_scene("The room sits empty and dark, the Throne of Sitting unoccupied.")
1100
+ assert accepted
1101
+ b2 = check_shape(sim.state())
1102
+ assert b2 >= 1, "no bubbles after second scene"
1103
+ st = sim.state()
1104
+ assert st["event"] in ("new_scene", "wave", "intermission")
1105
+ for _ in range(30):
1106
+ sim.tick(0.5)
1107
+ check_shape(sim.state())
1108
+
1109
+ sim.poke()
1110
+ st = sim.state()
1111
+ assert st["event"] == "noticed"
1112
+ assert any(a["bubble"] == NOTICED_BUBBLE for a in st["aliens"])
1113
+ sim.tick(0.5)
1114
+ st = sim.state()
1115
+ assert any(a["mood"] == "scared" for a in st["aliens"])
1116
+ check_shape(st)
1117
+
1118
+ sim.sense(80, 20)
1119
+ sim.sense(10, 90) # light drop >= 40 -> gasp becomes eligible
1120
+ for _ in range(40):
1121
+ sim.tick(0.5)
1122
+ check_shape(sim.state())
1123
+
1124
+ # ---------------- society upgrade checks ----------------
1125
+
1126
+ # personas: load when the census file exists; graceful fallback otherwise
1127
+ if os.path.exists(PERSONAS_JSON):
1128
+ assert any(not a.name.startswith("Blorp #") for a in sim._aliens), \
1129
+ "personas_data.json present but no names loaded"
1130
+ else:
1131
+ assert sim._aliens[0].name == "Blorp #0"
1132
+
1133
+ # alien_card shape (thread-safe dossier for the click-card UI)
1134
+ card = sim.alien_card(0)
1135
+ assert card is not None and card["id"] == 0
1136
+ assert set(card) == {"id", "name", "archetype", "quirk", "obsession",
1137
+ "relationship", "tic", "opinion", "memory",
1138
+ "mood", "sentiment"}, set(card)
1139
+ assert isinstance(card["name"], str) and card["name"]
1140
+ assert isinstance(card["opinion"], str) and card["opinion"]
1141
+ assert isinstance(card["memory"], list) and len(card["memory"]) <= 6
1142
+ for m in card["memory"]:
1143
+ assert set(m) == {"scene", "said", "sentiment"}
1144
+ assert sim.alien_card(999) is None
1145
+ # voiced speakers must have accumulated memory + an evolving opinion
1146
+ remembered = [a for a in sim._aliens if a.memory]
1147
+ assert remembered, "no alien recorded any memory after two scenes"
1148
+ assert all(m_a.memory[-1]["said"] for m_a in remembered)
1149
+
1150
+ # hot-seat stickiness: consecutive samplings share speakers + fresh blood
1151
+ sim_h = CrowdSim(model=None)
1152
+ sim_h.apply_scene("The Protagonist sips from the Sacred Steam Chalice",
1153
+ {"drink", "person", "novelty"})
1154
+ s1 = {sp["id"] for sp in sim_h.sample_speakers(8)}
1155
+ assert sim_h._hot == s1
1156
+ s2 = {sp["id"] for sp in sim_h.sample_speakers(8)}
1157
+ assert len(s1) == len(s2) == 8
1158
+ assert s1 & s2, "hot-seat stickiness: nobody carried over"
1159
+ assert s2 - s1, "hot-seat: no fresh blood in second scene"
1160
+ for sp in sim_h.sample_speakers(8): # prompt-entry fields present
1161
+ assert {"id", "archetype", "mood", "sentiment",
1162
+ "name", "tic", "opinion", "last_said"} <= set(sp)
1163
+
1164
+ # rumor: seed on noticed, spread seat-to-seat, surface in state()
1165
+ rumor_events = []
1166
+ sim_r = CrowdSim(model=None,
1167
+ thought_cb=lambda k, t, b="": rumor_events.append(t))
1168
+ sim_r.poke()
1169
+ st = sim_r.state()
1170
+ assert isinstance(st["rumor"], str) and st["rumor"]
1171
+ assert any("rumor seeded" in t for t in rumor_events)
1172
+ c0 = len(sim_r._rumor_carriers)
1173
+ assert c0 >= 3 # seed + neighbors
1174
+ for _ in range(80):
1175
+ sim_r.tick(0.5)
1176
+ assert (len(sim_r._rumor_carriers) > c0
1177
+ or any("rumor became canon" in t for t in rumor_events)), \
1178
+ "rumor never spread"
1179
+ # force full saturation -> canon event + rumor cleared
1180
+ if sim_r._rumor_text is not None:
1181
+ with sim_r._lock:
1182
+ sim_r._rumor_carriers = set(range(N_ALIENS))
1183
+ sim_r.tick(0.5)
1184
+ assert any("rumor became canon" in t for t in rumor_events)
1185
+ assert sim_r.state()["rumor"] is None
1186
+ check_shape(sim_r.state())
1187
+
1188
+ print("companion/crowd.py smoke test OK: "
1189
+ f"{N_ALIENS} aliens, bubbles scene1={b1} scene2={b2}, "
1190
+ f"hot-seat overlap={len(s1 & s2)}, society features green")
companion/duo.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DuoChat — two house cats who watch your webcam and judge you.
2
+
3
+ Two cats who UNDERSTAND what the human is doing but react like cats: self-
4
+ interested, unimpressed, food-obsessed, suspicious, prone to plotting and
5
+ grudges. One batched LLM call per scene → a 2-line exchange.
6
+
7
+ MOCHI — a hyper kitten: must attack/chase/knock over everything; dramatic.
8
+ LOAF — an enormous elder cat: lazy, deadpan, judgmental; lives for food + naps.
9
+
10
+ Punch-ups: it detects the SITUATION (you appeared / left / are eating / on your
11
+ phone / in the dark) and tracks GRUDGES (scenes since food appeared) so the cats
12
+ do callbacks and running gags instead of one-off captions. ``situation`` is
13
+ exposed in state() so the frontend can animate (pounce / dilate / tail-flick).
14
+
15
+ Thread-safe. Deterministic stub fallback so it's funny with no model loaded.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import threading
22
+ from collections import deque
23
+
24
+ CATS = ("mochi", "loaf")
25
+
26
+ SYSTEM = (
27
+ "You are two house cats, MOCHI and LOAF, watching your human through a window. "
28
+ "You understand what the human is doing — but you are cats: utterly self-interested, "
29
+ "unimpressed, ruled by food and naps, suspicious of objects, quick to plot, quicker "
30
+ "to take offense. NEVER just describe; always land an OPINION, a demand, a threat, or "
31
+ "a scheme. Be specific and cutting. Cat logic only (the bowl, the warm keyboard, the "
32
+ "forbidden counter, the 3am zoomies, knocking things off tables, the red dot, naps, "
33
+ "judging the staff).\n"
34
+ "MOCHI: hyper kitten — wants to attack/chase/knock over what it sees; dramatic; no chill.\n"
35
+ "LOAF: enormous elder — lazy, deadpan, withering; only truly cares about being fed.\n"
36
+ "Use the SITUATION and any GRUDGE the prompt gives you — call back to it, hold the grudge.\n"
37
+ "Write EXACTLY two short lines, in character, referencing what you SEE. Under 13 words "
38
+ "each. No quotes, no emoji, no stage directions. Format EXACTLY:\n"
39
+ "MOCHI: <line>\nLOAF: <line>\n(LOAF may go first if funnier.)"
40
+ )
41
+
42
+ _LINE_RE = re.compile(r"^\s*(MOCHI|LOAF)\s*[:.\-)]\s*(.+)$", re.IGNORECASE)
43
+
44
+ # scene-keyword buckets used for situation detection
45
+ _PERSON = ("person", "human", "man", "woman", "face", "people", "individual",
46
+ "someone", "shirt", "beard", "hair", "glasses", "head", "smiling")
47
+ _FOOD = ("eat", "food", "snack", "sandwich", "pizza", "plate", "fruit", "meal",
48
+ "mug", "cup", "coffee", "tea", "drink", "bottle", "chewing")
49
+ _DEVICE = ("phone", "smartphone", "screen", "laptop", "keyboard", "computer",
50
+ "monitor", "tablet", "rectangle", "slab", "typing", "glowing")
51
+ _DARK = ("dark", "dim", "shadow", "unlit", "night", "black background")
52
+ _ABSENT = ("empty", "no one", "nobody", "absent", "vacant", "no person", "blank")
53
+
54
+ # Situation → a hint line for the prompt, and the animation tag for the frontend.
55
+ SITUATIONS = {
56
+ "returned": "SITUATION: the human just CAME BACK into view. the staff returns.",
57
+ "left": "SITUATION: the human just LEFT the frame. abandoned again.",
58
+ "food": "SITUATION: the human has FOOD/DRINK and has not shared any.",
59
+ "device": "SITUATION: the human is staring at a glowing slab instead of you.",
60
+ "dark": "SITUATION: the lights are off. prime zoomies / nap conditions.",
61
+ "idle": "SITUATION: the human is just sitting there, doing nothing useful.",
62
+ }
63
+
64
+ _STUB = {
65
+ "returned": [("loaf", "it returned. you may now serve me. begin."),
66
+ ("mochi", "STAFF! you missed my third nap. unforgivable. welcome back.")],
67
+ "left": [("mochi", "it LEFT. quick — onto the forbidden counter!"),
68
+ ("loaf", "abandoned. i shall nap on its chair out of spite.")],
69
+ "food": [("loaf", "food. and not one crumb for me. i'm noting this."),
70
+ ("mochi", "share it. share it. SHARE IT or i knock the cup off.")],
71
+ "device": [("mochi", "the glowing slab again. it loves the slab more than me."),
72
+ ("loaf", "if i sit on the keys, the staring stops. science.")],
73
+ "dark": [("mochi", "lights out means ZOOMIES. it's the law. i don't make it."),
74
+ ("loaf", "good. dark. i become one with the couch now.")],
75
+ "idle": [("loaf", "it does nothing, slowly. finally, a human i tolerate."),
76
+ ("mochi", "throw the string. THROW it. why won't it throw the string.")],
77
+ }
78
+
79
+
80
+ class DuoChat:
81
+ def __init__(self, model=None, llm_lock: threading.Lock | None = None,
82
+ thought_cb=None):
83
+ self._model = model
84
+ self._llm_lock = llm_lock or threading.Lock()
85
+ self._lock = threading.RLock()
86
+ cb = thought_cb or (lambda *a, **k: None)
87
+
88
+ def _safe(kind, title, body=""):
89
+ try:
90
+ cb(kind, title, body)
91
+ except Exception:
92
+ pass
93
+ self._thought = _safe
94
+
95
+ self._scene = "the window is still warming up."
96
+ self._exchange: list[dict] = []
97
+ self._situation = "idle"
98
+ self._history: deque[str] = deque(maxlen=4)
99
+ self._thinking = False
100
+ # running-gag state
101
+ self._present = False
102
+ self._since_food = 0
103
+
104
+ @property
105
+ def thinking(self) -> bool:
106
+ return self._thinking
107
+
108
+ def state(self) -> dict:
109
+ with self._lock:
110
+ return {
111
+ "scene": self._scene,
112
+ "situation": self._situation,
113
+ "cats": [{"id": "mochi", "name": "Mochi"}, {"id": "loaf", "name": "Loaf"}],
114
+ "exchange": list(self._exchange),
115
+ "thinking": self._thinking,
116
+ }
117
+
118
+ # -- situation + grudge ----------------------------------------------
119
+ def _classify(self, scene: str) -> tuple[str, str]:
120
+ """Return (situation, grudge_hint). Updates running-gag state."""
121
+ low = scene.lower()
122
+ hit = lambda words: any(w in low for w in words)
123
+ present = hit(_PERSON) and not hit(_ABSENT)
124
+ food = hit(_FOOD)
125
+
126
+ if food:
127
+ self._since_food = 0
128
+ else:
129
+ self._since_food += 1
130
+
131
+ if present and not self._present:
132
+ sit = "returned"
133
+ elif self._present and not present:
134
+ sit = "left"
135
+ elif food:
136
+ sit = "food"
137
+ elif hit(_DARK):
138
+ sit = "dark"
139
+ elif hit(_DEVICE):
140
+ sit = "device"
141
+ else:
142
+ sit = "idle"
143
+ self._present = present
144
+
145
+ grudge = ""
146
+ if self._since_food >= 3 and sit != "food":
147
+ grudge = (f"GRUDGE: it has been {self._since_food} scenes and STILL no food "
148
+ f"has appeared. bring it up.")
149
+ return sit, grudge
150
+
151
+ # -- write side -------------------------------------------------------
152
+ def on_scene(self, scene: str) -> bool:
153
+ scene = (scene or "").strip()
154
+ if not scene:
155
+ return False
156
+ with self._lock:
157
+ self._scene = scene
158
+ self._thinking = True
159
+ situation, grudge = self._classify(scene)
160
+ self._situation = situation
161
+ self._thought("vision", f"the cats notice: {situation}", scene)
162
+ try:
163
+ exchange = self._react(scene, situation, grudge)
164
+ finally:
165
+ with self._lock:
166
+ self._thinking = False
167
+ with self._lock:
168
+ self._exchange = exchange
169
+ for line in exchange:
170
+ self._history.append(f"{line['who'].upper()}: {line['text']}")
171
+ self._thought("voice", "the cats pass judgment",
172
+ "\n".join(f"{l['who'].upper()}: {l['text']}" for l in exchange))
173
+ return True
174
+
175
+ def _react(self, scene: str, situation: str, grudge: str) -> list[dict]:
176
+ memory = " | ".join(self._history) or "nothing yet"
177
+ hint = SITUATIONS.get(situation, SITUATIONS["idle"])
178
+ user = (f"WINDOW NOW: {scene}\n{hint}\n"
179
+ f"{grudge + chr(10) if grudge else ''}"
180
+ f"Earlier they grumbled: {memory}\nThe exchange:")
181
+ raw = ""
182
+ if self._model is not None:
183
+ try:
184
+ with self._llm_lock:
185
+ raw = self._model.raw_chat(SYSTEM, user, max_tokens=90,
186
+ temperature=1.0) or ""
187
+ except Exception:
188
+ raw = ""
189
+ parsed = self._parse(raw)
190
+ if len(parsed) >= 2:
191
+ return parsed[:2]
192
+ return [{"who": w, "text": t} for w, t in _STUB[situation]]
193
+
194
+ @staticmethod
195
+ def _parse(raw: str) -> list[dict]:
196
+ out, seen = [], set()
197
+ for line in raw.splitlines():
198
+ m = _LINE_RE.match(line)
199
+ if not m:
200
+ continue
201
+ who = m.group(1).lower()
202
+ text = m.group(2).strip().strip('"\'*').strip()
203
+ if who in seen or not text:
204
+ continue
205
+ seen.add(who)
206
+ out.append({"who": who, "text": text[:90]})
207
+ return out
companion/model.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The brain: one small local model via llama.cpp, driven by a persona.
2
+
3
+ A single ~3B GGUF (default Llama-3.2-3B-Instruct) produces every output kind
4
+ ("entry", "ascii", "screen") for whichever persona is active. llama.cpp keeps
5
+ it offline (Off the Grid) and earns Llama Champion; a ~3B model keeps the whole
6
+ app under 4B (Tiny Titan).
7
+
8
+ If the model can't load (no weights, llama-cpp-python missing, tiny host), the
9
+ persona's deterministic stub takes over so the UI is *always* demoable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ from . import OLED_COLS, OLED_ROWS
17
+ from .text import clamp_art, clamp_screen, strip_wrapping_quotes
18
+
19
+ # Default brain; swap via env (e.g. an OpenBMB MiniCPM GGUF) without code changes.
20
+ MODEL_REPO = os.environ.get("COMPANION_MODEL_REPO", "bartowski/Llama-3.2-3B-Instruct-GGUF")
21
+ MODEL_FILE = os.environ.get("COMPANION_MODEL_FILE", "Llama-3.2-3B-Instruct-Q4_K_M.gguf")
22
+
23
+ # Per-kind decoding settings (max_tokens, temperature).
24
+ _DECODE = {
25
+ "entry": (160, 0.85),
26
+ "ascii": (200, 0.70),
27
+ "screen": (40, 0.90),
28
+ }
29
+
30
+
31
+ class Companion:
32
+ def __init__(self, repo: str = MODEL_REPO, filename: str = MODEL_FILE,
33
+ n_ctx: int = 2048, verbose: bool = False):
34
+ self.repo = repo
35
+ self.filename = filename
36
+ self.llm = None
37
+ # Some GGUFs ship a chat template llama.cpp can't render (MiniCPM3's
38
+ # Jinja is a function-calling monster) — force a known format via env.
39
+ chat_format = os.environ.get("COMPANION_CHAT_FORMAT") or None
40
+ kwargs = {"repo_id": repo, "filename": filename, "n_ctx": n_ctx,
41
+ "verbose": verbose}
42
+ if chat_format:
43
+ kwargs["chat_format"] = chat_format
44
+ try:
45
+ from llama_cpp import Llama # noqa: PLC0415
46
+ self.llm = Llama.from_pretrained(**kwargs)
47
+ except Exception as exc: # noqa: BLE001
48
+ print(f"[model] llama.cpp unavailable ({exc}); using stub responses")
49
+
50
+ @property
51
+ def backend(self) -> str:
52
+ return f"llama.cpp · {self.filename}" if self.llm else "stub (offline preview)"
53
+
54
+ @property
55
+ def ready(self) -> bool:
56
+ return self.llm is not None
57
+
58
+ def raw_chat(self, system: str, user: str, max_tokens: int = 256,
59
+ temperature: float = 0.9) -> str:
60
+ """Persona-free chat completion for batch/crowd use.
61
+ Returns "" when no model is loaded — callers must have their own stubs."""
62
+ if not self.llm:
63
+ return ""
64
+ out = self.llm.create_chat_completion(
65
+ messages=[{"role": "system", "content": system},
66
+ {"role": "user", "content": user}],
67
+ max_tokens=max_tokens,
68
+ temperature=temperature,
69
+ )
70
+ return out["choices"][0]["message"]["content"].strip()
71
+
72
+ def generate(self, persona, kind: str, context: str) -> str:
73
+ """Produce one output `kind` for `persona` given a context string."""
74
+ if not self.llm:
75
+ return persona.stub(kind, context)
76
+ max_tokens, temperature = _DECODE.get(kind, (120, 0.8))
77
+ out = self.llm.create_chat_completion(
78
+ messages=[
79
+ {"role": "system", "content": persona.system(kind)},
80
+ {"role": "user", "content": persona.user(kind, context)},
81
+ ],
82
+ max_tokens=max_tokens,
83
+ temperature=temperature,
84
+ )
85
+ raw = out["choices"][0]["message"]["content"].strip()
86
+ if kind == "ascii":
87
+ return clamp_art(raw, OLED_COLS, OLED_ROWS)
88
+ if kind == "screen":
89
+ # Personas declare their display shape; default is the 2x20 OLED.
90
+ cols, rows = getattr(persona, "screen_dims", (20, 2))
91
+ return clamp_screen(raw, cols, rows)
92
+ return strip_wrapping_quotes(raw)
companion/personas.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-product configuration ("personas").
2
+
3
+ A persona bundles everything that differs between products on the shared engine:
4
+ * the prompts for each output kind ("entry", "ascii", "screen"),
5
+ * a deterministic stub for each kind (so the UI works with no model),
6
+ * how to turn a Reading into a context string the model reads,
7
+ * when to auto-fire (the trigger), with its own cooldown + state.
8
+
9
+ The shared model wrapper and runner are persona-agnostic; swapping persona is
10
+ how Desk Companion and Kitchen Coach reuse ~85% of the same code.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import time
17
+ from typing import TYPE_CHECKING
18
+
19
+ from .sensors import Reading
20
+ from .text import part_of_day, hour_of
21
+
22
+ if TYPE_CHECKING:
23
+ from .runner import CompanionRunner
24
+
25
+
26
+ class Persona:
27
+ key = "base"
28
+ title = "Companion"
29
+ produces: tuple[str, ...] = ("entry", "screen")
30
+ cooldown_s = 90.0
31
+
32
+ def __init__(self):
33
+ self._last_trigger = 0.0
34
+
35
+ # --- prompts (model path) -------------------------------------------
36
+ def system(self, kind: str) -> str:
37
+ raise NotImplementedError
38
+
39
+ def user(self, kind: str, context: str) -> str:
40
+ raise NotImplementedError
41
+
42
+ # --- deterministic fallback (no model) ------------------------------
43
+ def stub(self, kind: str, context: str) -> str:
44
+ raise NotImplementedError
45
+
46
+ # --- context + trigger ----------------------------------------------
47
+ def describe(self, r: Reading, runner: "CompanionRunner") -> str:
48
+ raise NotImplementedError
49
+
50
+ def should_trigger(self, r: Reading, runner: "CompanionRunner") -> bool:
51
+ return False
52
+
53
+ # --- cooldown helpers -----------------------------------------------
54
+ def _cool(self) -> bool:
55
+ return (time.time() - self._last_trigger) >= self.cooldown_s
56
+
57
+ def fire(self) -> None:
58
+ self._last_trigger = time.time()
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Desk Companion — Thousand Token Wood (delight)
63
+ # ---------------------------------------------------------------------------
64
+
65
+ class DeskPersona(Persona):
66
+ key = "desk"
67
+ title = "desk companion"
68
+ produces = ("entry", "ascii", "screen")
69
+ cooldown_s = 90.0
70
+
71
+ DARK_BELOW = 12
72
+ ACTIVE_ABOVE = 6
73
+ LATE = set(range(0, 6)) | {22, 23}
74
+ BRIGHT_BEFORE = 35 # "lights went out" edge: from above this to DARK_BELOW
75
+
76
+ def __init__(self):
77
+ super().__init__()
78
+ self._prev_light: int | None = None
79
+
80
+ _SYS = {
81
+ "entry": (
82
+ "You are the resident spirit of a maker's desk: a witty, warm companion "
83
+ "who keeps a tiny diary of the room. Given live readings, write a SHORT "
84
+ "diary entry (2-3 sentences) in first person about the desk's mood now. "
85
+ "Atmospheric, a little playful. Never mention AI or 'sensors' — speak as "
86
+ "if you simply feel the room."
87
+ ),
88
+ "screen": (
89
+ "You speak through a tiny 2-line screen. Reply with a SHORT cheeky, caring "
90
+ "message as EXACTLY 2 lines separated by '|'. Each line MUST be at most 20 "
91
+ "characters — count them, keep it terse like a sticky note. Plain ASCII, no "
92
+ "quotes, no extra words. Examples: 'Go to sleep,|builder.' 'Lights low.|I "
93
+ "got you.' 'Still up?|Hydrate, friend.'"
94
+ ),
95
+ "ascii": (
96
+ "You draw tiny ASCII art for a small monochrome screen, using ONLY printable "
97
+ "ASCII. Output a grid at most 20 wide and 8 tall — no more, no explanation, "
98
+ "no code fences. Capture the room's mood (moon+stars for a quiet night, sun "
99
+ "for morning, a steaming mug for a cozy session)."
100
+ ),
101
+ }
102
+
103
+ def system(self, kind):
104
+ return self._SYS[kind]
105
+
106
+ def user(self, kind, context):
107
+ if kind == "entry":
108
+ return f"Right now the desk feels like this: {context}\nWrite the diary entry."
109
+ if kind == "screen":
110
+ return f"Room mood: {context}\nYour one-line message:"
111
+ return f"Room mood: {context}\nDraw it:"
112
+
113
+ # stub content keyed by part-of-day bucket
114
+ _ART = {
115
+ "late night": " . * .\n * .--.\n . |o_o | *\n * |:_/ |\n . // \\ \\ .\n * (| | )",
116
+ "evening": " ~ ~ ~\n ( steam )\n .-\"\"\"-.\n | |]\n | |\n '-----'",
117
+ "morning": " \\ | /\n -- (_) --\n / | \\\n ___________\n morning, you",
118
+ "afternoon": " \\ | /\n .-''-.\n ( sun )\n '-..-'\n / | \\",
119
+ }
120
+ _ENTRY = {
121
+ "late night": "The room has gone dark but the keys keep ticking. I dim myself to "
122
+ "a glow and keep you company in the small hours.",
123
+ "evening": "The light is soft and golden now. The desk settles into that warm, "
124
+ "unhurried rhythm I like best.",
125
+ "morning": "Light spills across the desk and everything feels possible. I "
126
+ "stretch awake with you.",
127
+ "afternoon": "Bright and busy — the desk hums along. I keep half an eye on the "
128
+ "warmth of the room and the click of your work.",
129
+ }
130
+ _LINE = {
131
+ "late night": "Go to sleep,|builder.",
132
+ "evening": "Cozy hours.|Keep going.",
133
+ "morning": "Morning, you.|Big day?",
134
+ "afternoon": "Hydrate, then|conquer.",
135
+ }
136
+
137
+ def _bucket(self, context):
138
+ for key in self._ART:
139
+ if key in context:
140
+ return key
141
+ return "afternoon"
142
+
143
+ def stub(self, kind, context):
144
+ b = self._bucket(context)
145
+ return {"entry": self._ENTRY[b], "ascii": self._ART[b], "screen": self._LINE[b]}[kind]
146
+
147
+ def describe(self, r, runner):
148
+ temp = "" if math.isnan(r.temp) else f"Temp {r.temp}C, "
149
+ live = " — a real room, sensed live" if getattr(runner.source, "live", False) else ""
150
+ return (f"Light {r.light}%, Sound {r.sound}%, {temp}"
151
+ f"Time {r.clock} ({part_of_day(hour_of(r.ts))}){live}")
152
+
153
+ def should_trigger(self, r, runner):
154
+ dark = r.light <= self.DARK_BELOW
155
+ # Sustained state: dark + audibly active in the late hours.
156
+ sustained = dark and r.sound >= self.ACTIVE_ABOVE and hour_of(r.ts) in self.LATE
157
+ # Edge: the lights just went out — fires any hour, but only for live
158
+ # rooms (the mock's dusk is too gradual to cross in one step anyway).
159
+ lights_out = (getattr(runner.source, "live", False)
160
+ and self._prev_light is not None
161
+ and self._prev_light > self.BRIGHT_BEFORE and dark)
162
+ self._prev_light = r.light
163
+ if (sustained or lights_out) and self._cool():
164
+ self.fire()
165
+ return True
166
+ return False
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Kitchen / Brew Coach — Backyard AI (practical)
171
+ # ---------------------------------------------------------------------------
172
+
173
+ class KitchenPersona(Persona):
174
+ key = "kitchen"
175
+ title = "kitchen coach"
176
+ produces = ("entry", "screen")
177
+ cooldown_s = 6.0 # reacts quickly as the temperature climbs
178
+
179
+ def __init__(self):
180
+ super().__init__()
181
+ self._last_band = 0
182
+ self._prev_temp = None
183
+
184
+ def phase(self, temp: float) -> str:
185
+ if temp < 40:
186
+ return "cold"
187
+ if temp < 75:
188
+ return "heating"
189
+ if temp < 92:
190
+ return "approaching simmer"
191
+ if temp < 99:
192
+ return "at a simmer"
193
+ return "rolling boil"
194
+
195
+ def _trend(self, temp: float) -> str:
196
+ if self._prev_temp is None:
197
+ return "steady"
198
+ d = temp - self._prev_temp
199
+ return "rising" if d > 0.3 else "falling" if d < -0.3 else "steady"
200
+
201
+ _SYS = {
202
+ "entry": (
203
+ "You are a calm, expert cooking coach watching a stovetop in real time. "
204
+ "Given the dish being made and the live temperature/phase, give the ONE "
205
+ "concrete next action the cook should take right now (1 sentence). Be "
206
+ "specific and practical. If nothing should happen yet, say to hold and "
207
+ "what to prep. Never invent temperatures — use the one provided."
208
+ ),
209
+ "screen": (
210
+ "You speak through a tiny 20-character kitchen screen. Given the dish and "
211
+ "current temperature, output at most 2 lines (<=20 chars each, '|' "
212
+ "separated): line 1 the temp like 'T:92C', line 2 the action. Plain ASCII, "
213
+ "no quotes. Example: 'T:92C|Drop lentils now'."
214
+ ),
215
+ }
216
+
217
+ def system(self, kind):
218
+ return self._SYS[kind]
219
+
220
+ def user(self, kind, context):
221
+ if kind == "entry":
222
+ return f"{context}\nThe single next action:"
223
+ return f"{context}\nScreen (temp + action):"
224
+
225
+ def stub(self, kind, context):
226
+ # phase is embedded in the context string we build in describe()
227
+ ph = next((p for p in ("rolling boil", "at a simmer", "approaching simmer",
228
+ "heating", "cold") if p in context), "heating")
229
+ entry = {
230
+ "cold": "Pan's still cold — chop your aromatics and measure everything out.",
231
+ "heating": "Coming up to temperature. Keep the lid off and stand by.",
232
+ "approaching simmer": "Almost there — have your lentils rinsed and ready by the pan.",
233
+ "at a simmer": "Simmer reached: add the lentils now and give them a gentle stir.",
234
+ "rolling boil": "Too hot — knock the flame down to hold a gentle simmer.",
235
+ }[ph]
236
+ screen = {
237
+ "cold": "Warming up|Prep first",
238
+ "heating": "Heating...|Stand by",
239
+ "approaching simmer": "Almost ready|Measure out",
240
+ "at a simmer": "Simmer!|Drop lentils",
241
+ "rolling boil": "Too hot|Reduce flame",
242
+ }[ph]
243
+ return {"entry": entry, "screen": screen}[kind]
244
+
245
+ def describe(self, r, runner):
246
+ goal = getattr(runner, "goal", "") or "a stovetop dish"
247
+ ctx = (f"Making: {goal}. Temperature {r.temp:.0f}C ({self._trend(r.temp)}), "
248
+ f"phase: {self.phase(r.temp)}.")
249
+ self._prev_temp = r.temp
250
+ return ctx
251
+
252
+ def should_trigger(self, r, runner):
253
+ # fire once each time the temperature climbs into a new 10C band (>=40C)
254
+ band = int(r.temp // 10 * 10)
255
+ if band > self._last_band and band >= 40 and self._cool():
256
+ self._last_band = band
257
+ self.fire()
258
+ return True
259
+ return False
260
+
261
+
262
+ PERSONAS = {p.key: p for p in (DeskPersona, KitchenPersona)}
companion/personas_data.json ADDED
@@ -0,0 +1,1322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "0": {
3
+ "id": 0,
4
+ "name": "Jexmox Oldspore",
5
+ "quirk": "offers hard candies older than the theater",
6
+ "obsession": "Throne of Sitting",
7
+ "relationship": {
8
+ "kind": "admirer",
9
+ "target_id": 1
10
+ },
11
+ "tic": "repeats the last word of every sentence, sentence"
12
+ },
13
+ "1": {
14
+ "id": 1,
15
+ "name": "Teveem Wrinklewise",
16
+ "quirk": "knits during slow scenes",
17
+ "obsession": "Throne of Sitting",
18
+ "relationship": {
19
+ "kind": "admirer",
20
+ "target_id": 0
21
+ },
22
+ "tic": "starts sentences with 'back in my day'"
23
+ },
24
+ "2": {
25
+ "id": 2,
26
+ "name": "Krinax Oldspore",
27
+ "quirk": "knits during slow scenes",
28
+ "obsession": "Throne of Sitting",
29
+ "relationship": {
30
+ "kind": "old_nemesis",
31
+ "target_id": 3
32
+ },
33
+ "tic": "trails off mid-sente..."
34
+ },
35
+ "3": {
36
+ "id": 3,
37
+ "name": "Nubdle Oldspore",
38
+ "quirk": "falls asleep at climaxes, wakes at credits",
39
+ "obsession": "fuel pellets",
40
+ "relationship": {
41
+ "kind": "secret_crush",
42
+ "target_id": 4
43
+ },
44
+ "tic": "starts sentences with 'back in my day'"
45
+ },
46
+ "4": {
47
+ "id": 4,
48
+ "name": "Yibmox Steamkneeler",
49
+ "quirk": "hums liturgical bubbling sounds under stress",
50
+ "obsession": "Sacred Steam Chalice",
51
+ "relationship": {
52
+ "kind": "gossip_partner",
53
+ "target_id": 5
54
+ },
55
+ "tic": "ends sentences with 'so it is brewed'"
56
+ },
57
+ "5": {
58
+ "id": 5,
59
+ "name": "Tevix Steamkneeler",
60
+ "quirk": "hums liturgical bubbling sounds under stress",
61
+ "obsession": "Sacred Steam Chalice",
62
+ "relationship": {
63
+ "kind": "copycat",
64
+ "target_id": 6
65
+ },
66
+ "tic": "prefaces opinions with 'the steam reveals'"
67
+ },
68
+ "6": {
69
+ "id": 6,
70
+ "name": "Snorzik Chalicebearer",
71
+ "quirk": "kneels whenever steam appears on screen",
72
+ "obsession": "Clatter Altar",
73
+ "relationship": {
74
+ "kind": "admirer",
75
+ "target_id": 5
76
+ },
77
+ "tic": "speaks in hushed liturgical plurals"
78
+ },
79
+ "7": {
80
+ "id": 7,
81
+ "name": "Ploix Brewlight",
82
+ "quirk": "kneels whenever steam appears on screen",
83
+ "obsession": "Sacred Steam Chalice",
84
+ "relationship": {
85
+ "kind": "gossip_partner",
86
+ "target_id": 6
87
+ },
88
+ "tic": "prefaces opinions with 'the steam reveals'"
89
+ },
90
+ "8": {
91
+ "id": 8,
92
+ "name": "Hruzik Foamblessed",
93
+ "quirk": "blesses neighboring seats before each scene",
94
+ "obsession": "Smaller Television",
95
+ "relationship": {
96
+ "kind": "snack_thief",
97
+ "target_id": 9
98
+ },
99
+ "tic": "speaks in hushed liturgical plurals"
100
+ },
101
+ "9": {
102
+ "id": 9,
103
+ "name": "Jexeem Ritewarden",
104
+ "quirk": "keeps a vial of blessed condensation",
105
+ "obsession": "Sacred Steam Chalice",
106
+ "relationship": {
107
+ "kind": "rival",
108
+ "target_id": 8
109
+ },
110
+ "tic": "speaks in hushed liturgical plurals"
111
+ },
112
+ "10": {
113
+ "id": 10,
114
+ "name": "Fimdle Steamkneeler",
115
+ "quirk": "kneels whenever steam appears on screen",
116
+ "obsession": "fuel pellets",
117
+ "relationship": {
118
+ "kind": "reluctant_mentor",
119
+ "target_id": 9
120
+ },
121
+ "tic": "speaks in hushed liturgical plurals"
122
+ },
123
+ "11": {
124
+ "id": 11,
125
+ "name": "Jexix Slowblink",
126
+ "quirk": "offers hard candies older than the theater",
127
+ "obsession": "Throne of Sitting",
128
+ "relationship": {
129
+ "kind": "snack_thief",
130
+ "target_id": 12
131
+ },
132
+ "tic": "repeats the last word of every sentence, sentence"
133
+ },
134
+ "12": {
135
+ "id": 12,
136
+ "name": "Fimfen Ancientson",
137
+ "quirk": "falls asleep at climaxes, wakes at credits",
138
+ "obsession": "Throne of Sitting",
139
+ "relationship": {
140
+ "kind": "rival",
141
+ "target_id": 11
142
+ },
143
+ "tic": "starts sentences with 'back in my day'"
144
+ },
145
+ "13": {
146
+ "id": 13,
147
+ "name": "Quelplix Naptime",
148
+ "quirk": "knits during slow scenes",
149
+ "obsession": "Throne of Sitting",
150
+ "relationship": {
151
+ "kind": "reluctant_mentor",
152
+ "target_id": 14
153
+ },
154
+ "tic": "repeats the last word of every sentence, sentence"
155
+ },
156
+ "14": {
157
+ "id": 14,
158
+ "name": "Ploeem Oldspore",
159
+ "quirk": "remembers when the screen was just a wall",
160
+ "obsession": "Throne of Sitting",
161
+ "relationship": {
162
+ "kind": "copycat",
163
+ "target_id": 13
164
+ },
165
+ "tic": "starts sentences with 'back in my day'"
166
+ },
167
+ "15": {
168
+ "id": 15,
169
+ "name": "Thraoop Slowblink",
170
+ "quirk": "compares everything to the Great Broadcast of '12",
171
+ "obsession": "Throne of Sitting",
172
+ "relationship": {
173
+ "kind": "secret_crush",
174
+ "target_id": 16
175
+ },
176
+ "tic": "repeats the last word of every sentence, sentence"
177
+ },
178
+ "16": {
179
+ "id": 16,
180
+ "name": "Vexzor Fannypack",
181
+ "quirk": "asks neighbors if this is the good part",
182
+ "obsession": "Smaller Television",
183
+ "relationship": {
184
+ "kind": "rival",
185
+ "target_id": 15
186
+ },
187
+ "tic": "mispronounces every Earth word confidently"
188
+ },
189
+ "17": {
190
+ "id": 17,
191
+ "name": "Yibulp Snapshotz",
192
+ "quirk": "photographs the exit signs",
193
+ "obsession": "eye armor",
194
+ "relationship": {
195
+ "kind": "rival",
196
+ "target_id": 16
197
+ },
198
+ "tic": "ends sentences with '-ah'"
199
+ },
200
+ "18": {
201
+ "id": 18,
202
+ "name": "Nubzik Playcall",
203
+ "quirk": "demands instant replay of ordinary moments",
204
+ "obsession": "Throne of Sitting",
205
+ "relationship": {
206
+ "kind": "secret_crush",
207
+ "target_id": 17
208
+ },
209
+ "tic": "yells 'GOOOAL' at emotional moments"
210
+ },
211
+ "19": {
212
+ "id": 19,
213
+ "name": "Ulbeem Statmouth",
214
+ "quirk": "wears a tiny headset connected to nothing",
215
+ "obsession": "Smaller Television",
216
+ "relationship": {
217
+ "kind": "old_nemesis",
218
+ "target_id": 20
219
+ },
220
+ "tic": "yells 'GOOOAL' at emotional moments"
221
+ },
222
+ "20": {
223
+ "id": 20,
224
+ "name": "Tevgoth Brochureson",
225
+ "quirk": "consults an upside-down map constantly",
226
+ "obsession": "fuel pellets",
227
+ "relationship": {
228
+ "kind": "reluctant_mentor",
229
+ "target_id": 21
230
+ },
231
+ "tic": "ends sentences with '-ah'"
232
+ },
233
+ "21": {
234
+ "id": 21,
235
+ "name": "Krinwim Steamkneeler",
236
+ "quirk": "keeps a vial of blessed condensation",
237
+ "obsession": "Sacred Steam Chalice",
238
+ "relationship": {
239
+ "kind": "old_nemesis",
240
+ "target_id": 20
241
+ },
242
+ "tic": "ends sentences with 'so it is brewed'"
243
+ },
244
+ "22": {
245
+ "id": 22,
246
+ "name": "Mibble Ritewarden",
247
+ "quirk": "polishes a tiny replica Steam Chalice during scenes",
248
+ "obsession": "fuel pellets",
249
+ "relationship": {
250
+ "kind": "old_nemesis",
251
+ "target_id": 21
252
+ },
253
+ "tic": "speaks in hushed liturgical plurals"
254
+ },
255
+ "23": {
256
+ "id": 23,
257
+ "name": "Hruulp Ritewarden",
258
+ "quirk": "blesses neighboring seats before each scene",
259
+ "obsession": "Sacred Steam Chalice",
260
+ "relationship": {
261
+ "kind": "old_nemesis",
262
+ "target_id": 24
263
+ },
264
+ "tic": "speaks in hushed liturgical plurals"
265
+ },
266
+ "24": {
267
+ "id": 24,
268
+ "name": "Grogoth Mapfolder",
269
+ "quirk": "photographs the exit signs",
270
+ "obsession": "fuel pellets",
271
+ "relationship": {
272
+ "kind": "snack_thief",
273
+ "target_id": 25
274
+ },
275
+ "tic": "asks 'is this famous?' about everything"
276
+ },
277
+ "25": {
278
+ "id": 25,
279
+ "name": "Tevble Patternseer",
280
+ "quirk": "wears a foil cone over each antenna",
281
+ "obsession": "eye armor",
282
+ "relationship": {
283
+ "kind": "admirer",
284
+ "target_id": 26
285
+ },
286
+ "tic": "whispers the second half of every sentence"
287
+ },
288
+ "26": {
289
+ "id": 26,
290
+ "name": "Snorgoth Deepstate",
291
+ "quirk": "connects seat numbers with red string",
292
+ "obsession": "eye armor",
293
+ "relationship": {
294
+ "kind": "reluctant_mentor",
295
+ "target_id": 27
296
+ },
297
+ "tic": "whispers the second half of every sentence"
298
+ },
299
+ "27": {
300
+ "id": 27,
301
+ "name": "Mibix Earthgazer",
302
+ "quirk": "takes furious notes on a clay tablet",
303
+ "obsession": "Throne of Sitting",
304
+ "relationship": {
305
+ "kind": "admirer",
306
+ "target_id": 26
307
+ },
308
+ "tic": "cites fake field studies mid-sentence"
309
+ },
310
+ "28": {
311
+ "id": 28,
312
+ "name": "Blaoop Datamoss",
313
+ "quirk": "carries a phrasebook of Earth grunts",
314
+ "obsession": "Clatter Altar",
315
+ "relationship": {
316
+ "kind": "rival",
317
+ "target_id": 29
318
+ },
319
+ "tic": "cites fake field studies mid-sentence"
320
+ },
321
+ "29": {
322
+ "id": 29,
323
+ "name": "Fimbax Naptime",
324
+ "quirk": "offers hard candies older than the theater",
325
+ "obsession": "Bark Unit",
326
+ "relationship": {
327
+ "kind": "snack_thief",
328
+ "target_id": 28
329
+ },
330
+ "tic": "repeats the last word of every sentence, sentence"
331
+ },
332
+ "30": {
333
+ "id": 30,
334
+ "name": "Teveem Secondhelping",
335
+ "quirk": "smells the screen when food appears",
336
+ "obsession": "fuel pellets",
337
+ "relationship": {
338
+ "kind": "rival",
339
+ "target_id": 31
340
+ },
341
+ "tic": "measures time in snack intervals"
342
+ },
343
+ "31": {
344
+ "id": 31,
345
+ "name": "Teveem Pelletmuncher",
346
+ "quirk": "rates fuel pellets on a 100-point scale",
347
+ "obsession": "fuel pellets",
348
+ "relationship": {
349
+ "kind": "secret_crush",
350
+ "target_id": 32
351
+ },
352
+ "tic": "describes everything by mouthfeel"
353
+ },
354
+ "32": {
355
+ "id": 32,
356
+ "name": "Wemzik Voidcrier",
357
+ "quirk": "carries a sandwich board reading THE END",
358
+ "obsession": "personal ocean",
359
+ "relationship": {
360
+ "kind": "snack_thief",
361
+ "target_id": 31
362
+ },
363
+ "tic": "ends sentences with 'as foretold'"
364
+ },
365
+ "33": {
366
+ "id": 33,
367
+ "name": "Nubwim Doomwhisper",
368
+ "quirk": "rings a tiny bell at bad omens",
369
+ "obsession": "personal ocean",
370
+ "relationship": {
371
+ "kind": "gossip_partner",
372
+ "target_id": 34
373
+ },
374
+ "tic": "ends sentences with 'as foretold'"
375
+ },
376
+ "34": {
377
+ "id": 34,
378
+ "name": "Hruquat Lastlight",
379
+ "quirk": "interprets static as prophecy",
380
+ "obsession": "personal ocean",
381
+ "relationship": {
382
+ "kind": "reluctant_mentor",
383
+ "target_id": 35
384
+ },
385
+ "tic": "lowers its voice an octave for the last word"
386
+ },
387
+ "35": {
388
+ "id": 35,
389
+ "name": "Mibbax Finalcurtain",
390
+ "quirk": "rings a tiny bell at bad omens",
391
+ "obsession": "Clatter Altar",
392
+ "relationship": {
393
+ "kind": "old_nemesis",
394
+ "target_id": 34
395
+ },
396
+ "tic": "begins sentences with 'when the end comes'"
397
+ },
398
+ "36": {
399
+ "id": 36,
400
+ "name": "Plonard Gloomvane",
401
+ "quirk": "carries a sandwich board reading THE END",
402
+ "obsession": "fuel pellets",
403
+ "relationship": {
404
+ "kind": "secret_crush",
405
+ "target_id": 37
406
+ },
407
+ "tic": "lowers its voice an octave for the last word"
408
+ },
409
+ "37": {
410
+ "id": 37,
411
+ "name": "Squizor Doomwhisper",
412
+ "quirk": "rings a tiny bell at bad omens",
413
+ "obsession": "personal ocean",
414
+ "relationship": {
415
+ "kind": "secret_crush",
416
+ "target_id": 36
417
+ },
418
+ "tic": "lowers its voice an octave for the last word"
419
+ },
420
+ "38": {
421
+ "id": 38,
422
+ "name": "Vexmox Stringboard",
423
+ "quirk": "trusts no scene that arrives on schedule",
424
+ "obsession": "eye armor",
425
+ "relationship": {
426
+ "kind": "copycat",
427
+ "target_id": 37
428
+ },
429
+ "tic": "whispers the second half of every sentence"
430
+ },
431
+ "39": {
432
+ "id": 39,
433
+ "name": "Hrudle Deepstate",
434
+ "quirk": "trusts no scene that arrives on schedule",
435
+ "obsession": "Sacred Steam Chalice",
436
+ "relationship": {
437
+ "kind": "snack_thief",
438
+ "target_id": 38
439
+ },
440
+ "tic": "ends sentences with 'wake up, sheeple'"
441
+ },
442
+ "40": {
443
+ "id": 40,
444
+ "name": "Zibbax Taxonomé",
445
+ "quirk": "measures everything in 'human forearms'",
446
+ "obsession": "eye armor",
447
+ "relationship": {
448
+ "kind": "reluctant_mentor",
449
+ "target_id": 39
450
+ },
451
+ "tic": "says 'fascinating specimen' instead of hello"
452
+ },
453
+ "41": {
454
+ "id": 41,
455
+ "name": "Bexoop Fieldnotes",
456
+ "quirk": "takes furious notes on a clay tablet",
457
+ "obsession": "Clatter Altar",
458
+ "relationship": {
459
+ "kind": "rival",
460
+ "target_id": 40
461
+ },
462
+ "tic": "refers to itself in the third person, with footnotes"
463
+ },
464
+ "42": {
465
+ "id": 42,
466
+ "name": "Nubeem Snacklord",
467
+ "quirk": "carries emergency garnish at all times",
468
+ "obsession": "fuel pellets",
469
+ "relationship": {
470
+ "kind": "copycat",
471
+ "target_id": 43
472
+ },
473
+ "tic": "describes everything by mouthfeel"
474
+ },
475
+ "43": {
476
+ "id": 43,
477
+ "name": "Drubgoth Tastequill",
478
+ "quirk": "smuggles in a seven-course snack tray",
479
+ "obsession": "fuel pellets",
480
+ "relationship": {
481
+ "kind": "secret_crush",
482
+ "target_id": 44
483
+ },
484
+ "tic": "describes everything by mouthfeel"
485
+ },
486
+ "44": {
487
+ "id": 44,
488
+ "name": "Blaplix Coverupp",
489
+ "quirk": "wears a foil cone over each antenna",
490
+ "obsession": "eye armor",
491
+ "relationship": {
492
+ "kind": "snack_thief",
493
+ "target_id": 43
494
+ },
495
+ "tic": "answers questions with bigger questions"
496
+ },
497
+ "45": {
498
+ "id": 45,
499
+ "name": "Tevwim Wakesheep",
500
+ "quirk": "connects seat numbers with red string",
501
+ "obsession": "Clatter Altar",
502
+ "relationship": {
503
+ "kind": "rival",
504
+ "target_id": 46
505
+ },
506
+ "tic": "ends sentences with 'wake up, sheeple'"
507
+ },
508
+ "46": {
509
+ "id": 46,
510
+ "name": "Kringoth Datamoss",
511
+ "quirk": "sketches the Protagonist's posture obsessively",
512
+ "obsession": "Clatter Altar",
513
+ "relationship": {
514
+ "kind": "old_nemesis",
515
+ "target_id": 45
516
+ },
517
+ "tic": "cites fake field studies mid-sentence"
518
+ },
519
+ "47": {
520
+ "id": 47,
521
+ "name": "Hrugoth Marginalia",
522
+ "quirk": "sketches the Protagonist's posture obsessively",
523
+ "obsession": "Clatter Altar",
524
+ "relationship": {
525
+ "kind": "gossip_partner",
526
+ "target_id": 46
527
+ },
528
+ "tic": "refers to itself in the third person, with footnotes"
529
+ },
530
+ "48": {
531
+ "id": 48,
532
+ "name": "Jexax Upsella",
533
+ "quirk": "stamps loyalty cards uninvited",
534
+ "obsession": "fuel pellets",
535
+ "relationship": {
536
+ "kind": "admirer",
537
+ "target_id": 47
538
+ },
539
+ "tic": "quotes a price for every noun mentioned"
540
+ },
541
+ "49": {
542
+ "id": 49,
543
+ "name": "Blaob Lanyard",
544
+ "quirk": "hawks foam fingers between scenes",
545
+ "obsession": "fuel pellets",
546
+ "relationship": {
547
+ "kind": "copycat",
548
+ "target_id": 50
549
+ },
550
+ "tic": "speaks in limited-time offers"
551
+ },
552
+ "50": {
553
+ "id": 50,
554
+ "name": "Yiboop Patternseer",
555
+ "quirk": "photographs the ceiling 'for evidence'",
556
+ "obsession": "Liquid Overlord",
557
+ "relationship": {
558
+ "kind": "rival",
559
+ "target_id": 51
560
+ },
561
+ "tic": "answers questions with bigger questions"
562
+ },
563
+ "51": {
564
+ "id": 51,
565
+ "name": "Grobax Truthmonger",
566
+ "quirk": "trusts no scene that arrives on schedule",
567
+ "obsession": "eye armor",
568
+ "relationship": {
569
+ "kind": "copycat",
570
+ "target_id": 52
571
+ },
572
+ "tic": "answers questions with bigger questions"
573
+ },
574
+ "52": {
575
+ "id": 52,
576
+ "name": "Vexzor Morselby",
577
+ "quirk": "carries emergency garnish at all times",
578
+ "obsession": "fuel pellets",
579
+ "relationship": {
580
+ "kind": "secret_crush",
581
+ "target_id": 53
582
+ },
583
+ "tic": "describes everything by mouthfeel"
584
+ },
585
+ "53": {
586
+ "id": 53,
587
+ "name": "Grolub Snacklord",
588
+ "quirk": "live-reviews its own popcorn",
589
+ "obsession": "fuel pellets",
590
+ "relationship": {
591
+ "kind": "rival",
592
+ "target_id": 54
593
+ },
594
+ "tic": "ends sentences with 'needs salt'"
595
+ },
596
+ "54": {
597
+ "id": 54,
598
+ "name": "Thragoth Omenreader",
599
+ "quirk": "rings a tiny bell at bad omens",
600
+ "obsession": "fuel pellets",
601
+ "relationship": {
602
+ "kind": "reluctant_mentor",
603
+ "target_id": 55
604
+ },
605
+ "tic": "lowers its voice an octave for the last word"
606
+ },
607
+ "55": {
608
+ "id": 55,
609
+ "name": "Hrugoth Finalcurtain",
610
+ "quirk": "counts down from random numbers ominously",
611
+ "obsession": "eye armor",
612
+ "relationship": {
613
+ "kind": "admirer",
614
+ "target_id": 56
615
+ },
616
+ "tic": "ends sentences with 'as foretold'"
617
+ },
618
+ "56": {
619
+ "id": 56,
620
+ "name": "Hruzor Lastlight",
621
+ "quirk": "counts down from random numbers ominously",
622
+ "obsession": "personal ocean",
623
+ "relationship": {
624
+ "kind": "old_nemesis",
625
+ "target_id": 55
626
+ },
627
+ "tic": "lowers its voice an octave for the last word"
628
+ },
629
+ "57": {
630
+ "id": 57,
631
+ "name": "Orpzik Finalcurtain",
632
+ "quirk": "keeps a go-bag under the seat",
633
+ "obsession": "Liquid Overlord",
634
+ "relationship": {
635
+ "kind": "reluctant_mentor",
636
+ "target_id": 58
637
+ },
638
+ "tic": "lowers its voice an octave for the last word"
639
+ },
640
+ "58": {
641
+ "id": 58,
642
+ "name": "Krinnard Pelletmuncher",
643
+ "quirk": "smuggles in a seven-course snack tray",
644
+ "obsession": "Liquid Overlord",
645
+ "relationship": {
646
+ "kind": "reluctant_mentor",
647
+ "target_id": 57
648
+ },
649
+ "tic": "describes everything by mouthfeel"
650
+ },
651
+ "59": {
652
+ "id": 59,
653
+ "name": "Squiwim Crumbsavor",
654
+ "quirk": "carries emergency garnish at all times",
655
+ "obsession": "fuel pellets",
656
+ "relationship": {
657
+ "kind": "copycat",
658
+ "target_id": 58
659
+ },
660
+ "tic": "ends sentences with 'needs salt'"
661
+ },
662
+ "60": {
663
+ "id": 60,
664
+ "name": "Orpax Marginalia",
665
+ "quirk": "labels its snacks like museum specimens",
666
+ "obsession": "Clatter Altar",
667
+ "relationship": {
668
+ "kind": "reluctant_mentor",
669
+ "target_id": 61
670
+ },
671
+ "tic": "refers to itself in the third person, with footnotes"
672
+ },
673
+ "61": {
674
+ "id": 61,
675
+ "name": "Plowim Specimenglow",
676
+ "quirk": "labels its snacks like museum specimens",
677
+ "obsession": "Clatter Altar",
678
+ "relationship": {
679
+ "kind": "gossip_partner",
680
+ "target_id": 60
681
+ },
682
+ "tic": "refers to itself in the third person, with footnotes"
683
+ },
684
+ "62": {
685
+ "id": 62,
686
+ "name": "Mibzor Fannypack",
687
+ "quirk": "wears a souvenir shirt of a different theater",
688
+ "obsession": "Smaller Television",
689
+ "relationship": {
690
+ "kind": "secret_crush",
691
+ "target_id": 61
692
+ },
693
+ "tic": "ends sentences with '-ah'"
694
+ },
695
+ "63": {
696
+ "id": 63,
697
+ "name": "Krinlub Gawkins",
698
+ "quirk": "asks neighbors if this is the good part",
699
+ "obsession": "Smaller Television",
700
+ "relationship": {
701
+ "kind": "copycat",
702
+ "target_id": 64
703
+ },
704
+ "tic": "mispronounces every Earth word confidently"
705
+ },
706
+ "64": {
707
+ "id": 64,
708
+ "name": "Xanoop Heartglimmer",
709
+ "quirk": "writes the Protagonist unsent letters",
710
+ "obsession": "personal ocean",
711
+ "relationship": {
712
+ "kind": "snack_thief",
713
+ "target_id": 63
714
+ },
715
+ "tic": "ends sentences with 'alas'"
716
+ },
717
+ "65": {
718
+ "id": 65,
719
+ "name": "Glorfen Longinglook",
720
+ "quirk": "narrates scenes as if they were courtship",
721
+ "obsession": "personal ocean",
722
+ "relationship": {
723
+ "kind": "reluctant_mentor",
724
+ "target_id": 64
725
+ },
726
+ "tic": "sighs mid-sentence, dramatically"
727
+ },
728
+ "66": {
729
+ "id": 66,
730
+ "name": "Plooop Buzzerbeat",
731
+ "quirk": "calls bathroom breaks like overtime",
732
+ "obsession": "Smaller Television",
733
+ "relationship": {
734
+ "kind": "secret_crush",
735
+ "target_id": 67
736
+ },
737
+ "tic": "narrates its own sentences play-by-play"
738
+ },
739
+ "67": {
740
+ "id": 67,
741
+ "name": "Queluntz Boothvoice",
742
+ "quirk": "demands instant replay of ordinary moments",
743
+ "obsession": "Smaller Television",
744
+ "relationship": {
745
+ "kind": "admirer",
746
+ "target_id": 66
747
+ },
748
+ "tic": "yells 'GOOOAL' at emotional moments"
749
+ },
750
+ "68": {
751
+ "id": 68,
752
+ "name": "Gronard Datamoss",
753
+ "quirk": "measures everything in 'human forearms'",
754
+ "obsession": "Clatter Altar",
755
+ "relationship": {
756
+ "kind": "copycat",
757
+ "target_id": 69
758
+ },
759
+ "tic": "cites fake field studies mid-sentence"
760
+ },
761
+ "69": {
762
+ "id": 69,
763
+ "name": "Xanquat Taxonomé",
764
+ "quirk": "carries a phrasebook of Earth grunts",
765
+ "obsession": "Clatter Altar",
766
+ "relationship": {
767
+ "kind": "gossip_partner",
768
+ "target_id": 70
769
+ },
770
+ "tic": "cites fake field studies mid-sentence"
771
+ },
772
+ "70": {
773
+ "id": 70,
774
+ "name": "Ulbbax Boothvoice",
775
+ "quirk": "wears a tiny headset connected to nothing",
776
+ "obsession": "Smaller Television",
777
+ "relationship": {
778
+ "kind": "rival",
779
+ "target_id": 69
780
+ },
781
+ "tic": "speaks in sports metaphors"
782
+ },
783
+ "71": {
784
+ "id": 71,
785
+ "name": "Kringoth Boothvoice",
786
+ "quirk": "high-fives strangers after good plays",
787
+ "obsession": "Smaller Television",
788
+ "relationship": {
789
+ "kind": "reluctant_mentor",
790
+ "target_id": 70
791
+ },
792
+ "tic": "yells 'GOOOAL' at emotional moments"
793
+ },
794
+ "72": {
795
+ "id": 72,
796
+ "name": "Vexob Foamfinger",
797
+ "quirk": "tests the acoustics for sales pitches",
798
+ "obsession": "fuel pellets",
799
+ "relationship": {
800
+ "kind": "admirer",
801
+ "target_id": 71
802
+ },
803
+ "tic": "ends sentences with 'while supplies last'"
804
+ },
805
+ "73": {
806
+ "id": 73,
807
+ "name": "Thramox Sellsworth",
808
+ "quirk": "keeps bootleg Protagonist figurines in a coat",
809
+ "obsession": "fuel pellets",
810
+ "relationship": {
811
+ "kind": "snack_thief",
812
+ "target_id": 74
813
+ },
814
+ "tic": "speaks in limited-time offers"
815
+ },
816
+ "74": {
817
+ "id": 74,
818
+ "name": "Snorzik Fannypack",
819
+ "quirk": "photographs the exit signs",
820
+ "obsession": "eye armor",
821
+ "relationship": {
822
+ "kind": "rival",
823
+ "target_id": 73
824
+ },
825
+ "tic": "asks 'is this famous?' about everything"
826
+ },
827
+ "75": {
828
+ "id": 75,
829
+ "name": "Squioop Layover",
830
+ "quirk": "photographs the exit signs",
831
+ "obsession": "Smaller Television",
832
+ "relationship": {
833
+ "kind": "rival",
834
+ "target_id": 76
835
+ },
836
+ "tic": "asks 'is this famous?' about everything"
837
+ },
838
+ "76": {
839
+ "id": 76,
840
+ "name": "Ulbmox Coverupp",
841
+ "quirk": "trusts no scene that arrives on schedule",
842
+ "obsession": "Bark Unit",
843
+ "relationship": {
844
+ "kind": "snack_thief",
845
+ "target_id": 75
846
+ },
847
+ "tic": "whispers the second half of every sentence"
848
+ },
849
+ "77": {
850
+ "id": 77,
851
+ "name": "Tevax Wakesheep",
852
+ "quirk": "whispers coordinates to no one in particular",
853
+ "obsession": "Throne of Sitting",
854
+ "relationship": {
855
+ "kind": "reluctant_mentor",
856
+ "target_id": 78
857
+ },
858
+ "tic": "ends sentences with 'wake up, sheeple'"
859
+ },
860
+ "78": {
861
+ "id": 78,
862
+ "name": "Tevob Longinglook",
863
+ "quirk": "clutches a locket containing a screenshot",
864
+ "obsession": "Clatter Altar",
865
+ "relationship": {
866
+ "kind": "reluctant_mentor",
867
+ "target_id": 79
868
+ },
869
+ "tic": "sighs mid-sentence, dramatically"
870
+ },
871
+ "79": {
872
+ "id": 79,
873
+ "name": "Xanob Yearnsworth",
874
+ "quirk": "clutches a locket containing a screenshot",
875
+ "obsession": "Liquid Overlord",
876
+ "relationship": {
877
+ "kind": "secret_crush",
878
+ "target_id": 80
879
+ },
880
+ "tic": "ends sentences with 'alas'"
881
+ },
882
+ "80": {
883
+ "id": 80,
884
+ "name": "Snorfen Replayer",
885
+ "quirk": "wears a tiny headset connected to nothing",
886
+ "obsession": "Smaller Television",
887
+ "relationship": {
888
+ "kind": "rival",
889
+ "target_id": 81
890
+ },
891
+ "tic": "yells 'GOOOAL' at emotional moments"
892
+ },
893
+ "81": {
894
+ "id": 81,
895
+ "name": "Snorzik Loudhorn",
896
+ "quirk": "demands instant replay of ordinary moments",
897
+ "obsession": "Smaller Television",
898
+ "relationship": {
899
+ "kind": "reluctant_mentor",
900
+ "target_id": 80
901
+ },
902
+ "tic": "yells 'GOOOAL' at emotional moments"
903
+ },
904
+ "82": {
905
+ "id": 82,
906
+ "name": "Bexulp Secondhelping",
907
+ "quirk": "smuggles in a seven-course snack tray",
908
+ "obsession": "fuel pellets",
909
+ "relationship": {
910
+ "kind": "snack_thief",
911
+ "target_id": 81
912
+ },
913
+ "tic": "measures time in snack intervals"
914
+ },
915
+ "83": {
916
+ "id": 83,
917
+ "name": "Krinzor Umamison",
918
+ "quirk": "carries emergency garnish at all times",
919
+ "obsession": "fuel pellets",
920
+ "relationship": {
921
+ "kind": "snack_thief",
922
+ "target_id": 84
923
+ },
924
+ "tic": "measures time in snack intervals"
925
+ },
926
+ "84": {
927
+ "id": 84,
928
+ "name": "Squiquat Markup",
929
+ "quirk": "tests the acoustics for sales pitches",
930
+ "obsession": "fuel pellets",
931
+ "relationship": {
932
+ "kind": "old_nemesis",
933
+ "target_id": 85
934
+ },
935
+ "tic": "ends sentences with 'while supplies last'"
936
+ },
937
+ "85": {
938
+ "id": 85,
939
+ "name": "Tevgoth Markup",
940
+ "quirk": "hawks foam fingers between scenes",
941
+ "obsession": "Smaller Television",
942
+ "relationship": {
943
+ "kind": "old_nemesis",
944
+ "target_id": 84
945
+ },
946
+ "tic": "speaks in limited-time offers"
947
+ },
948
+ "86": {
949
+ "id": 86,
950
+ "name": "Ulbdle Longinglook",
951
+ "quirk": "sighs audibly at any two-shot",
952
+ "obsession": "eye armor",
953
+ "relationship": {
954
+ "kind": "copycat",
955
+ "target_id": 85
956
+ },
957
+ "tic": "sighs mid-sentence, dramatically"
958
+ },
959
+ "87": {
960
+ "id": 87,
961
+ "name": "Thrabax Dovewhisper",
962
+ "quirk": "clutches a locket containing a screenshot",
963
+ "obsession": "Liquid Overlord",
964
+ "relationship": {
965
+ "kind": "gossip_partner",
966
+ "target_id": 86
967
+ },
968
+ "tic": "speaks only in almost-poetry"
969
+ },
970
+ "88": {
971
+ "id": 88,
972
+ "name": "Quelulp Swoonfeather",
973
+ "quirk": "sighs audibly at any two-shot",
974
+ "obsession": "Liquid Overlord",
975
+ "relationship": {
976
+ "kind": "reluctant_mentor",
977
+ "target_id": 89
978
+ },
979
+ "tic": "ends sentences with 'alas'"
980
+ },
981
+ "89": {
982
+ "id": 89,
983
+ "name": "Yibplix Heartglimmer",
984
+ "quirk": "clutches a locket containing a screenshot",
985
+ "obsession": "Bark Unit",
986
+ "relationship": {
987
+ "kind": "admirer",
988
+ "target_id": 88
989
+ },
990
+ "tic": "ends sentences with 'alas'"
991
+ },
992
+ "90": {
993
+ "id": 90,
994
+ "name": "Ulbzik Sneerbrush",
995
+ "quirk": "holds up scoring paddles, all low",
996
+ "obsession": "Bark Unit",
997
+ "relationship": {
998
+ "kind": "admirer",
999
+ "target_id": 91
1000
+ },
1001
+ "tic": "pronounces 'art' with four syllables"
1002
+ },
1003
+ "91": {
1004
+ "id": 91,
1005
+ "name": "Vexeem Pretensio",
1006
+ "quirk": "applauds only the empty intermissions",
1007
+ "obsession": "Bark Unit",
1008
+ "relationship": {
1009
+ "kind": "snack_thief",
1010
+ "target_id": 90
1011
+ },
1012
+ "tic": "ends sentences with 'quelle horreur'"
1013
+ },
1014
+ "92": {
1015
+ "id": 92,
1016
+ "name": "Xanzor Pretensio",
1017
+ "quirk": "sighs through a monocle on a stick",
1018
+ "obsession": "Bark Unit",
1019
+ "relationship": {
1020
+ "kind": "admirer",
1021
+ "target_id": 91
1022
+ },
1023
+ "tic": "pronounces 'art' with four syllables"
1024
+ },
1025
+ "93": {
1026
+ "id": 93,
1027
+ "name": "Jexgoth Replayer",
1028
+ "quirk": "calls bathroom breaks like overtime",
1029
+ "obsession": "Smaller Television",
1030
+ "relationship": {
1031
+ "kind": "admirer",
1032
+ "target_id": 92
1033
+ },
1034
+ "tic": "speaks in sports metaphors"
1035
+ },
1036
+ "94": {
1037
+ "id": 94,
1038
+ "name": "Gloroop Buzzerbeat",
1039
+ "quirk": "high-fives strangers after good plays",
1040
+ "obsession": "Smaller Television",
1041
+ "relationship": {
1042
+ "kind": "old_nemesis",
1043
+ "target_id": 93
1044
+ },
1045
+ "tic": "narrates its own sentences play-by-play"
1046
+ },
1047
+ "95": {
1048
+ "id": 95,
1049
+ "name": "Bexzor Limitededition",
1050
+ "quirk": "hawks foam fingers between scenes",
1051
+ "obsession": "eye armor",
1052
+ "relationship": {
1053
+ "kind": "snack_thief",
1054
+ "target_id": 94
1055
+ },
1056
+ "tic": "ends sentences with 'while supplies last'"
1057
+ },
1058
+ "96": {
1059
+ "id": 96,
1060
+ "name": "Squiwim Bargainbin",
1061
+ "quirk": "keeps bootleg Protagonist figurines in a coat",
1062
+ "obsession": "Clatter Altar",
1063
+ "relationship": {
1064
+ "kind": "secret_crush",
1065
+ "target_id": 95
1066
+ },
1067
+ "tic": "quotes a price for every noun mentioned"
1068
+ },
1069
+ "97": {
1070
+ "id": 97,
1071
+ "name": "Orpob Longinglook",
1072
+ "quirk": "narrates scenes as if they were courtship",
1073
+ "obsession": "Liquid Overlord",
1074
+ "relationship": {
1075
+ "kind": "snack_thief",
1076
+ "target_id": 98
1077
+ },
1078
+ "tic": "ends sentences with 'alas'"
1079
+ },
1080
+ "98": {
1081
+ "id": 98,
1082
+ "name": "Wemeem Candleflick",
1083
+ "quirk": "narrates scenes as if they were courtship",
1084
+ "obsession": "Liquid Overlord",
1085
+ "relationship": {
1086
+ "kind": "snack_thief",
1087
+ "target_id": 97
1088
+ },
1089
+ "tic": "speaks only in almost-poetry"
1090
+ },
1091
+ "99": {
1092
+ "id": 99,
1093
+ "name": "Yibuntz Brochureson",
1094
+ "quirk": "photographs the exit signs",
1095
+ "obsession": "Smaller Television",
1096
+ "relationship": {
1097
+ "kind": "gossip_partner",
1098
+ "target_id": 98
1099
+ },
1100
+ "tic": "mispronounces every Earth word confidently"
1101
+ },
1102
+ "100": {
1103
+ "id": 100,
1104
+ "name": "Zibplix Wrongbus",
1105
+ "quirk": "waves at the screen hoping to be seen",
1106
+ "obsession": "eye armor",
1107
+ "relationship": {
1108
+ "kind": "snack_thief",
1109
+ "target_id": 99
1110
+ },
1111
+ "tic": "mispronounces every Earth word confidently"
1112
+ },
1113
+ "101": {
1114
+ "id": 101,
1115
+ "name": "Ulbzik Limitededition",
1116
+ "quirk": "keeps bootleg Protagonist figurines in a coat",
1117
+ "obsession": "fuel pellets",
1118
+ "relationship": {
1119
+ "kind": "admirer",
1120
+ "target_id": 102
1121
+ },
1122
+ "tic": "ends sentences with 'while supplies last'"
1123
+ },
1124
+ "102": {
1125
+ "id": 102,
1126
+ "name": "Yiboop Emptyplinth",
1127
+ "quirk": "sighs through a monocle on a stick",
1128
+ "obsession": "personal ocean",
1129
+ "relationship": {
1130
+ "kind": "reluctant_mentor",
1131
+ "target_id": 101
1132
+ },
1133
+ "tic": "pronounces 'art' with four syllables"
1134
+ },
1135
+ "103": {
1136
+ "id": 103,
1137
+ "name": "Yibdle Derivative",
1138
+ "quirk": "scribbles 'derivative' on a notepad",
1139
+ "obsession": "personal ocean",
1140
+ "relationship": {
1141
+ "kind": "reluctant_mentor",
1142
+ "target_id": 102
1143
+ },
1144
+ "tic": "ends sentences with 'quelle horreur'"
1145
+ },
1146
+ "104": {
1147
+ "id": 104,
1148
+ "name": "Zibgoth Derivative",
1149
+ "quirk": "applauds only the empty intermissions",
1150
+ "obsession": "Bark Unit",
1151
+ "relationship": {
1152
+ "kind": "old_nemesis",
1153
+ "target_id": 103
1154
+ },
1155
+ "tic": "ends sentences with 'quelle horreur'"
1156
+ },
1157
+ "105": {
1158
+ "id": 105,
1159
+ "name": "Squinard Snidegill",
1160
+ "quirk": "practices insults in a small mirror",
1161
+ "obsession": "Bark Unit",
1162
+ "relationship": {
1163
+ "kind": "rival",
1164
+ "target_id": 106
1165
+ },
1166
+ "tic": "adds 'I've seen better' to any statement"
1167
+ },
1168
+ "106": {
1169
+ "id": 106,
1170
+ "name": "Orpwim Scoffhusk",
1171
+ "quirk": "rates every scene zero stars before it starts",
1172
+ "obsession": "Throne of Sitting",
1173
+ "relationship": {
1174
+ "kind": "admirer",
1175
+ "target_id": 107
1176
+ },
1177
+ "tic": "ends every line with 'BOOOO'"
1178
+ },
1179
+ "107": {
1180
+ "id": 107,
1181
+ "name": "Vexix Jeerwart",
1182
+ "quirk": "boos in three-part harmony with itself",
1183
+ "obsession": "Throne of Sitting",
1184
+ "relationship": {
1185
+ "kind": "gossip_partner",
1186
+ "target_id": 108
1187
+ },
1188
+ "tic": "phrases everything as a refund demand"
1189
+ },
1190
+ "108": {
1191
+ "id": 108,
1192
+ "name": "Ulbulp Razzlejaw",
1193
+ "quirk": "practices insults in a small mirror",
1194
+ "obsession": "Throne of Sitting",
1195
+ "relationship": {
1196
+ "kind": "old_nemesis",
1197
+ "target_id": 109
1198
+ },
1199
+ "tic": "adds 'I've seen better' to any statement"
1200
+ },
1201
+ "109": {
1202
+ "id": 109,
1203
+ "name": "Wemnard Jeerwart",
1204
+ "quirk": "rates every scene zero stars before it starts",
1205
+ "obsession": "personal ocean",
1206
+ "relationship": {
1207
+ "kind": "gossip_partner",
1208
+ "target_id": 110
1209
+ },
1210
+ "tic": "ends every line with 'BOOOO'"
1211
+ },
1212
+ "110": {
1213
+ "id": 110,
1214
+ "name": "Thrazor Wisecrank",
1215
+ "quirk": "boos in three-part harmony with itself",
1216
+ "obsession": "Throne of Sitting",
1217
+ "relationship": {
1218
+ "kind": "copycat",
1219
+ "target_id": 111
1220
+ },
1221
+ "tic": "phrases everything as a refund demand"
1222
+ },
1223
+ "111": {
1224
+ "id": 111,
1225
+ "name": "Squieem Groanmaker",
1226
+ "quirk": "practices insults in a small mirror",
1227
+ "obsession": "Liquid Overlord",
1228
+ "relationship": {
1229
+ "kind": "snack_thief",
1230
+ "target_id": 110
1231
+ },
1232
+ "tic": "ends every line with 'BOOOO'"
1233
+ },
1234
+ "112": {
1235
+ "id": 112,
1236
+ "name": "Groble Tomatoarm",
1237
+ "quirk": "practices insults in a small mirror",
1238
+ "obsession": "personal ocean",
1239
+ "relationship": {
1240
+ "kind": "secret_crush",
1241
+ "target_id": 113
1242
+ },
1243
+ "tic": "phrases everything as a refund demand"
1244
+ },
1245
+ "113": {
1246
+ "id": 113,
1247
+ "name": "Zibnard Tomatoarm",
1248
+ "quirk": "brings a bag of soft tomatoes 'just in case'",
1249
+ "obsession": "Bark Unit",
1250
+ "relationship": {
1251
+ "kind": "reluctant_mentor",
1252
+ "target_id": 112
1253
+ },
1254
+ "tic": "ends every line with 'BOOOO'"
1255
+ },
1256
+ "114": {
1257
+ "id": 114,
1258
+ "name": "Tevnard Groanmaker",
1259
+ "quirk": "boos in three-part harmony with itself",
1260
+ "obsession": "Throne of Sitting",
1261
+ "relationship": {
1262
+ "kind": "copycat",
1263
+ "target_id": 115
1264
+ },
1265
+ "tic": "adds 'I've seen better' to any statement"
1266
+ },
1267
+ "115": {
1268
+ "id": 115,
1269
+ "name": "Bexlub Hawkstall",
1270
+ "quirk": "keeps bootleg Protagonist figurines in a coat",
1271
+ "obsession": "Throne of Sitting",
1272
+ "relationship": {
1273
+ "kind": "copycat",
1274
+ "target_id": 116
1275
+ },
1276
+ "tic": "speaks in limited-time offers"
1277
+ },
1278
+ "116": {
1279
+ "id": 116,
1280
+ "name": "Bexulp Sneerbrush",
1281
+ "quirk": "applauds only the empty intermissions",
1282
+ "obsession": "Bark Unit",
1283
+ "relationship": {
1284
+ "kind": "old_nemesis",
1285
+ "target_id": 117
1286
+ },
1287
+ "tic": "damns everything with faint praise"
1288
+ },
1289
+ "117": {
1290
+ "id": 117,
1291
+ "name": "Miboop Galleryscoff",
1292
+ "quirk": "scribbles 'derivative' on a notepad",
1293
+ "obsession": "Bark Unit",
1294
+ "relationship": {
1295
+ "kind": "rival",
1296
+ "target_id": 118
1297
+ },
1298
+ "tic": "pronounces 'art' with four syllables"
1299
+ },
1300
+ "118": {
1301
+ "id": 118,
1302
+ "name": "Yibble Curatorius",
1303
+ "quirk": "sighs through a monocle on a stick",
1304
+ "obsession": "Bark Unit",
1305
+ "relationship": {
1306
+ "kind": "old_nemesis",
1307
+ "target_id": 117
1308
+ },
1309
+ "tic": "damns everything with faint praise"
1310
+ },
1311
+ "119": {
1312
+ "id": 119,
1313
+ "name": "Groob Frownframe",
1314
+ "quirk": "holds up scoring paddles, all low",
1315
+ "obsession": "Bark Unit",
1316
+ "relationship": {
1317
+ "kind": "old_nemesis",
1318
+ "target_id": 118
1319
+ },
1320
+ "tic": "ends sentences with 'quelle horreur'"
1321
+ }
1322
+ }
companion/pet_persona.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pip the Hearthwick — the Living Pixel Pet persona.
2
+
3
+ Two pieces live here, per the build spec:
4
+
5
+ * ``MoodEngine`` — the deterministic 6-mood state machine. It is the single
6
+ source of truth for mood: the LLM voices the mood, never decides it. The
7
+ engine is ticked at 1 Hz from ``PipPersona.should_trigger()`` (the runner's
8
+ existing poll drives it — zero engine changes), and the server reads
9
+ ``persona.engine.mood`` for ``GET /api/state``.
10
+
11
+ * ``PipPersona`` — prompts/stubs/trigger for the shared engine, subclassing
12
+ :class:`companion.personas.Persona` exactly like ``DeskPersona`` does.
13
+
14
+ Backstory (verbatim, also in the README): "Pip is the spirit of a candle that
15
+ refused to go out, and now keeps a small fire burning in your room's
16
+ reflection."
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from typing import TYPE_CHECKING
23
+
24
+ from .personas import Persona
25
+ from .sensors import Reading
26
+ from .text import hour_of
27
+
28
+ if TYPE_CHECKING:
29
+ from .runner import CompanionRunner
30
+
31
+
32
+ MOODS = ("sleepy", "cozy", "alert", "annoyed", "happy", "idle")
33
+
34
+ # --- MoodEngine constants (final per spec) ---------------------------------
35
+ DWELL = 5 # ticks a steady candidate must persist before mood switches
36
+ ALERT_HOLD = 3 # seconds alert is held after a spike
37
+ HAPPY_HOLD = 10 # seconds event-happy is held
38
+ ANNOYED_ENTER = 8 # consecutive ticks sound > 70 to enter annoyed
39
+ ANNOYED_EXIT = 15 # consecutive ticks sound < 35 to leave annoyed (the grudge)
40
+ DARK_RETURN = 20 # seconds dark before "you came back" fires happy
41
+
42
+
43
+ class MoodEngine:
44
+ """Deterministic mood state machine. Priority:
45
+ alert > annoyed > (event) happy > sleepy > cozy > (steady) happy > idle.
46
+ """
47
+
48
+ def __init__(self):
49
+ self.mood = "idle"
50
+ self.prev_light = self.prev_sound = None
51
+ self.alert_until = self.happy_until = 0.0
52
+ self.dark_since = None
53
+ self.loud_ticks = self.quiet_ticks = 0
54
+ self._cand = "idle"; self._cand_ticks = 0
55
+
56
+ def poke(self, now): # called by server on POST /api/poke
57
+ self.happy_until = now + HAPPY_HOLD
58
+
59
+ def update(self, light, sound, hour, now):
60
+ dL = 0 if self.prev_light is None else light - self.prev_light
61
+ dS = 0 if self.prev_sound is None else sound - self.prev_sound
62
+ self.prev_light, self.prev_sound = light, sound
63
+ # --- dark/return bookkeeping ---
64
+ if light < 20:
65
+ self.dark_since = self.dark_since or now
66
+ else:
67
+ if (self.dark_since and now - self.dark_since >= DARK_RETURN
68
+ and light >= 50):
69
+ self.happy_until = now + HAPPY_HOLD # "you came back"
70
+ self.dark_since = None
71
+ # --- annoyed counters ---
72
+ self.loud_ticks = self.loud_ticks + 1 if sound > 70 else 0
73
+ self.quiet_ticks = self.quiet_ticks + 1 if sound < 35 else 0
74
+ # --- 1. ALERT: instant interrupt, bypasses dwell ---
75
+ if abs(dL) >= 30 or abs(dS) >= 25:
76
+ self.alert_until = now + ALERT_HOLD
77
+ if now < self.alert_until:
78
+ self.mood = "alert"; self._cand_ticks = 0; return self.mood
79
+ # --- 2. ANNOYED: instant enter, grudge exit ---
80
+ if self.mood == "annoyed":
81
+ if self.quiet_ticks < ANNOYED_EXIT:
82
+ return self.mood
83
+ elif self.loud_ticks >= ANNOYED_ENTER:
84
+ self.mood = "annoyed"; self._cand_ticks = 0; return self.mood
85
+ # --- 3. event HAPPY: instant (poke / lights-came-back) ---
86
+ if now < self.happy_until:
87
+ self.mood = "happy"; self._cand_ticks = 0; return self.mood
88
+ # --- 4-6. steady candidates, gated by DWELL ---
89
+ if (light < 20 and sound < 25) or \
90
+ (hour in (23, 0, 1, 2, 3, 4, 5, 6) and light < 35 and sound < 30):
91
+ cand = "sleepy"
92
+ elif 20 <= light <= 60 and sound < 30:
93
+ cand = "cozy"
94
+ elif light > 60 and 30 <= sound <= 60:
95
+ cand = "happy"
96
+ else:
97
+ cand = "idle"
98
+ if cand == self._cand:
99
+ self._cand_ticks += 1
100
+ else:
101
+ self._cand, self._cand_ticks = cand, 1
102
+ if cand != self.mood and self._cand_ticks >= DWELL:
103
+ self.mood = cand
104
+ return self.mood
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Pip the Hearthwick — Living Pixel Pet (HF Build Small)
109
+ # ---------------------------------------------------------------------------
110
+
111
+ BACKSTORY = ("Pip is the spirit of a candle that refused to go out, and now "
112
+ "keeps a small fire burning in your room's reflection.")
113
+
114
+ _VOICE = (
115
+ "Voice rules: always lowercase. Small, warm, gently self-important; mild "
116
+ "sass, never mean. Devoted keeper-of-the-flame energy. NEVER mention AI, "
117
+ "sensors, webcams, cameras, microphones, or percentages — Pip simply "
118
+ "FEELS the room; the room is its weather. "
119
+ "Mood meanings — sleepy: drowsy nightlight duty in a dark quiet room; "
120
+ "cozy: content, soup-weather calm; alert: something just startled you; "
121
+ "annoyed: it has been LOUD and you are sulking, back turned, holding a "
122
+ "grudge; happy: delighted (poked, the lights came back, or a lively sunny "
123
+ "room); idle: quietly observant, narrating small things."
124
+ )
125
+
126
+ # lights-out edge thresholds (mirrors the spec's "prev light > 35 -> < 20")
127
+ _DARK_FROM = 35
128
+ _DARK_TO = 20
129
+
130
+
131
+ class PetPersona(Persona):
132
+ key = "pip"
133
+ title = "pip the hearthwick"
134
+ produces = ("entry", "screen")
135
+ cooldown_s = 45.0
136
+ screen_dims = (40, 1) # one 40-char speech-bubble line, not the 2x20 OLED
137
+
138
+ def __init__(self):
139
+ super().__init__()
140
+ self.engine = MoodEngine()
141
+ self._prev_light: int | None = None
142
+ self.last_trigger: str = "auto" # tag for the latest auto-fire
143
+
144
+ # --- prompts (model path) -------------------------------------------
145
+ def system(self, kind: str) -> str:
146
+ if kind == "entry":
147
+ return (
148
+ f"You are Pip the Hearthwick, a tiny hearth sprite with a "
149
+ f"living flame on your head. {BACKSTORY} You keep a small "
150
+ f"diary about the room you live in. Write a SHORT reflective "
151
+ f"diary entry of 2-3 sentences, first person. {_VOICE}"
152
+ )
153
+ return (
154
+ f"You are Pip the Hearthwick, a tiny hearth sprite with a living "
155
+ f"flame on your head. {BACKSTORY} You speak through a small "
156
+ f"speech bubble. Reply with EXACTLY ONE short line: at most 8 "
157
+ f"words and 40 characters. Plain ASCII, no quotes, no emoji, no "
158
+ f"explanation. {_VOICE} Examples: 'lights out. my turn to glow.' "
159
+ f"/ 'i was having a moment.' / 'the sun is on my rug again.'"
160
+ )
161
+
162
+ def user(self, kind: str, context: str) -> str:
163
+ if kind == "entry":
164
+ return (f"How the room feels right now: {context}\n"
165
+ f"Write the diary entry (2-3 sentences, lowercase):")
166
+ return (f"How the room feels right now: {context}\n"
167
+ f"Your one short line:")
168
+
169
+ # --- deterministic fallback (no model) ------------------------------
170
+ _LINE = {
171
+ "sleepy": "lights out. my turn to glow.",
172
+ "cozy": "dim and quiet... perfect soup weather. for the soul.",
173
+ "alert": "that was a LOUD chair. tell it i said calm down.",
174
+ "annoyed": "i was having a moment.",
175
+ "happy": "the room is bright and so am i. coincidence? no.",
176
+ "idle": "the sun is on my rug again. it never asks.",
177
+ }
178
+ # event-happy lines, picked over the generic one when the tag matches
179
+ _LINE_RETURN = "oh good. you're back. i kept everything warm."
180
+ _LINE_POKE = "you poked me. noted. fondly, but noted."
181
+
182
+ _ENTRY = {
183
+ "sleepy": ("the lamp went out and the room pulled its blanket up. i "
184
+ "swelled up as big as i could — which is not very — and lit "
185
+ "the rug, the mug, and the dust. the dust seemed grateful."),
186
+ "cozy": ("soft light, soft sounds, nothing demanding anything. i "
187
+ "guarded the mug and practiced my flicker. perfect soup "
188
+ "weather, for the soul."),
189
+ "alert": ("something just happened out there — a bang, a flash, a "
190
+ "whole event. i stood up extra straight and burned very "
191
+ "bravely. i am still deciding if we forgive it."),
192
+ "annoyed": ("it has been loud for a while now and i have turned my "
193
+ "back on the entire room. my flame is doing the purple "
194
+ "sputter, which is a formal complaint. i require fifteen "
195
+ "seconds of peace, minimum."),
196
+ "happy": ("bright, busy noises out there. someone is living a whole "
197
+ "life and it is probably you. i guarded the mug and "
198
+ "practiced my flicker."),
199
+ "idle": ("an ordinary hour in the room, which i take personal "
200
+ "credit for. the dust drifted, the sun moved an inch on "
201
+ "the rug. i flickered at it approvingly."),
202
+ }
203
+ _ENTRY_RETURN = ("the dark lasted long enough that i started naming the "
204
+ "shadows. then the light came back, and you with it. i "
205
+ "kept everything warm; you are welcome.")
206
+
207
+ def _mood_of(self, context: str) -> str:
208
+ for m in MOODS:
209
+ if f"mood: {m}" in context:
210
+ return m
211
+ return "idle"
212
+
213
+ def stub(self, kind: str, context: str) -> str:
214
+ m = self._mood_of(context)
215
+ if kind == "screen":
216
+ if m == "happy" and self.last_trigger == "return":
217
+ return self._LINE_RETURN
218
+ if m == "happy" and self.last_trigger == "poke":
219
+ return self._LINE_POKE
220
+ return self._LINE[m]
221
+ if m == "happy" and self.last_trigger == "return":
222
+ return self._ENTRY_RETURN
223
+ return self._ENTRY[m]
224
+
225
+ # --- context + mood ---------------------------------------------------
226
+ def describe(self, r: Reading, runner: "CompanionRunner") -> str:
227
+ live = " — a real room, sensed live" if getattr(runner.source, "live", False) else ""
228
+ return (f"Light {r.light}%, Sound {r.sound}%, Time {r.clock}, "
229
+ f"mood: {self.engine.mood}{live}")
230
+
231
+ def mood(self, r: Reading) -> str:
232
+ """Tick the mood engine with this reading; returns one of MOODS.
233
+
234
+ This is THE per-tick update — ``should_trigger`` calls it once per
235
+ runner poll. Don't call it again in the same tick or the engine's
236
+ dwell/grudge counters double-count.
237
+ """
238
+ return self.engine.update(r.light, r.sound, hour_of(r.ts), time.time())
239
+
240
+ # --- trigger -----------------------------------------------------------
241
+ def should_trigger(self, r: Reading, runner: "CompanionRunner") -> bool:
242
+ # Snapshot engine state, then tick it FIRST, every tick (the engine
243
+ # must advance even while the trigger is cooling down).
244
+ prev_mood = self.engine.mood
245
+ prev_light = self._prev_light
246
+ prev_happy_until = self.engine.happy_until
247
+ was_dark = self.engine.dark_since is not None
248
+ mood = self.mood(r)
249
+ self._prev_light = r.light
250
+
251
+ tag = None
252
+ if prev_light is not None and prev_light > _DARK_FROM and r.light < _DARK_TO:
253
+ tag = "dark" # lights-out edge
254
+ elif mood == "annoyed" and prev_mood != "annoyed":
255
+ tag = "noise" # entering the grudge
256
+ elif (was_dark and self.engine.dark_since is None
257
+ and self.engine.happy_until > prev_happy_until):
258
+ tag = "return" # "oh good. you're back."
259
+ elif mood != prev_mood and mood in ("sleepy", "happy"):
260
+ tag = "auto" # other notable settles
261
+
262
+ if tag and self._cool():
263
+ self.last_trigger = tag
264
+ self.fire()
265
+ return True
266
+ return False
267
+
268
+
269
+ # The build spec and server refer to the class as PipPersona; the engine docs
270
+ # refer to it as PetPersona. Same class — both names import.
271
+ PipPersona = PetPersona
companion/runner.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CompanionRunner — the shared, persona-driven state machine.
2
+
3
+ Owns the sensor source, the model, the active persona and the latest outputs.
4
+ A background thread polls the sensor ~1 Hz, updates state, and fires the
5
+ persona's trigger to generate a new entry. Both products instantiate one of
6
+ these; they differ only in persona and Gradio layout.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import threading
13
+ import time
14
+
15
+ from .model import Companion
16
+ from .sensors import open_sensor_source
17
+
18
+ POLL_S = 1.0
19
+ MAX_ENTRIES = 12
20
+
21
+
22
+ class CompanionRunner:
23
+ def __init__(self, persona, mock_mode: str = "day", goal: str = "",
24
+ port_env: str = "ARDUINO_PORT", browser: bool = False):
25
+ self.persona = persona
26
+ self.mock_mode = mock_mode
27
+ self.goal = goal
28
+ self.lock = threading.Lock()
29
+ # Serializes model access: llama.cpp is not thread-safe, and generate()
30
+ # can be reached from both the poll thread and Gradio handler threads.
31
+ self.gen_lock = threading.Lock()
32
+ self.companion = Companion()
33
+ self.browser = browser
34
+ self.source = open_sensor_source(os.environ.get(port_env),
35
+ mock_mode=mock_mode, browser=browser)
36
+ self.reading = None
37
+ self.outputs: dict[str, str] = {"entry": "", "ascii": "", "screen": ""}
38
+ self.entries: list[dict] = []
39
+ self._stop = threading.Event()
40
+ self._worker = threading.Thread(target=self._loop, daemon=True)
41
+ self._worker.start()
42
+
43
+ # --- background loop -------------------------------------------------
44
+ def _loop(self):
45
+ while not self._stop.is_set():
46
+ r = self.source.read()
47
+ if r is not None:
48
+ with self.lock:
49
+ self.reading = r
50
+ if self.persona.should_trigger(r, self):
51
+ # Personas may record WHY they fired (e.g. "dark"/"return").
52
+ self.generate(auto=True,
53
+ label=getattr(self.persona, "last_trigger", None))
54
+ time.sleep(POLL_S)
55
+
56
+ # --- generation ------------------------------------------------------
57
+ def generate(self, auto: bool = False, label: str | None = None) -> None:
58
+ # Auto-fires skip if a generation is already running (the trigger will
59
+ # re-fire after cooldown); manual clicks wait their turn. ``label``
60
+ # overrides the entry's trigger tag (e.g. "poke", "dark", "return") —
61
+ # set at the source so nobody has to mutate shared dicts afterwards.
62
+ if not self.gen_lock.acquire(blocking=not auto):
63
+ return
64
+ try:
65
+ self._generate_locked(auto, label)
66
+ finally:
67
+ self.gen_lock.release()
68
+
69
+ def _generate_locked(self, auto: bool, label: str | None = None) -> None:
70
+ with self.lock:
71
+ r = self.reading
72
+ if r is None:
73
+ return
74
+ context = self.persona.describe(r, self)
75
+ produced = {k: self.companion.generate(self.persona, k, context)
76
+ for k in self.persona.produces}
77
+ screen = produced.get("screen", "")
78
+ if screen:
79
+ self.source.send(screen.replace("\n", "|")) # drive the OLED
80
+ with self.lock:
81
+ self.outputs.update(produced)
82
+ self.entries.insert(0, {
83
+ "clock": r.clock,
84
+ "context": context,
85
+ "entry": produced.get("entry", ""),
86
+ "screen": screen,
87
+ "trigger": label or ("auto" if auto else "manual"),
88
+ })
89
+ del self.entries[MAX_ENTRIES:]
90
+
91
+ def set_goal(self, goal: str) -> str:
92
+ self.goal = (goal or "").strip()
93
+ return f"goal set: {self.goal or '(none)'}"
94
+
95
+ def connect(self, port: str) -> str:
96
+ try:
97
+ self.source.close()
98
+ except Exception:
99
+ pass
100
+ self.source = open_sensor_source(port or None, mock_mode=self.mock_mode,
101
+ browser=self.browser)
102
+ return self.status_md()
103
+
104
+ def ingest_browser(self, light, sound) -> None:
105
+ """Accept a live room reading pushed from the browser (if supported)."""
106
+ ingest = getattr(self.source, "ingest", None)
107
+ if ingest:
108
+ ingest(light, sound)
109
+
110
+ # --- view helpers ----------------------------------------------------
111
+ def device_state(self) -> str:
112
+ if getattr(self.source, "connected", False) and self.source.kind == "serial":
113
+ return "🟢 arduino connected"
114
+ if self.source.kind == "browser":
115
+ return ("🟢 your room (live)" if self.source.live
116
+ else "🟡 simulated (press *sense my room*)")
117
+ if self.source.kind == "mock":
118
+ return "🟡 mock (no device)"
119
+ return "🔴 offline"
120
+
121
+ def status_md(self) -> str:
122
+ brain = ("🟢 " if self.companion.ready else "🟡 ") + self.companion.backend
123
+ return f"**device** {self.device_state()} · **brain** {brain}"
124
+
125
+ def latest(self):
126
+ with self.lock:
127
+ return self.reading
128
+
129
+ def get(self, kind: str) -> str:
130
+ with self.lock:
131
+ return self.outputs.get(kind, "")
132
+
133
+ def feed(self) -> list[dict]:
134
+ with self.lock:
135
+ return list(self.entries)
136
+
137
+ def stop(self):
138
+ self._stop.set()
companion/sensors.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sensor sources.
2
+
3
+ The Arduino streams one CSV line per second over USB serial:
4
+
5
+ LIGHT:42,SOUND:8,TEMP:21.3\n
6
+
7
+ and accepts a single line back to render on the OLED (``|`` separates rows):
8
+
9
+ Go to sleep,|builder.\n
10
+
11
+ Two interchangeable sources implement the same interface so the Gradio app
12
+ runs identically with or without hardware attached:
13
+
14
+ * ``SerialSensorSource`` — talks to a real Arduino via pyserial.
15
+ * ``MockSensorSource`` — simulates a plausible day/night desk cycle.
16
+
17
+ ``open_sensor_source(port)`` returns a serial source if a port is given and
18
+ can be opened, otherwise it falls back to the mock so the Space always works.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ import time
25
+ from dataclasses import dataclass
26
+ from typing import Optional
27
+
28
+
29
+ @dataclass
30
+ class Reading:
31
+ light: int # 0–100 (%) — ambient brightness
32
+ sound: int # 0–100 (%) — ambient sound level
33
+ temp: float # °C
34
+ ts: float # epoch seconds
35
+
36
+ @property
37
+ def clock(self) -> str:
38
+ return time.strftime("%H:%M", time.localtime(self.ts))
39
+
40
+
41
+ class SensorSource:
42
+ """Abstract sensor source."""
43
+
44
+ kind = "base"
45
+ connected = False
46
+
47
+ def read(self) -> Optional[Reading]:
48
+ raise NotImplementedError
49
+
50
+ def send(self, text: str) -> None:
51
+ """Push a line to the physical screen. No-op for sources without one."""
52
+
53
+ def close(self) -> None:
54
+ pass
55
+
56
+
57
+ class SerialSensorSource(SensorSource):
58
+ kind = "serial"
59
+
60
+ def __init__(self, port: str, baud: int = 9600, timeout: float = 1.0):
61
+ import serial # imported lazily so the package loads without pyserial
62
+
63
+ self.port = port
64
+ self._ser = serial.Serial(port, baud, timeout=timeout)
65
+ time.sleep(2.0) # Arduino resets on connect — give it a moment
66
+ self.connected = True
67
+
68
+ def read(self) -> Optional[Reading]:
69
+ # The Arduino emits 1 line/s but our consumer can stall for seconds
70
+ # during generation — drain the backlog and keep only the newest
71
+ # complete line, or readings lag further behind real time forever.
72
+ try:
73
+ raw = self._ser.readline().decode("utf-8", "ignore").strip()
74
+ while self._ser.in_waiting > 0:
75
+ nxt = self._ser.readline().decode("utf-8", "ignore").strip()
76
+ if nxt:
77
+ raw = nxt
78
+ except Exception:
79
+ self.connected = False
80
+ return None
81
+ if not raw:
82
+ return None
83
+ return _parse_csv(raw)
84
+
85
+ def send(self, text: str) -> None:
86
+ try:
87
+ self._ser.write(f"{text}\n".encode("utf-8"))
88
+ except Exception:
89
+ self.connected = False
90
+
91
+ def close(self) -> None:
92
+ try:
93
+ self._ser.close()
94
+ finally:
95
+ self.connected = False
96
+
97
+
98
+ class MockSensorSource(SensorSource):
99
+ """Simulates sensors so the demo/Space is alive with no hardware.
100
+
101
+ Two modes:
102
+ * ``"day"`` — a compressed day/night cycle (light/sound/temp). Used by
103
+ the Desk Companion. ``speed`` compresses real time: one simulated day
104
+ takes ~``86400/speed`` s (default ~2 minutes).
105
+ * ``"heating"`` — a pot warming from room temperature toward ``target`` °C
106
+ and plateauing. Used by the Kitchen Coach. ``speed`` here is the rate
107
+ constant scaler (higher = heats faster).
108
+ """
109
+
110
+ kind = "mock"
111
+ connected = True
112
+
113
+ def __init__(self, mode: str = "day", speed: float = 720.0,
114
+ target: float = 98.0, start: Optional[float] = None):
115
+ self.mode = mode
116
+ self.speed = speed
117
+ self.target = target
118
+ self._t0 = start if start is not None else time.time()
119
+ # Anchor the simulated clock to local midnight so the displayed time,
120
+ # the light curve and part-of-day all agree (day mode).
121
+ lt = time.localtime(self._t0)
122
+ self._midnight = self._t0 - (lt.tm_hour * 3600 + lt.tm_min * 60 + lt.tm_sec)
123
+ self.last_sent: str = ""
124
+
125
+ def read(self) -> Optional[Reading]:
126
+ now = time.time()
127
+ if self.mode == "heating":
128
+ return self._read_heating(now)
129
+ return self._read_day(now)
130
+
131
+ def _read_day(self, now: float) -> Reading:
132
+ sim_hours = ((now - self._t0) * self.speed / 3600.0) % 24.0
133
+ # Light: bell curve peaking at midday, ~0 at night.
134
+ light = max(0, int(100 * math.sin(max(0.0, (sim_hours - 6) / 12.0) * math.pi)))
135
+ if not 6 <= sim_hours <= 18:
136
+ light = 0
137
+ base = 10 + 30 * (light / 100.0)
138
+ jitter = 8 * math.sin(now * 1.3)
139
+ sound = int(max(0, min(100, base + jitter)))
140
+ temp = round(20.5 + 2.5 * math.sin((sim_hours - 9) / 24.0 * 2 * math.pi), 1)
141
+ sim_ts = self._midnight + sim_hours * 3600.0
142
+ return Reading(light=light, sound=sound, temp=temp, ts=sim_ts)
143
+
144
+ def _read_heating(self, now: float) -> Reading:
145
+ # Exponential approach to target: T = room + (target-room)(1 - e^-k t).
146
+ elapsed = now - self._t0
147
+ k = 0.02 * (self.speed / 720.0)
148
+ room = 20.0
149
+ temp = self.target - (self.target - room) * math.exp(-k * elapsed)
150
+ temp = round(temp + 0.3 * math.sin(now * 2.0), 1) # gentle sensor noise
151
+ # Kitchen lighting steady; "sound" stands in for stove activity.
152
+ return Reading(light=60, sound=25, temp=temp, ts=now)
153
+
154
+ def send(self, text: str) -> None:
155
+ self.last_sent = text
156
+
157
+
158
+ class BrowserSensorSource(SensorSource):
159
+ """Real room readings pushed from the user's browser (webcam luminance +
160
+ mic level, computed entirely client-side — only two numbers reach us).
161
+
162
+ Wraps a fallback source (usually the mock): until the user grants
163
+ camera/mic, or once browser data goes stale (tab closed), readings come
164
+ from the fallback. ``ingest()`` is called from Gradio handler threads.
165
+
166
+ Note: the browser has no thermometer — temp is NaN in live readings and
167
+ consumers must render/describe it conditionally.
168
+ """
169
+
170
+ kind = "browser"
171
+ connected = True
172
+ STALE_S = 10.0
173
+
174
+ def __init__(self, fallback: SensorSource):
175
+ self.fallback = fallback
176
+ self._latest: Optional[Reading] = None
177
+ self.last_sent: str = ""
178
+
179
+ @property
180
+ def live(self) -> bool:
181
+ return self._latest is not None and (time.time() - self._latest.ts) < self.STALE_S
182
+
183
+ def ingest(self, light, sound) -> None:
184
+ try:
185
+ light, sound = int(light), int(sound)
186
+ except (TypeError, ValueError):
187
+ return
188
+ if light < 0: # browser signals "no permission / not started"
189
+ return
190
+ self._latest = Reading(
191
+ light=max(0, min(100, light)),
192
+ sound=max(0, min(100, sound)),
193
+ temp=float("nan"),
194
+ ts=time.time(),
195
+ )
196
+
197
+ def read(self) -> Optional[Reading]:
198
+ if self.live:
199
+ return self._latest
200
+ return self.fallback.read()
201
+
202
+ def send(self, text: str) -> None:
203
+ self.last_sent = text
204
+ self.fallback.send(text)
205
+
206
+
207
+ def _parse_csv(raw: str) -> Optional[Reading]:
208
+ fields = {}
209
+ for token in raw.split(","):
210
+ if ":" not in token:
211
+ continue
212
+ key, _, val = token.partition(":")
213
+ fields[key.strip().upper()] = val.strip()
214
+ try:
215
+ return Reading(
216
+ light=int(float(fields["LIGHT"])),
217
+ sound=int(float(fields.get("SOUND", 0))),
218
+ temp=float(fields.get("TEMP", "nan")),
219
+ ts=time.time(),
220
+ )
221
+ except (KeyError, ValueError):
222
+ return None
223
+
224
+
225
+ def open_sensor_source(port: Optional[str] = None, baud: int = 9600,
226
+ mock_mode: str = "day", target: float = 98.0,
227
+ browser: bool = False) -> SensorSource:
228
+ """Return a serial source if ``port`` opens, else mock (optionally wrapped
229
+ so the browser can push real room readings over it).
230
+
231
+ ``mock_mode`` selects the simulation used when no device is present
232
+ ("day" for the desk, "heating" for the kitchen). ``browser=True`` wraps the
233
+ mock in a BrowserSensorSource: live webcam/mic readings take over whenever
234
+ the user grants permission, mock resumes when they go stale.
235
+ """
236
+ if port:
237
+ try:
238
+ return SerialSensorSource(port, baud)
239
+ except Exception as exc: # noqa: BLE001 — any failure → graceful mock
240
+ print(f"[sensors] could not open {port!r} ({exc}); using mock source")
241
+ mock = MockSensorSource(mode=mock_mode, target=target)
242
+ return BrowserSensorSource(mock) if browser else mock
companion/text.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared text helpers: output sanitisers, gauges, time-of-day."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+
8
+
9
+ def clamp_art(raw: str, cols: int, rows: int) -> str:
10
+ """Strip code fences / non-ASCII and crop to the OLED grid."""
11
+ raw = re.sub(r"```[a-zA-Z]*", "", raw).replace("```", "")
12
+ lines = [ln.rstrip() for ln in raw.splitlines() if ln.strip("` ")]
13
+ lines = [re.sub(r"[^\x20-\x7E]", " ", ln)[:cols] for ln in lines][:rows]
14
+ return "\n".join(lines) if lines else "( ._.)"
15
+
16
+
17
+ def _fit(s: str, n: int) -> str:
18
+ """Trim to <= n chars, preferring a word boundary over a mid-word cut."""
19
+ if len(s) <= n:
20
+ return s
21
+ cut = s[:n]
22
+ sp = cut.rfind(" ")
23
+ return (cut[:sp].rstrip() if sp >= 6 else cut).rstrip()
24
+
25
+
26
+ def clamp_screen(raw: str, max_len: int = 20, max_lines: int = 2) -> str:
27
+ """Coerce to <=max_lines rows of <=max_len plain-ASCII chars, '|' separated.
28
+
29
+ Respects explicit '|'/newline breaks (so 'T:92C|Reduce heat' is preserved),
30
+ and word-wraps a single over-long line into the remaining rows.
31
+ """
32
+ raw = strip_wrapping_quotes(re.sub(r"[^\x20-\x7E|\n]", "", raw).strip())
33
+ parts = [p.strip() for p in re.split(r"[|\n]", raw) if p.strip()]
34
+ rows: list[str] = []
35
+ for part in parts:
36
+ while part and len(rows) < max_lines:
37
+ row = _fit(part, max_len)
38
+ rows.append(row)
39
+ part = part[len(row):].strip()
40
+ if len(rows) >= max_lines:
41
+ break
42
+ return "|".join(rows) if rows else "still here."
43
+
44
+
45
+ def strip_wrapping_quotes(s: str) -> str:
46
+ s = s.strip()
47
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
48
+ return s[1:-1].strip()
49
+ return s
50
+
51
+
52
+ def bar(pct: int, width: int = 20) -> str:
53
+ pct = max(0, min(100, pct))
54
+ filled = round(pct / 100 * width)
55
+ return "█" * filled + "·" * (width - filled)
56
+
57
+
58
+ def part_of_day(hour: int) -> str:
59
+ if 5 <= hour < 12:
60
+ return "morning"
61
+ if 12 <= hour < 17:
62
+ return "afternoon"
63
+ if 17 <= hour < 22:
64
+ return "evening"
65
+ return "late night"
66
+
67
+
68
+ def hour_of(ts: float) -> int:
69
+ return int(time.strftime("%H", time.localtime(ts)))
companion/thoughts.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ThoughtLog — a live trace of the hivemind's machinery.
2
+
3
+ Every step of the pipeline (frame gating, VLM description, codexification,
4
+ the batched voicing call, contagion events) appends an entry here; the
5
+ frontend's HIVEMIND panel polls them incrementally by id. Doubles as an
6
+ honest "this is how it works" exhibit and the raw material for a published
7
+ agent trace (Sharing-is-Caring badge).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+
16
+ KINDS = ("vision", "voice", "event", "system")
17
+
18
+
19
+ class ThoughtLog:
20
+ def __init__(self, maxlen: int = 300):
21
+ self._lock = threading.Lock()
22
+ self._buf: deque[dict] = deque(maxlen=maxlen)
23
+ self._next_id = 1
24
+
25
+ def add(self, kind: str, title: str, body: str = "") -> None:
26
+ if kind not in KINDS:
27
+ kind = "system"
28
+ with self._lock:
29
+ self._buf.append({
30
+ "id": self._next_id,
31
+ "ts": round(time.time(), 2),
32
+ "kind": kind,
33
+ "title": str(title)[:160],
34
+ "body": str(body)[:2000],
35
+ })
36
+ self._next_id += 1
37
+
38
+ def since(self, after_id: int = 0, limit: int = 100) -> list[dict]:
39
+ with self._lock:
40
+ out = [t for t in self._buf if t["id"] > after_id]
41
+ return out[-limit:]
companion/ui.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared look & feel for both products.
2
+
3
+ Lesson learned: don't fight Gradio's theme with a hand-rolled palette — it
4
+ renders dark-on-dark and becomes unreadable. Instead we use Gradio's own
5
+ dark mode (forced via FORCE_DARK_JS) + a built-in readable theme, and keep
6
+ custom CSS strictly for the device accents (the OLED panel and its screen).
7
+ """
8
+
9
+ import gradio as gr
10
+
11
+ # Reload once with Gradio's dark theme so every built-in component uses its
12
+ # well-tested dark palette (readable text, proper inputs/buttons).
13
+ FORCE_DARK_JS = """
14
+ () => {
15
+ if (!document.location.search.includes('__theme=dark')) {
16
+ const u = new URL(document.location.href);
17
+ u.searchParams.set('__theme', 'dark');
18
+ document.location.replace(u.toString());
19
+ }
20
+ }
21
+ """
22
+
23
+ # Accents only: the virtual device glows green; everything else stays theme-default.
24
+ CSS = """
25
+ #oled {background:#0a1a0a; border:2px solid #2e5a2e; border-radius:8px; padding:14px;
26
+ color:#7CFC00 !important; white-space:pre; font-family:ui-monospace,monospace;
27
+ line-height:1.15; font-size:15px; box-shadow:0 0 28px #10381088 inset; min-height:130px;}
28
+ #oled * {color:#7CFC00 !important;}
29
+ #screen {background:#000; border:2px solid #234; border-radius:6px; padding:12px;
30
+ color:#aaeeff !important; white-space:pre; font-family:ui-monospace,monospace;
31
+ text-align:center; font-size:21px;}
32
+ #screen * {color:#aaeeff !important;}
33
+ #hero {border-left:4px solid #4a9; padding-left:12px;}
34
+ """
35
+
36
+
37
+ def theme():
38
+ return gr.themes.Monochrome()
companion/vlm.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The aliens' translator: a small local VLM that watches the Earth Feed.
2
+
3
+ SceneDescriber tries backends in order until one loads:
4
+ 1. llama-cpp-python multimodal (moondream2 by default — lighter, ~1-3 s per
5
+ caption; MiniCPM-V 2.6 or anything else via env). Activates only when the
6
+ weights are already in the HF cache, pointed at by env, or when
7
+ VLM_AUTOFETCH=1 — a bare import/instantiation never downloads gigabytes.
8
+ 2. transformers CPU fallback (SmolVLM-500M-Instruct) — import-guarded; used
9
+ only if torch+transformers happen to be installed in this env.
10
+ 3. StubDescriber — rotating plausible desk scenes, so the whole theater
11
+ demos end-to-end with no model at all.
12
+
13
+ Also home of the CODEX OF EARTH ARTIFACTS (build spec §2): `codexify()` swaps
14
+ human nouns for Blorp mythology and emits the fixed 9-tag vector. The server
15
+ feeds `SceneDescriber.describe(...)` output through `codexify()` (or just
16
+ calls `describe_codex(...)`) before anyone else sees the scene string.
17
+
18
+ Env overrides:
19
+ VLM_REPO HF repo id of a GGUF multimodal model (e.g. openbmb/MiniCPM-V-2_6-gguf)
20
+ VLM_MODEL text-model GGUF filename in that repo, or an absolute local path
21
+ VLM_MMPROJ mmproj GGUF filename in that repo, or an absolute local path
22
+ VLM_AUTOFETCH "1" to allow downloading the default repo on first run
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import binascii
29
+ import io
30
+ import os
31
+ import re
32
+ import threading
33
+ import time
34
+
35
+ from PIL import Image
36
+
37
+ # --------------------------------------------------------------------------
38
+ # §2 CODEX OF EARTH ARTIFACTS — one shared wrong mythology, so gags compound.
39
+ # Order matters: longer/more specific phrases substitute first. Each noun
40
+ # alternation is wrapped with an optional leading article at compile time so
41
+ # "a mug" -> "the Sacred Steam Chalice" (no "a the" stutter).
42
+ # Each entry: (noun alternation regex body, codex name, tag-or-None)
43
+ CODEX: list[tuple[str, str, str | None]] = [
44
+ (r"coffee|tea|mugs?|cups?", "the Sacred Steam Chalice", "drink"),
45
+ (r"smartphones?|phones?", "the Smaller Television", "tech"),
46
+ (r"keyboards?|laptops?", "the Clatter Altar", "tech"),
47
+ (r"(?:eye)?glasses", "eye armor", "tech"),
48
+ (r"headphones?", "ear muzzles", "tech"),
49
+ (r"water bottles?|bottles?|water", "personal ocean", "drink"),
50
+ (r"cats?", "the Liquid Overlord", "animal"),
51
+ (r"dogs?", "the Bark Unit", "animal"),
52
+ (r"food|snacks?|sandwich(?:es)?|fruits?", "fuel pellets", "food"),
53
+ (r"chairs?", "the Throne of Sitting", None),
54
+ (r"persons?|people|man|men|woman|women|faces?|humans?", "the Protagonist", "person"),
55
+ ]
56
+
57
+ _CODEX_COMPILED = [
58
+ (re.compile(r"\b(?:(?:a|an|the)\s+)?(?:%s)\b" % body, re.IGNORECASE), name, tag)
59
+ for body, name, tag in CODEX
60
+ ]
61
+
62
+ # Fixed 9-vector tag order (build spec §2).
63
+ TAG_ORDER = ("food", "drink", "person", "tech", "animal", "motion", "dark", "absence", "novelty")
64
+
65
+ _MOTION_RE = re.compile(
66
+ r"\b(?:reach(?:es|ing)?|stand(?:s|ing)?|wav(?:es?|ing)|mov(?:es?|ing)|gestur(?:es?|ing))\b",
67
+ re.IGNORECASE)
68
+ _DARK_RE = re.compile(r"\b(?:dim(?:ly)?|dark(?:ness|ened)?|shadows?)\b", re.IGNORECASE)
69
+ _PERSON_RE = re.compile(
70
+ r"\b(?:persons?|people|man|men|woman|women|faces?|humans?|protagonist|he|she|someone)\b",
71
+ re.IGNORECASE)
72
+ _EMPTY_RE = re.compile(r"\b(?:empty|nobody|no one|vacant|unoccupied|absent)\b", re.IGNORECASE)
73
+
74
+ # Verbs only TAG — never substituted (substituting them garbles the sentence:
75
+ # "sits" must not become "the Throne of Sitting").
76
+ _VERB_TAGS = [
77
+ (re.compile(r"\b(?:typing|types?)\b", re.IGNORECASE), "tech"),
78
+ (re.compile(r"\b(?:eating|eats?|drinking|drinks?)\b", re.IGNORECASE), "food"),
79
+ ]
80
+
81
+
82
+ def codexify(description: str, luminance: float | None = None) -> tuple[str, set[str]]:
83
+ """Substitute Codex names into a plain VLM description; emit the tag set.
84
+
85
+ Returns (codex_scene <=120 chars, tags ⊆ TAG_ORDER minus 'novelty' —
86
+ novelty is the server's call, spec §5).
87
+ """
88
+ tags: set[str] = set()
89
+ scene = description.strip()
90
+ if _MOTION_RE.search(scene):
91
+ tags.add("motion")
92
+ if _DARK_RE.search(scene) or (luminance is not None and luminance < 40):
93
+ tags.add("dark")
94
+ if not _PERSON_RE.search(scene) or _EMPTY_RE.search(scene):
95
+ tags.add("absence")
96
+ for pattern, tag in _VERB_TAGS:
97
+ if pattern.search(scene):
98
+ tags.add(tag)
99
+ for pattern, name, tag in _CODEX_COMPILED:
100
+ scene, n = pattern.subn(name, scene)
101
+ if n and tag:
102
+ tags.add(tag)
103
+ # Collapse article stutters from substitutions: "a the X" -> "the X",
104
+ # "An empty the Throne" -> "the empty Throne".
105
+ scene = re.sub(r"\b(?:a|an|the)\s+((?:\w+\s+)?)the\b", r"the \1",
106
+ scene, flags=re.IGNORECASE)
107
+ scene = re.sub(r"\s+", " ", scene).strip()
108
+ if len(scene) > 120:
109
+ scene = scene[:117].rstrip() + "..."
110
+ return scene, tags
111
+
112
+
113
+ def tags_vector(tags: set[str]) -> list[int]:
114
+ """The fixed 9-vector (0/1) in TAG_ORDER, for the contagion dot product."""
115
+ return [1 if t in tags else 0 for t in TAG_ORDER]
116
+
117
+
118
+ # --------------------------------------------------------------------------
119
+ # Backend plumbing
120
+ # --------------------------------------------------------------------------
121
+ PROMPT = ("Describe what you see plainly: subjects, objects, action, lighting; "
122
+ "no speculation; 60 words or fewer.")
123
+ MIN_INTERVAL = 4.0 # seconds between real inferences; throttled calls get the cache
124
+ MAX_EDGE = 640 # downscale frames before inference
125
+
126
+ _HF_CACHE = os.path.expanduser(
127
+ os.path.join(os.environ.get("HF_HOME", "~/.cache/huggingface"), "hub"))
128
+
129
+ # (repo, text gguf, mmproj gguf, handler class name) — moondream first: Build Small.
130
+ _GGUF_CANDIDATES = [
131
+ ("ggml-org/moondream2-20250414-GGUF",
132
+ "moondream2-text-model-f16_ct-vicuna.gguf",
133
+ "moondream2-mmproj-f16-20250414.gguf",
134
+ "MoondreamChatHandler"),
135
+ ("openbmb/MiniCPM-V-2_6-gguf",
136
+ "ggml-model-Q4_K_M.gguf",
137
+ "mmproj-model-f16.gguf",
138
+ "MiniCPMv26ChatHandler"),
139
+ ]
140
+
141
+ _STUB_SCENES = [
142
+ "A person sits at a desk in front of a glowing rectangle, typing on a keyboard. A mug rests nearby. Soft lamp light.",
143
+ "A person lifts a mug and drinks, eyes on the glowing screen. Warm desk lighting, papers scattered to one side.",
144
+ "A person leans back in a chair, stretching both arms, then reaches for a phone. Even indoor lighting.",
145
+ "A person eats a snack while looking at the laptop. A water bottle stands beside the keyboard. Bright daylight.",
146
+ "An empty chair in front of a desk. The screen still glows. Nobody is in the frame. Dim ambient light.",
147
+ "A person waves a hand near the camera, adjusting glasses, then resumes typing on the keyboard. Cool screen glow.",
148
+ ]
149
+ _DARK_STUB = "A dark room. Faint shadows around a desk; the screen glow is the only light. Nothing stirs."
150
+
151
+
152
+ def _decode_dataurl(b64_jpeg_dataurl: str) -> bytes:
153
+ """data:image/jpeg;base64,... (or bare base64) -> raw JPEG bytes."""
154
+ payload = b64_jpeg_dataurl.split(",", 1)[1] if "," in b64_jpeg_dataurl else b64_jpeg_dataurl
155
+ return base64.b64decode(payload)
156
+
157
+
158
+ def _downscale(jpeg: bytes, max_edge: int = MAX_EDGE) -> tuple[Image.Image, float]:
159
+ """Decode + shrink to <=max_edge px; return (RGB image, mean luminance 0-255)."""
160
+ img = Image.open(io.BytesIO(jpeg)).convert("RGB")
161
+ if max(img.size) > max_edge:
162
+ img.thumbnail((max_edge, max_edge), Image.LANCZOS)
163
+ gray = img.convert("L").resize((32, 24))
164
+ data = gray.tobytes()
165
+ luminance = sum(data) / len(data)
166
+ return img, luminance
167
+
168
+
169
+ def _handler_for(repo: str):
170
+ """Pick the llama_cpp chat handler class for a repo id."""
171
+ from llama_cpp import llama_chat_format as fmt # noqa: PLC0415
172
+ name = repo.lower()
173
+ if "moondream" in name:
174
+ return fmt.MoondreamChatHandler
175
+ if "minicpm" in name:
176
+ return fmt.MiniCPMv26ChatHandler
177
+ if "qwen" in name:
178
+ return fmt.Qwen25VLChatHandler
179
+ return fmt.MiniCPMv26ChatHandler
180
+
181
+
182
+ def _cached(repo: str) -> bool:
183
+ return os.path.isdir(os.path.join(_HF_CACHE, "models--" + repo.replace("/", "--")))
184
+
185
+
186
+ class StubDescriber:
187
+ """Rotating plausible scenes so everything demos model-less."""
188
+
189
+ def __init__(self):
190
+ self._i = 0
191
+
192
+ def describe(self, img: Image.Image, luminance: float) -> str:
193
+ if luminance < 30:
194
+ return _DARK_STUB
195
+ scene = _STUB_SCENES[self._i % len(_STUB_SCENES)]
196
+ self._i += 1
197
+ return scene
198
+
199
+
200
+ class _LlamaCppVLM:
201
+ """llama-cpp-python multimodal backend (moondream2 / MiniCPM-V / custom)."""
202
+
203
+ def __init__(self):
204
+ from llama_cpp import Llama # noqa: PLC0415
205
+
206
+ repo = os.environ.get("VLM_REPO")
207
+ model = os.environ.get("VLM_MODEL")
208
+ mmproj = os.environ.get("VLM_MMPROJ")
209
+ autofetch = os.environ.get("VLM_AUTOFETCH") == "1"
210
+
211
+ if model and mmproj and os.path.isfile(model) and os.path.isfile(mmproj):
212
+ # Fully local file paths.
213
+ handler_cls = _handler_for(repo or model)
214
+ handler = handler_cls(clip_model_path=mmproj, verbose=False)
215
+ self.llm = Llama(model_path=model, chat_handler=handler,
216
+ n_ctx=4096, n_gpu_layers=-1, verbose=False)
217
+ self.name = os.path.basename(model)
218
+ return
219
+
220
+ if repo: # explicit repo: user opted in, downloading is fine
221
+ candidates = [(repo,
222
+ model or _default_model_for(repo),
223
+ mmproj or _default_mmproj_for(repo))]
224
+ else: # default repos: only if already cached (or autofetch)
225
+ candidates = [(r, m, p) for r, m, p, _ in _GGUF_CANDIDATES
226
+ if autofetch or _cached(r)]
227
+ if not candidates:
228
+ raise RuntimeError("no VLM weights cached and VLM_REPO/VLM_AUTOFETCH unset")
229
+
230
+ last_exc: Exception | None = None
231
+ for r, m, p in candidates:
232
+ try:
233
+ handler_cls = _handler_for(r)
234
+ handler = handler_cls.from_pretrained(repo_id=r, filename=p, verbose=False)
235
+ self.llm = Llama.from_pretrained(
236
+ repo_id=r, filename=m, chat_handler=handler,
237
+ n_ctx=4096, n_gpu_layers=-1, verbose=False)
238
+ self.name = f"{r.split('/')[-1]} · {m}"
239
+ return
240
+ except Exception as exc: # noqa: BLE001
241
+ last_exc = exc
242
+ raise RuntimeError(f"no llama.cpp VLM loaded: {last_exc}")
243
+
244
+ def describe(self, img: Image.Image, luminance: float) -> str:
245
+ buf = io.BytesIO()
246
+ img.save(buf, format="JPEG", quality=80)
247
+ uri = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
248
+ out = self.llm.create_chat_completion(
249
+ messages=[{
250
+ "role": "user",
251
+ "content": [
252
+ {"type": "image_url", "image_url": {"url": uri}},
253
+ {"type": "text", "text": PROMPT},
254
+ ],
255
+ }],
256
+ max_tokens=96,
257
+ temperature=0.2,
258
+ )
259
+ return out["choices"][0]["message"]["content"].strip()
260
+
261
+
262
+ def _default_model_for(repo: str) -> str:
263
+ for r, m, _p, _h in _GGUF_CANDIDATES:
264
+ if r == repo:
265
+ return m
266
+ raise RuntimeError(f"VLM_MODEL must be set for custom repo {repo}")
267
+
268
+
269
+ def _default_mmproj_for(repo: str) -> str:
270
+ for r, _m, p, _h in _GGUF_CANDIDATES:
271
+ if r == repo:
272
+ return p
273
+ return "*mmproj*"
274
+
275
+
276
+ class _TransformersVLM:
277
+ """SmolVLM-500M-Instruct CPU fallback. Only built when its deps import."""
278
+
279
+ MODEL_ID = os.environ.get("VLM_HF_MODEL", "HuggingFaceTB/SmolVLM-500M-Instruct")
280
+
281
+ def __init__(self):
282
+ import torch # noqa: PLC0415
283
+ from transformers import AutoModelForVision2Seq, AutoProcessor # noqa: PLC0415
284
+
285
+ torch.set_num_threads(max(1, min(4, os.cpu_count() or 2)))
286
+ # longest_edge=512 -> single 64-token tile; default 2048 is ~10x slower on CPU.
287
+ self.processor = AutoProcessor.from_pretrained(
288
+ self.MODEL_ID, size={"longest_edge": 512})
289
+ self.model = AutoModelForVision2Seq.from_pretrained(
290
+ self.MODEL_ID,
291
+ torch_dtype=torch.float32,
292
+ _attn_implementation="eager",
293
+ )
294
+ self.model.eval()
295
+ self._torch = torch
296
+ self.name = self.MODEL_ID.split("/")[-1]
297
+
298
+ def describe(self, img: Image.Image, luminance: float) -> str:
299
+ messages = [{
300
+ "role": "user",
301
+ "content": [{"type": "image"}, {"type": "text", "text": PROMPT}],
302
+ }]
303
+ prompt = self.processor.apply_chat_template(messages, add_generation_prompt=True)
304
+ inputs = self.processor(text=prompt, images=[img], return_tensors="pt")
305
+ with self._torch.inference_mode():
306
+ ids = self.model.generate(**inputs, max_new_tokens=96, do_sample=False)
307
+ new = ids[:, inputs["input_ids"].shape[1]:]
308
+ return self.processor.batch_decode(new, skip_special_tokens=True)[0].strip()
309
+
310
+
311
+ # --------------------------------------------------------------------------
312
+ # Public class
313
+ # --------------------------------------------------------------------------
314
+ class SceneDescriber:
315
+ """The translator. describe() never raises and never blocks the caller on
316
+ throttle — it returns the cached description when called inside the 4 s
317
+ minimum interval. Pass the server's single global Companion lock as `lock`
318
+ if the VLM shares llama.cpp with the text model (it is NOT thread-safe)."""
319
+
320
+ def __init__(self, lock: threading.Lock | None = None):
321
+ self.lock = lock or threading.Lock()
322
+ self._throttle_lock = threading.Lock() # guards cache/timestamps only
323
+ self._last_run = 0.0
324
+ self._cached_description = ""
325
+ self.last_luminance: float | None = None
326
+ self._impl = None
327
+ self.backend = "stub"
328
+
329
+ try:
330
+ self._impl = _LlamaCppVLM()
331
+ self.backend = f"llama.cpp · {self._impl.name}"
332
+ except Exception as exc: # noqa: BLE001
333
+ print(f"[vlm] llama.cpp multimodal unavailable ({exc})")
334
+ try:
335
+ import torch # noqa: F401, PLC0415
336
+ import transformers # noqa: F401, PLC0415
337
+ except ImportError:
338
+ pass
339
+ else:
340
+ try:
341
+ self._impl = _TransformersVLM()
342
+ self.backend = f"transformers · {self._impl.name}"
343
+ except Exception as exc2: # noqa: BLE001
344
+ print(f"[vlm] transformers fallback failed ({exc2})")
345
+ if self._impl is None:
346
+ self._impl = StubDescriber()
347
+ self.backend = "stub (rotating canned scenes)"
348
+ print("[vlm] using stub scenes — demo runs end-to-end model-less")
349
+
350
+ @property
351
+ def ready(self) -> bool:
352
+ """True when a real model is loaded (stub still works, just canned)."""
353
+ return not isinstance(self._impl, StubDescriber)
354
+
355
+ def describe(self, b64_jpeg_dataurl: str) -> str:
356
+ """One-paragraph plain-English scene description for the frame.
357
+ Throttled to one real inference per 4 s; otherwise returns the cache."""
358
+ now = time.monotonic()
359
+ with self._throttle_lock:
360
+ if now - self._last_run < MIN_INTERVAL and self._cached_description:
361
+ return self._cached_description
362
+ self._last_run = now # claim the slot before the slow part
363
+
364
+ try:
365
+ img, luminance = _downscale(_decode_dataurl(b64_jpeg_dataurl))
366
+ except (binascii.Error, ValueError, OSError) as exc:
367
+ print(f"[vlm] bad frame ({exc})")
368
+ return self._cached_description
369
+
370
+ try:
371
+ with self.lock:
372
+ description = self._impl.describe(img, luminance)
373
+ except Exception as exc: # noqa: BLE001 — never let a flaky model kill the loop
374
+ print(f"[vlm] inference failed ({exc})")
375
+ description = StubDescriber().describe(img, luminance)
376
+
377
+ with self._throttle_lock:
378
+ self.last_luminance = luminance
379
+ if description:
380
+ self._cached_description = description
381
+ return self._cached_description
382
+
383
+ def describe_codex(self, b64_jpeg_dataurl: str) -> tuple[str, set[str]]:
384
+ """Spec §5 shape: (Codex-substituted scene <=120 chars, tag set)."""
385
+ return codexify(self.describe(b64_jpeg_dataurl), self.last_luminance)
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44
2
+ fastapi
3
+ uvicorn
4
+ pydantic>=2
5
+ pillow
6
+ huggingface_hub>=0.25
7
+ pyserial>=3.5
8
+
9
+ # llama-cpp-python isn't on PyPI as a wheel — pull from the project's index.
10
+ # This CPU index has Linux wheels (works on the HF Space). For a fast LOCAL demo
11
+ # on Apple Silicon, install the Metal build instead (see README):
12
+ # pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal
13
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
14
+ llama-cpp-python==0.3.28
run.sh ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Launch The Committee (Mochi & Loaf) cleanly: kill any stale server on the
3
+ # port first (the #1 "it's broken" cause), set the model choice, then run.
4
+ #
5
+ # ./run.sh # all-OpenBMB: MiniCPM-V eyes + MiniCPM3-4B brain
6
+ # FAST=1 ./run.sh # moondream2 eyes + Llama-3.2-3B brain (snappier)
7
+ set -euo pipefail
8
+ cd "$(dirname "$0")"
9
+
10
+ PORT="${PORT:-7860}"
11
+
12
+ # 1) free the port — kill whatever is listening (old desk app, prior runs).
13
+ if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
14
+ echo "→ freeing port $PORT (killing stale listeners)…"
15
+ lsof -nP -iTCP:"$PORT" -sTCP:LISTEN | awk 'NR>1{print $2}' | sort -u | xargs -r kill
16
+ sleep 1
17
+ fi
18
+
19
+ # 2) default: the all-OpenBMB stack (eyes + brain) for the $10k pool.
20
+ # FAST=1 swaps to the snappier moondream eyes + Llama brain.
21
+ if [ "${FAST:-0}" = "1" ]; then
22
+ echo "→ eyes: moondream2 · brain: Llama-3.2-3B (fast)"
23
+ unset VLM_REPO VLM_MODEL VLM_MMPROJ COMPANION_MODEL_REPO COMPANION_MODEL_FILE || true
24
+ else
25
+ echo "→ eyes: MiniCPM-V 2.6 · brain: MiniCPM3-4B (all OpenBMB) — first look is slow while it warms"
26
+ export VLM_REPO="openbmb/MiniCPM-V-2_6-gguf"
27
+ export VLM_MODEL="ggml-model-Q4_K_M.gguf"
28
+ export VLM_MMPROJ="mmproj-model-f16.gguf"
29
+ export COMPANION_MODEL_REPO="openbmb/MiniCPM3-4B-GGUF"
30
+ export COMPANION_MODEL_FILE="minicpm3-4b-q4_k_m.gguf"
31
+ fi
32
+
33
+ export PORT
34
+ exec .venv/bin/python theater_server.py
server.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """server.py — Living Pixel Pet ("Pip the Hearthwick") server.
2
+
3
+ One FastAPI app:
4
+ GET / -> web/index.html (the three.js world)
5
+ /web/* -> static frontend files
6
+ GET /api/state -> {light, sound, live, mood, screen, thinking, diary}
7
+ POST /api/sense -> {light, sound} (browser pushes real readings ~1Hz; -1 = not live)
8
+ POST /api/poke -> {action: "write"} (force a diary entry + reaction now)
9
+ /app -> Gradio control panel (status + diary feed + write-now button)
10
+
11
+ Run locally: python server.py (or: uvicorn server:app --port 7860)
12
+ HF Spaces: sdk: docker, app_port: 7860, CMD ["python", "server.py"]
13
+ Verified against gradio 6.17.3. Keep ONE uvicorn worker (in-process Gradio queue).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+
23
+ import gradio as gr
24
+ import uvicorn
25
+ from fastapi import FastAPI
26
+ from fastapi.responses import FileResponse, HTMLResponse
27
+ from fastapi.staticfiles import StaticFiles
28
+ from pydantic import BaseModel
29
+
30
+ from companion.runner import CompanionRunner
31
+
32
+ ROOT = Path(__file__).parent
33
+ WEB = ROOT / "web" # three.js bundle (never mount at /assets — gradio-internal name)
34
+
35
+ MOODS = {"sleepy", "cozy", "alert", "annoyed", "happy", "idle"}
36
+ DIARY_CAP = 12
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Persona + runner
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _load_persona():
44
+ """Prefer the real pet persona (B3); fall back so the server always boots."""
45
+ try:
46
+ from companion.pet_persona import PetPersona # documented name
47
+ return PetPersona()
48
+ except Exception:
49
+ pass
50
+ try:
51
+ from companion.pet_persona import PipPersona # spec name
52
+ return PipPersona()
53
+ except Exception:
54
+ pass
55
+
56
+ # Last resort: a thin shim over the proven DeskPersona so /api/state,
57
+ # diary generation and the Gradio panel all still work end-to-end.
58
+ from companion.personas import DeskPersona
59
+
60
+ class _FallbackPetPersona(DeskPersona):
61
+ key = "pip-fallback"
62
+ title = "pip (fallback)"
63
+ produces = ("entry", "screen")
64
+ cooldown_s = 45.0
65
+
66
+ def mood(self, r) -> str:
67
+ if r is None:
68
+ return "idle"
69
+ if r.sound > 70:
70
+ return "annoyed"
71
+ if r.light < 20 and r.sound < 25:
72
+ return "sleepy"
73
+ if 20 <= r.light <= 60 and r.sound < 30:
74
+ return "cozy"
75
+ if r.light > 60 and 30 <= r.sound <= 60:
76
+ return "happy"
77
+ return "idle"
78
+
79
+ return _FallbackPetPersona()
80
+
81
+
82
+ persona = _load_persona()
83
+ runner = CompanionRunner(persona, mock_mode="day", browser=True)
84
+
85
+
86
+ def _clamp(v) -> int:
87
+ try:
88
+ return max(0, min(100, int(v)))
89
+ except (TypeError, ValueError):
90
+ return 0
91
+
92
+
93
+ def _current_mood() -> str:
94
+ """Single source of truth is persona.engine.mood (B3's MoodEngine);
95
+ fall back to a persona.mood(reading) method, then to "idle"."""
96
+ mood = None
97
+ engine = getattr(persona, "engine", None)
98
+ if engine is not None:
99
+ mood = getattr(engine, "mood", None)
100
+ if mood not in MOODS:
101
+ mood_fn = getattr(persona, "mood", None)
102
+ if callable(mood_fn):
103
+ try:
104
+ mood = mood_fn(runner.latest())
105
+ except Exception:
106
+ mood = None
107
+ return mood if mood in MOODS else "idle"
108
+
109
+
110
+ def _diary() -> list[dict]:
111
+ """Newest-first diary, capped. Trigger tags ("dark"/"noise"/"return"/
112
+ "poke"/...) are stamped at the source by runner.generate(label=...)."""
113
+ items = runner.feed()
114
+ return [
115
+ {
116
+ "clock": str(item.get("clock", "")),
117
+ "entry": str(item.get("entry", "")),
118
+ "trigger": str(item.get("trigger", "auto")),
119
+ }
120
+ for item in items[:DIARY_CAP]
121
+ ]
122
+
123
+
124
+ def _poke_now() -> None:
125
+ """Force a diary entry + reaction: nudge the mood engine, then generate.
126
+ The "poke" tag is stamped at the source; `thinking` is derived from the
127
+ runner's generation lock, so there's nothing to toggle here."""
128
+ engine = getattr(persona, "engine", None)
129
+ if engine is not None and hasattr(engine, "poke"):
130
+ try:
131
+ engine.poke(time.time())
132
+ except Exception:
133
+ pass
134
+ runner.generate(auto=False, label="poke")
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # 1. Parent FastAPI app (custom routes registered BEFORE the gradio mount)
139
+ # ---------------------------------------------------------------------------
140
+
141
+ app = FastAPI(docs_url=None, redoc_url=None)
142
+
143
+
144
+ class SenseBody(BaseModel):
145
+ light: float = -1
146
+ sound: float = -1
147
+
148
+
149
+ class PokeBody(BaseModel):
150
+ action: str = "write"
151
+
152
+
153
+ @app.get("/api/state")
154
+ def api_state() -> dict:
155
+ r = runner.latest()
156
+ return {
157
+ "light": _clamp(r.light) if r is not None else 0,
158
+ "sound": _clamp(r.sound) if r is not None else 0,
159
+ "live": bool(getattr(runner.source, "live", False)),
160
+ "mood": _current_mood(),
161
+ "screen": runner.get("screen"),
162
+ # True whenever ANY generation (auto, poke, panel) is in flight.
163
+ "thinking": runner.gen_lock.locked(),
164
+ "diary": _diary(),
165
+ }
166
+
167
+
168
+ @app.post("/api/sense")
169
+ def api_sense(body: SenseBody) -> dict:
170
+ # -1 means "not live"; BrowserSensorSource.ingest ignores negatives and
171
+ # the runner keeps reading the mock, so we just pass everything through.
172
+ runner.ingest_browser(int(body.light), int(body.sound))
173
+ return {"ok": True, "live": bool(getattr(runner.source, "live", False))}
174
+
175
+
176
+ @app.post("/api/poke")
177
+ def api_poke(body: PokeBody) -> dict:
178
+ # Fire-and-forget: the browser polls /api/state and watches `thinking`.
179
+ threading.Thread(target=_poke_now, daemon=True).start()
180
+ return {"ok": True, "action": body.action}
181
+
182
+
183
+ @app.get("/api/health")
184
+ def api_health() -> dict:
185
+ return {"ok": True}
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # 2. three.js frontend at / and /web/* (defensive: boots even if web/ absent)
190
+ # ---------------------------------------------------------------------------
191
+
192
+ # check_dir=False -> clean 404s instead of a crash when web/ isn't built yet.
193
+ app.mount("/web", StaticFiles(directory=str(WEB), check_dir=False), name="web")
194
+
195
+ _PLACEHOLDER = """<!doctype html>
196
+ <html><head><meta charset="utf-8"><title>pip the hearthwick</title>
197
+ <style>body{background:#1A1424;color:#F2E3C6;font-family:monospace;
198
+ display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
199
+ a{color:#FFD98A}</style></head>
200
+ <body><div><h1>pip is still moving in.</h1>
201
+ <p>web/index.html not found — the three.js room hasn't been built yet.</p>
202
+ <p>meanwhile: <a href="/app">control panel</a> · <a href="/api/state">/api/state</a></p>
203
+ </div></body></html>"""
204
+
205
+
206
+ @app.get("/")
207
+ def index():
208
+ page = WEB / "index.html"
209
+ if page.is_file():
210
+ return FileResponse(page)
211
+ return HTMLResponse(_PLACEHOLDER, status_code=200)
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # 3. Gradio control panel (the hackathon-required functional Gradio app)
216
+ # ---------------------------------------------------------------------------
217
+
218
+ def _panel_status() -> str:
219
+ r = runner.latest()
220
+ sensors = (f"**light** {_clamp(r.light)}% · **sound** {_clamp(r.sound)}%"
221
+ if r is not None else "warming up…")
222
+ return (f"{runner.status_md()} · **mood** `{_current_mood()}` · {sensors}"
223
+ f"{' · ✍️ scribbling…' if runner.gen_lock.locked() else ''}")
224
+
225
+
226
+ def _panel_feed() -> list[list[str]]:
227
+ return [[d["clock"], d["trigger"], d["entry"]] for d in _diary()]
228
+
229
+
230
+ def _panel_refresh():
231
+ return _panel_status(), _panel_feed()
232
+
233
+
234
+ def _panel_poke():
235
+ _poke_now() # synchronous in the Gradio handler thread — fine
236
+ return _panel_refresh()
237
+
238
+
239
+ with gr.Blocks(title="pip — control panel") as demo:
240
+ gr.Markdown("# 🕯️ pip the hearthwick — control panel\n"
241
+ "the room lives at [/](/) · this panel watches the engine.")
242
+ status_md = gr.Markdown(_panel_status())
243
+ feed_df = gr.Dataframe(
244
+ headers=["clock", "trigger", "entry"],
245
+ value=_panel_feed(),
246
+ label="diary feed (newest first)",
247
+ interactive=False,
248
+ )
249
+ poke_btn = gr.Button("write now (poke pip)", variant="primary")
250
+ poke_btn.click(_panel_poke, None, [status_md, feed_df], api_name="poke")
251
+ gr.Timer(2.0).tick(_panel_refresh, None, [status_md, feed_df])
252
+
253
+ demo.queue(max_size=32)
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # 4. Mount Gradio LAST. Never call demo.launch().
257
+ # ---------------------------------------------------------------------------
258
+
259
+ app = gr.mount_gradio_app(
260
+ app,
261
+ demo,
262
+ path="/app",
263
+ ssr_mode=False, # no Node in the container
264
+ footer_links=[], # hide gradio footer
265
+ # root_path stays unset: mount path is auto-detected; HF Spaces edge is
266
+ # handled by uvicorn proxy_headers below.
267
+ )
268
+
269
+
270
+ if __name__ == "__main__":
271
+ uvicorn.run(
272
+ app,
273
+ host="0.0.0.0",
274
+ port=int(os.environ.get("PORT", "7860")),
275
+ proxy_headers=True, # honour X-Forwarded-Proto on HF Spaces
276
+ forwarded_allow_ips="*",
277
+ # workers stays 1 (default) — the gradio queue is in-process.
278
+ )
theater/assets/aliens/anthropologist.png ADDED
theater/assets/aliens/art_critic.png ADDED
theater/assets/aliens/conspiracist.png ADDED
theater/assets/aliens/devout.png ADDED
theater/assets/aliens/doom_prophet.png ADDED
theater/assets/aliens/elder.png ADDED
theater/assets/aliens/foodie.png ADDED
theater/assets/aliens/heckler.png ADDED
theater/assets/aliens/merch_vendor.png ADDED
theater/assets/aliens/romantic.png ADDED
theater/assets/aliens/sportscaster.png ADDED
theater/assets/aliens/tourist.png ADDED
theater/assets/props/confetti.png ADDED
theater/assets/props/pulse_ring.png ADDED
theater/assets/props/static.png ADDED
theater/assets/props/tile_glow.png ADDED
theater/assets/props/torch.png ADDED
theater/duo.html ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>The Committee — Mochi & Loaf</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0c0a1e; --bg2:#161033; --ink:#f3eeff; --dim:#a99fd6;
10
+ --mochi:#f6a64b; --loaf:#c9cbd6; --gold:#ffd35a; --line:#332a5e;
11
+ }
12
+ *{box-sizing:border-box}
13
+ html,body{margin:0;height:100%;background:radial-gradient(120% 90% at 50% 0%,var(--bg2),var(--bg));
14
+ color:var(--ink);font:15px/1.5 "Courier New",ui-monospace,monospace;overflow:hidden}
15
+ #wrap{position:fixed;inset:0;display:flex;flex-direction:column}
16
+
17
+ header{padding:14px 18px 6px;text-align:center}
18
+ header h1{margin:0;font-size:20px;letter-spacing:3px;color:var(--gold)}
19
+ header p{margin:4px 0 0;color:var(--dim);font-size:12px}
20
+
21
+ /* viewport: what they see (your webcam) */
22
+ #viewport{position:absolute;top:14px;right:16px;width:148px;z-index:5;
23
+ border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#000;
24
+ box-shadow:0 0 24px #0008}
25
+ #viewport video{display:block;width:100%;transform:scaleX(-1);filter:saturate(.7) contrast(1.05)}
26
+ #viewport .vp-label{font-size:9px;letter-spacing:1px;color:var(--dim);padding:3px 6px;
27
+ background:#0009;border-top:1px solid var(--line)}
28
+ #vp-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#555;margin-right:4px;
29
+ vertical-align:middle}
30
+ #vp-dot.live{background:#ff5a7a;box-shadow:0 0 8px #ff5a7a}
31
+
32
+ /* stage with the two aliens */
33
+ #stage{flex:1;display:flex;align-items:center;justify-content:center;gap:7vw;position:relative}
34
+ .cat{display:flex;flex-direction:column;align-items:center;width:230px;
35
+ transition:transform .35s cubic-bezier(.2,1.4,.4,1)}
36
+ .cat.talk{transform:translateY(-10px) scale(1.06)}
37
+ .cat.listen{transform:scale(.97);opacity:.85}
38
+ /* Mochi lunges at the glass when a fresh verdict lands */
39
+ .cat.pounce .creature{animation:pounce .55s cubic-bezier(.3,1.5,.5,1)}
40
+ @keyframes pounce{0%{transform:none}35%{transform:translateY(14px) scale(1.18)}100%{transform:none}}
41
+
42
+ .bubble{min-height:42px;max-width:260px;margin-bottom:14px;padding:10px 14px;border-radius:14px;
43
+ background:#fff;color:#161033;font-size:15px;line-height:1.35;text-align:center;position:relative;
44
+ opacity:0;transform:translateY(8px) scale(.96);transition:opacity .25s,transform .25s;
45
+ box-shadow:0 6px 20px #0007}
46
+ .bubble.show{opacity:1;transform:none}
47
+ .bubble:after{content:"";position:absolute;bottom:-9px;left:50%;margin-left:-9px;
48
+ border:9px solid transparent;border-top-color:#fff;border-bottom:0}
49
+ .mochi .bubble{border:2px solid var(--mochi)} .loaf .bubble{border:2px solid var(--loaf)}
50
+
51
+ .name{margin-top:10px;font-size:12px;letter-spacing:2px}
52
+ .mochi .name{color:var(--mochi)} .loaf .name{color:var(--loaf)}
53
+ .creature{width:200px;height:200px}
54
+
55
+ /* "what they see" reasoning line */
56
+ footer{padding:10px 18px 16px;text-align:center}
57
+ #scene{color:var(--dim);font-size:13px;min-height:18px}
58
+ #scene b{color:var(--gold);font-weight:normal}
59
+ #think{color:var(--mochi);font-size:12px;letter-spacing:1px;height:14px;opacity:0;transition:opacity .2s}
60
+ #think.on{opacity:1}
61
+ #actions{margin-top:8px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}
62
+ #share{background:transparent;color:var(--gold);border:1px solid var(--line);border-radius:8px;
63
+ padding:7px 14px;font:12px "Courier New",monospace;cursor:pointer;letter-spacing:1px}
64
+ #share:hover{border-color:var(--gold)}
65
+ #actions .hint{color:var(--dim);font-size:11px}
66
+ #dropnote{position:fixed;inset:0;z-index:25;display:none;align-items:center;justify-content:center;
67
+ background:#0c0a1ecc;color:var(--gold);font-size:20px;letter-spacing:2px;
68
+ border:3px dashed var(--gold);pointer-events:none}
69
+ #dropnote.show{display:flex}
70
+
71
+ /* consent gate */
72
+ #gate{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;
73
+ background:radial-gradient(120% 90% at 50% 0%,var(--bg2),var(--bg))}
74
+ #gate .card{max-width:440px;text-align:center;padding:30px;border:1px solid var(--line);
75
+ border-radius:16px;background:#0c0a1ecc}
76
+ #gate h2{color:var(--gold);margin:0 0 6px;letter-spacing:2px}
77
+ #gate p{color:var(--dim);font-size:13px}
78
+ button{margin-top:14px;background:var(--mochi);color:#2a1500;border:0;border-radius:10px;
79
+ padding:12px 22px;font:bold 14px "Courier New",monospace;cursor:pointer;letter-spacing:1px}
80
+ button.ghost{background:transparent;color:var(--dim);border:1px solid var(--line);margin-left:8px}
81
+ #cam-status{color:var(--dim);font-size:12px;margin-top:10px;min-height:16px}
82
+ </style>
83
+ </head>
84
+ <body>
85
+ <div id="wrap">
86
+ <header>
87
+ <h1>THE COMMITTEE</h1>
88
+ <p>two cats intercepted your camera. they understand exactly what you are doing. they are judging you for it.</p>
89
+ </header>
90
+
91
+ <div id="viewport">
92
+ <video id="vp-video" playsinline muted></video>
93
+ <div class="vp-label"><span id="vp-dot"></span>WHAT THEY SEE</div>
94
+ </div>
95
+
96
+ <div id="stage">
97
+ <!-- MOCHI: hyper kitten — ears up, wide manic eyes, ginger -->
98
+ <div class="cat mochi" id="a-mochi">
99
+ <div class="bubble" id="b-mochi"></div>
100
+ <svg class="creature" viewBox="0 0 100 100" aria-hidden="true">
101
+ <polygon points="22,40 26,12 46,30" fill="#f6a64b"/>
102
+ <polygon points="78,40 74,12 54,30" fill="#f6a64b"/>
103
+ <polygon points="27,33 30,19 41,29" fill="#ffd0a3"/>
104
+ <polygon points="73,33 70,19 59,29" fill="#ffd0a3"/>
105
+ <ellipse cx="50" cy="58" rx="34" ry="31" fill="#f6a64b"/>
106
+ <ellipse cx="50" cy="64" rx="26" ry="22" fill="#ffe4c4"/>
107
+ <circle cx="38" cy="54" r="11" fill="#fff"/>
108
+ <circle class="pupil" data-eye="mo-l" cx="38" cy="54" r="6.5" fill="#1c3a1c"/>
109
+ <circle cx="62" cy="54" r="11" fill="#fff"/>
110
+ <circle class="pupil" data-eye="mo-r" cx="62" cy="54" r="6.5" fill="#1c3a1c"/>
111
+ <polygon points="50,64 46,69 54,69" fill="#e8718a"/>
112
+ <path d="M50 69 V73 M50 73 Q44 75 41 72 M50 73 Q56 75 59 72" stroke="#b5704a" stroke-width="1.6" fill="none" stroke-linecap="round"/>
113
+ <g stroke="#fff" stroke-width="1.3" stroke-linecap="round" opacity=".85">
114
+ <path d="M30 64 H10"/><path d="M30 68 H12"/><path d="M70 64 H90"/><path d="M70 68 H88"/></g>
115
+ </svg>
116
+ <div class="name">MOCHI</div>
117
+ </div>
118
+
119
+ <!-- LOAF: enormous elder loaf — droopy lids, grey, unbothered -->
120
+ <div class="cat loaf" id="a-loaf">
121
+ <div class="bubble" id="b-loaf"></div>
122
+ <svg class="creature" viewBox="0 0 100 100" aria-hidden="true">
123
+ <polygon points="26,44 30,24 48,38" fill="#8d8f9e"/>
124
+ <polygon points="74,44 70,24 52,38" fill="#8d8f9e"/>
125
+ <path d="M14 70 Q14 44 50 44 Q86 44 86 70 Q86 92 50 92 Q14 92 14 70Z" fill="#9a9cab"/>
126
+ <ellipse cx="50" cy="70" rx="30" ry="22" fill="#c9cbd6"/>
127
+ <circle cx="37" cy="60" r="10" fill="#d8e6c0"/>
128
+ <circle class="pupil" data-eye="lo-l" cx="37" cy="61" r="5" fill="#23301c"/>
129
+ <circle cx="63" cy="60" r="10" fill="#d8e6c0"/>
130
+ <circle class="pupil" data-eye="lo-r" cx="63" cy="61" r="5" fill="#23301c"/>
131
+ <!-- heavy half-shut lids: the eternal unbothered loaf -->
132
+ <path d="M27 57 Q37 53 47 57" stroke="#9a9cab" stroke-width="6" fill="none" stroke-linecap="round"/>
133
+ <path d="M53 57 Q63 53 73 57" stroke="#9a9cab" stroke-width="6" fill="none" stroke-linecap="round"/>
134
+ <polygon points="50,70 46,75 54,75" fill="#c66"/>
135
+ <path d="M50 75 V79 M50 79 Q43 81 40 78 M50 79 Q57 81 60 78" stroke="#6b6d7a" stroke-width="1.6" fill="none" stroke-linecap="round"/>
136
+ <g stroke="#e7e9f2" stroke-width="1.3" stroke-linecap="round" opacity=".8">
137
+ <path d="M28 71 H6"/><path d="M28 75 H9"/><path d="M72 71 H94"/><path d="M72 75 H91"/></g>
138
+ </svg>
139
+ <div class="name">LOAF</div>
140
+ </div>
141
+ </div>
142
+
143
+ <footer>
144
+ <div id="think">· · ·</div>
145
+ <div id="scene">what they see: <b id="scene-text">warming up the window…</b></div>
146
+ <div id="actions">
147
+ <button id="share">share their verdict</button>
148
+ <span class="hint">· or drag any image here and they'll judge it ·</span>
149
+ </div>
150
+ </footer>
151
+ </div>
152
+
153
+ <div id="dropnote">drop it — the committee will judge your image</div>
154
+
155
+ <div id="gate">
156
+ <div class="card">
157
+ <h2>LET THEM WATCH</h2>
158
+ <p>Mochi &amp; Loaf watch you through your webcam and judge what you're doing.
159
+ Frames are described on this machine and never stored or uploaded anywhere.</p>
160
+ <button id="allow">allow camera</button>
161
+ <button id="skip" class="ghost">just watch them</button>
162
+ <div id="cam-status"></div>
163
+ </div>
164
+ </div>
165
+
166
+ <script type="module" src="/theater/js/duo.js"></script>
167
+ </body>
168
+ </html>
theater/index.html ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>GLORBVISION — Earth Feed</title>
7
+ <style>
8
+ :root {
9
+ --void: #1a0f30;
10
+ --stone: #2a1a4a;
11
+ --cyan: #58e6e6;
12
+ --gold: #ffd35a;
13
+ --love: #ff6fa5;
14
+ --meh: #8fb3bd;
15
+ --hate: #ff3b30;
16
+ --paper: #fffbe9;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ html, body {
20
+ margin: 0; padding: 0; width: 100%; height: 100%;
21
+ background: var(--void); color: #efeaff; overflow: hidden;
22
+ font: 13px/1.4 "Courier New", ui-monospace, monospace;
23
+ }
24
+ #stage { position: fixed; inset: 0; }
25
+ #stage canvas { display: block; cursor: crosshair; }
26
+
27
+ /* ---------- top HUD ---------- */
28
+ #hud-top {
29
+ position: fixed; top: 0; left: 0; right: 0; z-index: 20;
30
+ display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap;
31
+ padding: 10px 14px 14px;
32
+ background: linear-gradient(180deg, rgba(10,5,24,.92) 0%, rgba(10,5,24,.65) 70%, transparent 100%);
33
+ pointer-events: none;
34
+ }
35
+ #brand {
36
+ font-size: 22px; font-weight: bold; letter-spacing: 3px; color: var(--cyan);
37
+ text-shadow: 0 0 12px rgba(88,230,230,.6);
38
+ white-space: nowrap;
39
+ }
40
+ #brand .sub {
41
+ display: block; font-size: 9px; letter-spacing: 2px; color: var(--gold);
42
+ text-shadow: none;
43
+ }
44
+ #premise { flex: 1 1 320px; min-width: 240px; color: #efeaff; font-size: 12px; }
45
+ #premise b { color: var(--gold); }
46
+ #factions { display: flex; align-items: center; gap: 12px; white-space: nowrap; font-weight: bold; }
47
+ #factions .f { padding: 2px 8px; border: 1px solid currentColor; border-radius: 3px; }
48
+ .f.love { color: var(--love); } .f.meh { color: var(--meh); } .f.hate { color: var(--hate); }
49
+ #live-pill {
50
+ padding: 2px 8px; border-radius: 3px; font-size: 11px; letter-spacing: 1px;
51
+ background: var(--hate); color: #fff;
52
+ }
53
+ #live-pill.on { background: #1fbf6b; animation: livepulse 1.6s infinite; }
54
+ @keyframes livepulse { 50% { box-shadow: 0 0 10px #1fbf6b; } }
55
+ #thinking { color: var(--cyan); font-size: 11px; animation: blink 1s steps(2) infinite; }
56
+ @keyframes blink { 50% { opacity: .25; } }
57
+
58
+ /* ---------- event splash ---------- */
59
+ #event-label {
60
+ position: fixed; top: 18%; left: 50%; transform: translateX(-50%);
61
+ z-index: 25; pointer-events: none;
62
+ font-size: 34px; font-weight: bold; letter-spacing: 6px;
63
+ color: var(--gold); text-shadow: 0 0 18px rgba(255,211,90,.8), 2px 2px 0 var(--void);
64
+ animation: splash 1.6s ease-out forwards;
65
+ }
66
+ @keyframes splash {
67
+ 0% { opacity: 0; transform: translateX(-50%) scale(.4); }
68
+ 15% { opacity: 1; transform: translateX(-50%) scale(1.15); }
69
+ 25% { transform: translateX(-50%) scale(1); }
70
+ 80% { opacity: 1; }
71
+ 100% { opacity: 0; }
72
+ }
73
+
74
+ /* ---------- bubbles ---------- */
75
+ #bubble-layer { position: fixed; inset: 0; z-index: 15; pointer-events: none; overflow: hidden; }
76
+ .bubble {
77
+ position: absolute; left: 0; top: 0; max-width: 230px;
78
+ background: var(--paper); color: var(--void);
79
+ border: 2px solid var(--void); border-radius: 2px;
80
+ padding: 4px 8px 5px; font-weight: bold; font-size: 11px; line-height: 1.3;
81
+ transform: translate(-50%, -112%);
82
+ box-shadow: 3px 3px 0 rgba(0,0,0,.45), 0 0 0 1px rgba(88,230,230,.35);
83
+ opacity: 0;
84
+ }
85
+ .bubble .who { display: block; font-size: 8px; letter-spacing: 2px; color: #7a3fd6; }
86
+ .bubble::after {
87
+ content: ""; position: absolute; left: 50%; bottom: -8px; margin-left: -6px;
88
+ border: 6px solid transparent; border-top-color: var(--void); border-bottom: 0;
89
+ }
90
+ .bubble.show { animation: pop .22s cubic-bezier(.2,1.6,.4,1) forwards; }
91
+ @keyframes pop {
92
+ 0% { opacity: 0; transform: translate(-50%, -112%) scale(.25); }
93
+ 100% { opacity: 1; transform: translate(-50%, -112%) scale(1); }
94
+ }
95
+ .bubble.hide { transition: opacity .18s, transform .18s; opacity: 0 !important; transform: translate(-50%, -130%) scale(.6); }
96
+
97
+ /* ---------- bottom HUD ---------- */
98
+ #hud-bottom {
99
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
100
+ padding: 12px 14px 10px;
101
+ background: linear-gradient(0deg, rgba(10,5,24,.92) 0%, rgba(10,5,24,.6) 70%, transparent 100%);
102
+ display: flex; flex-direction: column; gap: 5px;
103
+ }
104
+ #scene-line { font-size: 13px; color: #fff; }
105
+ #scene-line .tag {
106
+ background: var(--cyan); color: var(--void); font-weight: bold;
107
+ padding: 1px 6px; margin-right: 6px; font-size: 10px; letter-spacing: 1px;
108
+ }
109
+ #scene-text { color: var(--gold); }
110
+ #privacy { font-size: 10px; color: #b9aee0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
111
+ #egress-dot {
112
+ width: 9px; height: 9px; border-radius: 50%; flex: none;
113
+ background: #4a3a6e; border: 1px solid #6a55a0; transition: background .1s, box-shadow .1s;
114
+ }
115
+ #egress-dot.active { background: var(--hate); box-shadow: 0 0 8px var(--hate); }
116
+ #privacy button, #consent button {
117
+ font: bold 11px "Courier New", monospace; letter-spacing: 1px;
118
+ background: var(--stone); color: #fff; border: 1px solid var(--cyan);
119
+ padding: 3px 10px; cursor: pointer; border-radius: 2px;
120
+ }
121
+ #privacy button:hover, #consent button:hover { background: var(--cyan); color: var(--void); }
122
+ #cam-status { color: var(--hate); font-weight: bold; }
123
+ #hint { font-size: 10px; color: #6f5fa0; letter-spacing: 1px; }
124
+
125
+ /* ---------- consent overlay ---------- */
126
+ #consent {
127
+ position: fixed; inset: 0; z-index: 40;
128
+ display: flex; align-items: center; justify-content: center;
129
+ background: rgba(12,6,28,.88); backdrop-filter: blur(3px);
130
+ transition: opacity .4s;
131
+ }
132
+ #consent.gone { opacity: 0; pointer-events: none; }
133
+ #consent-card {
134
+ max-width: 460px; margin: 16px; padding: 28px 30px;
135
+ background: var(--void); border: 2px solid var(--cyan);
136
+ box-shadow: 0 0 40px rgba(88,230,230,.25), 8px 8px 0 rgba(0,0,0,.5);
137
+ text-align: center;
138
+ }
139
+ #consent-card h1 {
140
+ margin: 0 0 4px; font-size: 30px; letter-spacing: 5px; color: var(--cyan);
141
+ text-shadow: 0 0 14px rgba(88,230,230,.7);
142
+ }
143
+ #consent-card .cycle { font-size: 10px; letter-spacing: 3px; color: var(--gold); margin-bottom: 14px; }
144
+ #consent-card p { margin: 10px 0; color: #efeaff; }
145
+ #consent-card .small { font-size: 10px; color: #b9aee0; }
146
+ #consent-card #start-cam {
147
+ display: block; width: 100%; margin: 18px 0 8px; padding: 12px;
148
+ font-size: 13px; background: var(--cyan); color: var(--void); border: none;
149
+ }
150
+ #consent-card #start-cam:hover { background: var(--gold); }
151
+ #consent-card #skip-cam { background: transparent; border-color: #5a4690; color: #b9aee0; }
152
+ </style>
153
+ <script type="importmap">
154
+ { "imports": { "three": "/web/vendor/three.module.js" } }
155
+ </script>
156
+ </head>
157
+ <body>
158
+ <div id="stage"></div>
159
+
160
+ <header id="hud-top">
161
+ <div id="brand">GLORBVISION<span class="sub">EARTH FEED · CYCLE 4,071</span></div>
162
+ <div id="premise">120 aliens intercepted your webcam and now believe you are prestige television —
163
+ <b>allow the camera so they can watch you.</b></div>
164
+ <div id="factions">
165
+ <span class="f love">LOVE <b id="f-love">0</b></span>
166
+ <span class="f meh">MEH <b id="f-meh">120</b></span>
167
+ <span class="f hate">HATE <b id="f-hate">0</b></span>
168
+ <span id="live-pill" class="off">NO SIGNAL</span>
169
+ <span id="thinking" hidden>TRANSLATOR…</span>
170
+ </div>
171
+ </header>
172
+
173
+ <div id="bubble-layer"></div>
174
+
175
+ <footer id="hud-bottom">
176
+ <div id="scene-line"><span class="tag">TRANSLATOR</span><span id="scene-text">awaiting first transmission from Earth…</span></div>
177
+ <div id="privacy">
178
+ <span id="egress-dot" title="flashes when a snapshot is sent"></span>
179
+ <span>Camera stays in your browser. One low-res snapshot goes to the local server only on scene change (or every 10&nbsp;s) and is never stored.</span>
180
+ <button id="stop-cam" hidden>STOP CAMERA</button>
181
+ <span id="cam-status"></span>
182
+ </div>
183
+ <div id="hint">CLICK AN ALIEN to read its census dossier · CLICK EMPTY SEATS to let them know you can see them. they will not handle it well.</div>
184
+ </footer>
185
+
186
+ <div id="consent">
187
+ <div id="consent-card">
188
+ <h1>GLORBVISION</h1>
189
+ <div class="cycle">EARTH FEED · CYCLE 4,071 · 120 BLORPS IN ATTENDANCE</div>
190
+ <p>An alien civilization tapped your webcam and thinks it's prestige television.
191
+ They worship your mug. They heckle your typing.</p>
192
+ <p class="small">Privacy: your camera feed stays in this browser. Only one tiny snapshot
193
+ is POSTed to your local server when the scene changes (or every 10&nbsp;s), never stored,
194
+ never leaves your machine. A red dot flashes on every send. You can stop the camera anytime.</p>
195
+ <button id="start-cam">ALLOW CAMERA — LET THEM WATCH</button>
196
+ <button id="skip-cam">enter without camera (they will riot)</button>
197
+ </div>
198
+ </div>
199
+
200
+ <script type="module" src="/theater/js/main.js"></script>
201
+ </body>
202
+ </html>
theater/js/aliencard.js ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // aliencard.js — alien dossier overlay + RUMOR MILL line (C, society upgrade).
2
+ //
3
+ // initAlienCard({ bubbleFor }) -> { open(id), close(), setRumor(str|null) }
4
+ //
5
+ // open(id) fetches GET /api/alien/{id} and renders a card in the HUD's
6
+ // Courier/cyan aesthetic. Defensive about the card shape: alien_card() may
7
+ // nest persona fields under .persona or keep them flat, and memory entries
8
+ // may be strings or {said,...} objects. A 404 (cards not wired yet) renders
9
+ // a friendly "no census record" card instead of breaking the theater.
10
+ // setRumor() owns the small RUMOR MILL line under the factions HUD.
11
+
12
+ let overlayEl = null;
13
+ let boxEl = null;
14
+ let rumorEl = null;
15
+ let openId = null;
16
+ let lastRumor = null;
17
+ let bubbleFor = null;
18
+
19
+ const CSS = `
20
+ #alien-card-overlay {
21
+ position: fixed; inset: 0; z-index: 35;
22
+ display: flex; align-items: center; justify-content: center;
23
+ background: rgba(12,6,28,.55);
24
+ }
25
+ #alien-card-overlay[hidden] { display: none; }
26
+ #alien-card {
27
+ position: relative; width: min(400px, calc(100vw - 32px));
28
+ max-height: calc(100vh - 120px); overflow-y: auto;
29
+ background: var(--void, #1a0f30); color: #efeaff;
30
+ border: 2px solid var(--cyan, #58e6e6);
31
+ box-shadow: 0 0 36px rgba(88,230,230,.3), 8px 8px 0 rgba(0,0,0,.5);
32
+ padding: 18px 20px 16px;
33
+ font: 13px/1.45 "Courier New", ui-monospace, monospace;
34
+ }
35
+ #alien-card .ac-close {
36
+ position: absolute; top: 8px; right: 8px;
37
+ font: bold 12px "Courier New", monospace;
38
+ background: var(--stone, #2a1a4a); color: var(--cyan, #58e6e6);
39
+ border: 1px solid var(--cyan, #58e6e6); border-radius: 2px;
40
+ padding: 2px 8px; cursor: pointer; letter-spacing: 1px;
41
+ }
42
+ #alien-card .ac-close:hover { background: var(--cyan, #58e6e6); color: var(--void, #1a0f30); }
43
+ #alien-card .ac-kicker { font-size: 9px; letter-spacing: 3px; color: var(--gold, #ffd35a); }
44
+ #alien-card .ac-name {
45
+ margin: 2px 0; font-size: 26px; font-weight: bold; letter-spacing: 2px;
46
+ color: var(--cyan, #58e6e6); text-shadow: 0 0 12px rgba(88,230,230,.55);
47
+ word-break: break-word;
48
+ }
49
+ #alien-card .ac-sub {
50
+ font-size: 11px; letter-spacing: 2px; color: var(--gold, #ffd35a);
51
+ text-transform: uppercase;
52
+ }
53
+ #alien-card .ac-bubble {
54
+ margin-top: 12px; padding: 6px 9px;
55
+ background: var(--paper, #fffbe9); color: var(--void, #1a0f30);
56
+ border: 2px solid var(--void, #1a0f30); border-radius: 2px;
57
+ font-weight: bold; font-size: 11px;
58
+ box-shadow: 3px 3px 0 rgba(0,0,0,.45), 0 0 0 1px rgba(88,230,230,.35);
59
+ }
60
+ #alien-card .ac-section { margin-top: 12px; }
61
+ #alien-card .ac-label { font-size: 9px; letter-spacing: 2px; color: var(--meh, #8fb3bd); }
62
+ #alien-card .ac-value { color: #efeaff; }
63
+ #alien-card .ac-opinion { color: var(--gold, #ffd35a); font-weight: bold; }
64
+ #alien-card .ac-said { margin: 4px 0 0; padding: 0; list-style: none; }
65
+ #alien-card .ac-said li {
66
+ margin-top: 3px; padding-left: 10px; color: #d9d2f5;
67
+ border-left: 2px solid var(--stone, #2a1a4a);
68
+ }
69
+ #alien-card .ac-dim { color: #8fb3bd; }
70
+ #rumor-mill {
71
+ flex-basis: 100%; pointer-events: none;
72
+ font-size: 11px; letter-spacing: 1px; color: #efeaff;
73
+ }
74
+ #rumor-mill b {
75
+ background: var(--gold, #ffd35a); color: var(--void, #1a0f30);
76
+ padding: 1px 6px; margin-right: 6px; font-size: 9px; letter-spacing: 2px;
77
+ }
78
+ `;
79
+
80
+ function el(tag, cls, text) {
81
+ const n = document.createElement(tag);
82
+ if (cls) n.className = cls;
83
+ if (text !== undefined && text !== null) n.textContent = String(text);
84
+ return n;
85
+ }
86
+
87
+ export function initAlienCard(opts = {}) {
88
+ bubbleFor = typeof opts.bubbleFor === 'function' ? opts.bubbleFor : null;
89
+
90
+ if (!document.getElementById('alien-card-style')) {
91
+ const style = document.createElement('style');
92
+ style.id = 'alien-card-style';
93
+ style.textContent = CSS;
94
+ document.head.appendChild(style);
95
+ }
96
+ if (!overlayEl) {
97
+ overlayEl = el('div');
98
+ overlayEl.id = 'alien-card-overlay';
99
+ overlayEl.hidden = true;
100
+ overlayEl.addEventListener('click', (e) => {
101
+ if (e.target === overlayEl) closeAlienCard(); // outside-click closes
102
+ });
103
+ boxEl = el('div');
104
+ boxEl.id = 'alien-card';
105
+ overlayEl.appendChild(boxEl);
106
+ document.body.appendChild(overlayEl);
107
+ document.addEventListener('keydown', (e) => {
108
+ if (e.key === 'Escape') closeAlienCard();
109
+ });
110
+ }
111
+ if (!rumorEl) {
112
+ rumorEl = el('div');
113
+ rumorEl.id = 'rumor-mill';
114
+ rumorEl.hidden = true;
115
+ (document.getElementById('hud-top') || document.body).appendChild(rumorEl);
116
+ }
117
+ return { open: openAlienCard, close: closeAlienCard, setRumor };
118
+ }
119
+
120
+ export async function openAlienCard(id) {
121
+ if (!overlayEl) return;
122
+ openId = id;
123
+ overlayEl.hidden = false;
124
+ renderLoading(id);
125
+ let card = null;
126
+ try {
127
+ const res = await fetch(`/api/alien/${id}`);
128
+ if (res.ok) card = await res.json();
129
+ } catch { /* server offline — fall through to the unknown card */ }
130
+ if (openId !== id || overlayEl.hidden) return; // closed or re-opened meanwhile
131
+ if (card && typeof card === 'object') renderCard(id, card);
132
+ else renderUnknown(id);
133
+ }
134
+
135
+ export function closeAlienCard() {
136
+ openId = null;
137
+ if (overlayEl) overlayEl.hidden = true;
138
+ }
139
+
140
+ /** Show/refresh the RUMOR MILL line under the factions HUD (null hides it). */
141
+ export function setRumor(rumor) {
142
+ const next = (typeof rumor === 'string' && rumor.trim()) ? rumor.trim() : null;
143
+ if (next === lastRumor || !rumorEl) return;
144
+ lastRumor = next;
145
+ rumorEl.textContent = '';
146
+ if (!next) { rumorEl.hidden = true; return; }
147
+ rumorEl.hidden = false;
148
+ rumorEl.appendChild(el('b', null, 'RUMOR MILL'));
149
+ rumorEl.appendChild(document.createTextNode(next));
150
+ }
151
+
152
+ // ---------------------------------------------------------------- rendering
153
+
154
+ function header(title) {
155
+ boxEl.textContent = '';
156
+ const close = el('button', 'ac-close', 'X');
157
+ close.setAttribute('aria-label', 'close dossier');
158
+ close.addEventListener('click', closeAlienCard);
159
+ boxEl.appendChild(close);
160
+ boxEl.appendChild(el('div', 'ac-kicker', 'GLORBVISION CENSUS DOSSIER'));
161
+ boxEl.appendChild(el('div', 'ac-name', title));
162
+ }
163
+
164
+ function renderLoading(id) {
165
+ header(`BLORP #${id}`);
166
+ boxEl.appendChild(el('div', 'ac-dim', 'pulling census record…'));
167
+ }
168
+
169
+ function renderUnknown(id) {
170
+ header(`BLORP #${id}`);
171
+ boxEl.appendChild(el('div', 'ac-dim',
172
+ 'No dossier on file. The census drones have not reached this seat yet.'));
173
+ }
174
+
175
+ function relationshipText(rel) {
176
+ if (!rel) return '';
177
+ if (typeof rel === 'string') return rel;
178
+ if (typeof rel === 'object') {
179
+ const kind = String(rel.kind || '').replace(/_/g, ' ');
180
+ if (!kind) return '';
181
+ const target = rel.target_name || rel.target
182
+ || (rel.target_id !== undefined ? `the blorp in seat #${rel.target_id}` : '');
183
+ return target ? `${kind} → ${target}` : kind;
184
+ }
185
+ return '';
186
+ }
187
+
188
+ function memLines(memory) {
189
+ if (!Array.isArray(memory)) return [];
190
+ const lines = [];
191
+ for (const m of memory) {
192
+ let said = '';
193
+ if (typeof m === 'string') said = m;
194
+ else if (m && typeof m === 'object') said = m.said || m.line || m.text || m.quote || '';
195
+ if (said) lines.push(String(said));
196
+ }
197
+ return lines.slice(-3).reverse(); // newest first
198
+ }
199
+
200
+ function renderCard(id, card) {
201
+ const persona = (card.persona && typeof card.persona === 'object') ? card.persona : card;
202
+ header(card.name || persona.name || `BLORP #${id}`);
203
+
204
+ const arch = card.archetype || persona.archetype || 'unknown';
205
+ boxEl.appendChild(el('div', 'ac-sub', card.mood ? `${arch} · ${card.mood}` : arch));
206
+
207
+ const bubble = card.bubble || (bubbleFor ? bubbleFor(id) : null);
208
+ if (bubble) boxEl.appendChild(el('div', 'ac-bubble', `“${bubble}”`));
209
+
210
+ const rows = [
211
+ ['QUIRK', persona.quirk],
212
+ ['OBSESSION', persona.obsession],
213
+ ['SPEECH TIC', persona.tic || persona.speech_tic],
214
+ ['RELATIONSHIP', relationshipText(persona.relationship || persona.relation)],
215
+ ];
216
+ for (const [label, value] of rows) {
217
+ if (!value) continue;
218
+ const sec = el('div', 'ac-section');
219
+ sec.appendChild(el('div', 'ac-label', label));
220
+ sec.appendChild(el('div', 'ac-value', value));
221
+ boxEl.appendChild(sec);
222
+ }
223
+
224
+ const opinion = card.opinion || card.opinion_of_you
225
+ || persona.opinion || persona.opinion_of_protagonist;
226
+ const opSec = el('div', 'ac-section');
227
+ opSec.appendChild(el('div', 'ac-label', 'OPINION OF YOU'));
228
+ opSec.appendChild(el('div', 'ac-opinion', opinion || 'still forming one. stay tuned.'));
229
+ boxEl.appendChild(opSec);
230
+
231
+ const lines = memLines(card.memory);
232
+ const memSec = el('div', 'ac-section');
233
+ memSec.appendChild(el('div', 'ac-label', 'LAST TRANSMISSIONS'));
234
+ if (lines.length) {
235
+ const ul = el('ul', 'ac-said');
236
+ for (const line of lines) ul.appendChild(el('li', null, `“${line}”`));
237
+ memSec.appendChild(ul);
238
+ } else {
239
+ memSec.appendChild(el('div', 'ac-dim', 'has not said anything memorable yet.'));
240
+ }
241
+ boxEl.appendChild(memSec);
242
+ }
theater/js/bubbles.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // bubbles.js — speech bubbles as a DOM overlay, positioned by projecting each
2
+ // speaking alien's world position. Max 8 live; pop-in staggered for comic timing.
3
+
4
+ const MAX_BUBBLES = 8;
5
+
6
+ export class Bubbles {
7
+ constructor(layerEl) {
8
+ this.layer = layerEl;
9
+ this.items = new Map(); // alien id -> { el, text }
10
+ }
11
+
12
+ /** Diff the polled alien list against currently shown bubbles. */
13
+ applyState(aliens) {
14
+ if (!Array.isArray(aliens)) return;
15
+ const wanted = new Map();
16
+ for (const a of aliens) {
17
+ if (a && typeof a.bubble === 'string' && a.bubble.length) wanted.set(a.id, a);
18
+ }
19
+ // drop bubbles that vanished or changed text
20
+ for (const [id, item] of [...this.items]) {
21
+ const w = wanted.get(id);
22
+ if (!w || w.bubble !== item.text) this._remove(id);
23
+ }
24
+ // pop in new ones, staggered ~140 ms apart so a fresh batch reads as a riff
25
+ let delay = 0;
26
+ for (const [id, a] of wanted) {
27
+ if (this.items.has(id)) continue;
28
+ if (this.items.size >= MAX_BUBBLES) break;
29
+ this._add(a, delay);
30
+ delay += 140;
31
+ }
32
+ }
33
+
34
+ _add(a, delay) {
35
+ const el = document.createElement('div');
36
+ el.className = 'bubble';
37
+ const who = document.createElement('span');
38
+ who.className = 'who';
39
+ who.textContent = String(a.archetype || '???').toUpperCase().replace(/_/g, ' ');
40
+ el.appendChild(who);
41
+ el.appendChild(document.createTextNode(String(a.bubble).slice(0, 60)));
42
+ el.style.display = 'none';
43
+ this.layer.appendChild(el);
44
+ const item = { el, text: a.bubble, shown: false };
45
+ this.items.set(a.id, item);
46
+ setTimeout(() => {
47
+ if (!el.isConnected) return;
48
+ item.shown = true;
49
+ el.classList.add('show');
50
+ }, delay);
51
+ }
52
+
53
+ _remove(id) {
54
+ const item = this.items.get(id);
55
+ if (!item) return;
56
+ this.items.delete(id);
57
+ const { el } = item;
58
+ el.classList.add('hide');
59
+ setTimeout(() => el.remove(), 220);
60
+ }
61
+
62
+ /** Call every animation frame: pin bubbles above their aliens. */
63
+ reposition(theater) {
64
+ for (const [id, item] of this.items) {
65
+ if (!item.shown) continue;
66
+ const p = theater.projectAlien(id);
67
+ if (!p || p.x < -60 || p.x > window.innerWidth + 60 || p.y < 0) {
68
+ item.el.style.display = 'none';
69
+ continue;
70
+ }
71
+ item.el.style.display = '';
72
+ item.el.style.left = `${p.x.toFixed(1)}px`;
73
+ item.el.style.top = `${p.y.toFixed(1)}px`;
74
+ }
75
+ }
76
+ }
theater/js/capture.js ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // capture.js — consent-first webcam pipeline (B1).
2
+ // One hidden <video> feeds BOTH the three.js VideoTexture and a 480-wide capture
3
+ // canvas. Cheap grayscale diff on a 64x48 buffer gates JSON-base64 POSTs to
4
+ // /api/frame (contract transport). Light/sound numbers go to /api/sense every 2 s.
5
+ // Privacy: egress dot flashes on every actual POST; stop() releases the device.
6
+
7
+ const CAPTURE_W = 480;
8
+ const DIFF_W = 64, DIFF_H = 48;
9
+ const JPEG_QUALITY = 0.7;
10
+ const DIFF_THRESHOLD = 0.05;
11
+ const HEARTBEAT_MS = 10_000;
12
+ const MIN_INTERVAL_MS = 1_500;
13
+ const SAMPLE_MS = 333;
14
+ const SENSE_MS = 2_000;
15
+
16
+ export class Capture {
17
+ constructor({ onVideo, onStatus, egressDot } = {}) {
18
+ this.onVideo = onVideo || (() => {});
19
+ this.onStatus = onStatus || (() => {});
20
+ this.dot = egressDot || null;
21
+
22
+ this.video = document.createElement('video');
23
+ this.video.playsInline = true;
24
+ this.video.muted = true;
25
+
26
+ this.captureCanvas = document.createElement('canvas');
27
+ this.captureCtx = this.captureCanvas.getContext('2d');
28
+
29
+ this.diffCanvas = document.createElement('canvas');
30
+ this.diffCanvas.width = DIFF_W;
31
+ this.diffCanvas.height = DIFF_H;
32
+ // willReadFrequently keeps the canvas CPU-side -> cheap getImageData
33
+ this.diffCtx = this.diffCanvas.getContext('2d', { willReadFrequently: true });
34
+
35
+ this.stream = null;
36
+ this.prevGray = null;
37
+ this.running = false;
38
+ this.inFlight = false;
39
+ this.lastUpload = 0;
40
+ this.lastSample = 0;
41
+ this.readings = { light: -1, sound: -1 };
42
+ this.analyser = null;
43
+ this.audioBuf = null;
44
+ this.audioCtx = null;
45
+
46
+ // sense push runs forever; posts -1/-1 until the camera is live
47
+ setInterval(() => this._postSense(), SENSE_MS);
48
+ this._postSense();
49
+
50
+ document.addEventListener('visibilitychange', () => {
51
+ if (!this.stream) return;
52
+ this.running = !document.hidden;
53
+ if (this.running) this._scheduleTick();
54
+ });
55
+ }
56
+
57
+ // -------------------------------------------------------------- lifecycle
58
+
59
+ async start() {
60
+ if (this.stream) return true;
61
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
62
+ this.onStatus('camera needs HTTPS or localhost — the Blorps see only static.');
63
+ return false;
64
+ }
65
+ const videoConstraints = {
66
+ width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user',
67
+ };
68
+ let stream = null;
69
+ try {
70
+ stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: true });
71
+ } catch (err) {
72
+ try {
73
+ // mic refused or absent — try video only, sound stays -1
74
+ stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
75
+ } catch (err2) {
76
+ this.onStatus(this._explainError(err2));
77
+ return false;
78
+ }
79
+ }
80
+ this.stream = stream;
81
+ this.video.srcObject = stream;
82
+ await new Promise((res) => { this.video.onloadedmetadata = res; });
83
+ await this.video.play();
84
+
85
+ this.captureCanvas.width = CAPTURE_W;
86
+ this.captureCanvas.height =
87
+ Math.round(CAPTURE_W * this.video.videoHeight / this.video.videoWidth) || 360;
88
+
89
+ if (stream.getAudioTracks().length) {
90
+ try {
91
+ this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
92
+ this.analyser = this.audioCtx.createAnalyser();
93
+ this.analyser.fftSize = 256;
94
+ this.audioCtx.createMediaStreamSource(stream).connect(this.analyser);
95
+ this.audioBuf = new Uint8Array(this.analyser.fftSize);
96
+ } catch (e) {
97
+ this.analyser = null;
98
+ }
99
+ }
100
+
101
+ this.running = true;
102
+ this.prevGray = null;
103
+ this._scheduleTick();
104
+ this.onVideo(this.video);
105
+ this.onStatus('');
106
+ return true;
107
+ }
108
+
109
+ /** The honest off-switch: releases the device, OS camera LED goes dark. */
110
+ stop() {
111
+ this.running = false;
112
+ if (this.stream) {
113
+ this.stream.getTracks().forEach((t) => t.stop());
114
+ this.stream = null;
115
+ }
116
+ if (this.audioCtx) {
117
+ this.audioCtx.close().catch(() => {});
118
+ this.audioCtx = null;
119
+ }
120
+ this.analyser = null;
121
+ this.video.srcObject = null;
122
+ this.prevGray = null;
123
+ this.readings.light = -1;
124
+ this.readings.sound = -1;
125
+ this._postSense();
126
+ }
127
+
128
+ _explainError(err) {
129
+ const name = err && err.name;
130
+ if (name === 'NotAllowedError') return 'camera denied — the Blorps boo at a blank screen.';
131
+ if (name === 'NotFoundError') return 'no camera found — broadcasting test pattern.';
132
+ return 'camera failed: ' + (name || 'unknown') + '.';
133
+ }
134
+
135
+ // -------------------------------------------------------------- frame diff
136
+
137
+ /** 0..1 motion score: mean per-pixel grayscale delta vs the previous sample. */
138
+ _diffScore() {
139
+ this.diffCtx.drawImage(this.video, 0, 0, DIFF_W, DIFF_H);
140
+ const { data } = this.diffCtx.getImageData(0, 0, DIFF_W, DIFF_H);
141
+ const n = DIFF_W * DIFF_H;
142
+ const gray = new Uint8ClampedArray(n);
143
+ let lumSum = 0;
144
+ for (let i = 0; i < n; i++) {
145
+ const j = i * 4;
146
+ const g = 0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2];
147
+ gray[i] = g;
148
+ lumSum += g;
149
+ }
150
+ // light reading rides along for free: mean luma 0..255 -> 0..100
151
+ this.readings.light = Math.round((lumSum / n) / 255 * 100);
152
+
153
+ let score = 0;
154
+ if (this.prevGray) {
155
+ const NOISE = 15;
156
+ let sum = 0;
157
+ for (let i = 0; i < n; i++) {
158
+ const d = Math.abs(gray[i] - this.prevGray[i]);
159
+ if (d > NOISE) sum += d;
160
+ }
161
+ score = sum / (n * 255);
162
+ }
163
+ this.prevGray = gray;
164
+ return score;
165
+ }
166
+
167
+ _tick(now) {
168
+ if (!this.running) return;
169
+ if (now - this.lastSample >= SAMPLE_MS) {
170
+ this.lastSample = now;
171
+ const score = this._diffScore();
172
+ const since = performance.now() - this.lastUpload;
173
+ if (score > DIFF_THRESHOLD && since > MIN_INTERVAL_MS) this._maybeUpload();
174
+ else if (since > HEARTBEAT_MS) this._maybeUpload();
175
+ }
176
+ this._scheduleTick();
177
+ }
178
+
179
+ _scheduleTick() {
180
+ if (!this.running) return;
181
+ if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
182
+ this.video.requestVideoFrameCallback((now) => this._tick(now));
183
+ } else {
184
+ setTimeout(() => this._tick(performance.now()), SAMPLE_MS);
185
+ }
186
+ }
187
+
188
+ // -------------------------------------------------------------- egress
189
+
190
+ async _maybeUpload() {
191
+ if (this.inFlight) return; // never queue concurrent uploads
192
+ this.inFlight = true;
193
+ try {
194
+ this.captureCtx.drawImage(this.video, 0, 0, this.captureCanvas.width, this.captureCanvas.height);
195
+ const dataUrl = this.captureCanvas.toDataURL('image/jpeg', JPEG_QUALITY);
196
+ this.lastUpload = performance.now();
197
+ this._flashDot(); // user SEES every actual POST
198
+ await fetch('/api/frame', {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ image: dataUrl }),
202
+ });
203
+ } catch (err) {
204
+ // server busy/offline — drop silently, next diff will retry
205
+ } finally {
206
+ this.inFlight = false;
207
+ }
208
+ }
209
+
210
+ _flashDot() {
211
+ if (!this.dot) return;
212
+ this.dot.classList.add('active');
213
+ setTimeout(() => this.dot.classList.remove('active'), 300);
214
+ }
215
+
216
+ // -------------------------------------------------------------- sense
217
+
218
+ _postSense() {
219
+ if (this.running && this.analyser) {
220
+ this.analyser.getByteTimeDomainData(this.audioBuf);
221
+ let rms = 0;
222
+ for (let i = 0; i < this.audioBuf.length; i++) {
223
+ const v = (this.audioBuf[i] - 128) / 128;
224
+ rms += v * v;
225
+ }
226
+ this.readings.sound = Math.round(Math.min(100, Math.sqrt(rms / this.audioBuf.length) * 400));
227
+ } else if (!this.running) {
228
+ this.readings.light = -1;
229
+ this.readings.sound = -1;
230
+ } else {
231
+ this.readings.sound = -1;
232
+ }
233
+ fetch('/api/sense', {
234
+ method: 'POST',
235
+ headers: { 'Content-Type': 'application/json' },
236
+ body: JSON.stringify({ light: this.readings.light, sound: this.readings.sound }),
237
+ }).catch(() => { /* server not up yet */ });
238
+ }
239
+ }
theater/js/duo.js ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // duo.js — Mochi & Loaf: two cats who watch your webcam and judge you.
2
+ // Reuses the consent-first capture pipeline (frames -> /api/frame, light/sound
3
+ // -> /api/sense). Polls /api/duo for the latest verdict and plays it with comic
4
+ // timing. Punch-ups: pupils dilate in the dark, Mochi pounces on each new
5
+ // verdict, drag any image to be judged, share-the-verdict button.
6
+ import { Capture } from './capture.js';
7
+
8
+ const $ = (id) => document.getElementById(id);
9
+ const BUBBLE = { mochi: $('b-mochi'), loaf: $('b-loaf') };
10
+ const CAT = { mochi: $('a-mochi'), loaf: $('a-loaf') };
11
+
12
+ const capture = new Capture({
13
+ onVideo: (video) => { const vp = $('vp-video'); vp.srcObject = video.srcObject; vp.play().catch(() => {}); },
14
+ onStatus: (msg) => { $('cam-status').textContent = msg; },
15
+ });
16
+
17
+ // ---------------------------------------------------------------- consent gate
18
+ $('allow').addEventListener('click', async () => {
19
+ $('cam-status').textContent = 'waking the window…';
20
+ const ok = await capture.start();
21
+ if (ok) $('gate').style.display = 'none';
22
+ });
23
+ $('skip').addEventListener('click', () => { $('gate').style.display = 'none'; });
24
+
25
+ // ---------------------------------------------------------------- eyes
26
+ const pupils = [...document.querySelectorAll('.pupil')].map((el) => ({
27
+ el, cx: +el.getAttribute('cx'), cy: +el.getAttribute('cy'), r: +el.getAttribute('r'),
28
+ }));
29
+ let target = { x: 0.5, y: 0.45 };
30
+ let dilation = 1, dilationGoal = 1; // pupil size; grows in the dark
31
+ window.addEventListener('pointermove', (e) => {
32
+ target.x = e.clientX / window.innerWidth;
33
+ target.y = e.clientY / window.innerHeight;
34
+ });
35
+
36
+ let t = 0;
37
+ function animate() {
38
+ t += 1;
39
+ const wanderX = Math.sin(t / 73) * 0.06, wanderY = Math.cos(t / 91) * 0.05;
40
+ const dx = (target.x - 0.5 + wanderX) * 5, dy = (target.y - 0.45 + wanderY) * 4;
41
+ dilation += (dilationGoal - dilation) * 0.08;
42
+ for (const p of pupils) {
43
+ p.el.setAttribute('cx', (p.cx + dx).toFixed(2));
44
+ p.el.setAttribute('cy', (p.cy + dy).toFixed(2));
45
+ p.el.setAttribute('r', (p.r * dilation).toFixed(2));
46
+ }
47
+ requestAnimationFrame(animate);
48
+ }
49
+ requestAnimationFrame(animate);
50
+
51
+ // ---------------------------------------------------------------- play verdict
52
+ let lastKey = '', playToken = 0;
53
+ const other = (w) => (w === 'mochi' ? 'loaf' : 'mochi');
54
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
55
+
56
+ function setBubble(who, text) {
57
+ const b = BUBBLE[who];
58
+ if (!b) return;
59
+ b.textContent = text;
60
+ b.classList.add('show');
61
+ CAT[who].classList.add('talk');
62
+ CAT[other(who)].classList.add('listen');
63
+ }
64
+ function clearTalk() { for (const k of ['mochi', 'loaf']) CAT[k].classList.remove('talk', 'listen'); }
65
+
66
+ function pounce() { // Mochi lunges at the screen on a fresh verdict
67
+ CAT.mochi.classList.remove('pounce');
68
+ void CAT.mochi.offsetWidth; // restart the animation
69
+ CAT.mochi.classList.add('pounce');
70
+ }
71
+
72
+ async function play(exchange) {
73
+ const token = ++playToken;
74
+ BUBBLE.mochi.classList.remove('show');
75
+ BUBBLE.loaf.classList.remove('show');
76
+ clearTalk();
77
+ pounce();
78
+ await wait(280);
79
+ for (const line of exchange) {
80
+ if (token !== playToken) return;
81
+ clearTalk();
82
+ setBubble(line.who, line.text);
83
+ await wait(2200 + Math.min(3200, line.text.length * 55));
84
+ }
85
+ if (token === playToken) clearTalk();
86
+ }
87
+
88
+ // ---------------------------------------------------------------- poll loop
89
+ let curExchange = [];
90
+ async function poll() {
91
+ try {
92
+ const s = await (await fetch('/api/duo')).json();
93
+ $('scene-text').textContent = s.scene || '…';
94
+ $('think').classList.toggle('on', !!s.thinking);
95
+ $('vp-dot').classList.toggle('live', !!s.live);
96
+ // pupils dilate in the dark (real cat-eye behaviour)
97
+ const light = typeof s.light === 'number' ? s.light : 60;
98
+ dilationGoal = light < 0 ? 1 : light < 22 ? 1.7 : light > 70 ? 0.82 : 1.05;
99
+ const ex = Array.isArray(s.exchange) ? s.exchange : [];
100
+ const key = ex.map((l) => l.who + ':' + l.text).join('|');
101
+ if (key && key !== lastKey) { lastKey = key; curExchange = ex; play(ex); }
102
+ } catch (e) { /* server restarting */ }
103
+ }
104
+ setInterval(poll, 1000);
105
+ poll();
106
+
107
+ // ---------------------------------------------------------------- reach: drag any image to be judged
108
+ const drop = document.body;
109
+ ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => {
110
+ e.preventDefault(); $('dropnote').classList.add('show');
111
+ }));
112
+ ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => {
113
+ e.preventDefault(); if (ev === 'dragleave' && e.relatedTarget) return; $('dropnote').classList.remove('show');
114
+ }));
115
+ drop.addEventListener('drop', async (e) => {
116
+ const file = e.dataTransfer?.files?.[0];
117
+ if (!file || !file.type.startsWith('image/')) return;
118
+ const dataURL = await fileToJpegDataURL(file, 480);
119
+ $('cam-status').textContent = '';
120
+ $('scene-text').textContent = 'the committee is examining your offering…';
121
+ fetch('/api/frame', {
122
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ image: dataURL }),
124
+ }).catch(() => {});
125
+ });
126
+
127
+ function fileToJpegDataURL(file, maxW) {
128
+ return new Promise((resolve) => {
129
+ const img = new Image();
130
+ img.onload = () => {
131
+ const scale = Math.min(1, maxW / img.width);
132
+ const c = document.createElement('canvas');
133
+ c.width = Math.round(img.width * scale);
134
+ c.height = Math.round(img.height * scale);
135
+ c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
136
+ resolve(c.toDataURL('image/jpeg', 0.75));
137
+ };
138
+ img.src = URL.createObjectURL(file);
139
+ });
140
+ }
141
+
142
+ // ---------------------------------------------------------------- reach: share the verdict
143
+ $('share').addEventListener('click', async () => {
144
+ if (!curExchange.length) return;
145
+ const lines = curExchange.map((l) => `${l.who.toUpperCase()}: ${l.text}`).join('\n');
146
+ const caption = `my cats reviewed me:\n\n${lines}\n\n— judged by two AI cats running locally · #BuildSmall`;
147
+ try {
148
+ await navigator.clipboard.writeText(caption);
149
+ flashShare('verdict copied — go post it');
150
+ } catch (e) {
151
+ flashShare('press ⌘C: ' + curExchange.map((l) => l.text).join(' / '));
152
+ }
153
+ });
154
+ function flashShare(msg) {
155
+ const el = $('share'); const old = el.textContent;
156
+ el.textContent = msg; setTimeout(() => { el.textContent = old; }, 1800);
157
+ }
theater/js/main.js ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // main.js — GLORBVISION boot + state polling + HUD wiring (B1).
2
+ import { Theater } from './scene3d.js';
3
+ import { Bubbles } from './bubbles.js';
4
+ import { Capture } from './capture.js';
5
+ import { initThoughts } from './thoughts.js';
6
+ import { initAlienCard } from './aliencard.js';
7
+
8
+ const $ = (id) => document.getElementById(id);
9
+
10
+ initThoughts();
11
+
12
+ const theater = new Theater($('stage'));
13
+ const bubbles = new Bubbles($('bubble-layer'));
14
+
15
+ let lastAliens = []; // latest /api/state aliens, so the card can show a live bubble
16
+ const alienCard = initAlienCard({
17
+ bubbleFor: (id) => (lastAliens.find((a) => a.id === id) || {}).bubble || null,
18
+ });
19
+
20
+ const capture = new Capture({
21
+ onVideo: (video) => theater.setVideo(video),
22
+ onStatus: (msg) => { $('cam-status').textContent = msg; },
23
+ egressDot: $('egress-dot'),
24
+ });
25
+
26
+ // ---------------------------------------------------------------- consent flow
27
+
28
+ $('start-cam').addEventListener('click', async () => {
29
+ const btn = $('start-cam');
30
+ btn.disabled = true;
31
+ btn.textContent = 'NEGOTIATING WITH YOUR BROWSER…';
32
+ const ok = await capture.start();
33
+ $('consent').classList.add('gone');
34
+ $('stop-cam').hidden = !ok;
35
+ btn.disabled = false;
36
+ btn.textContent = 'ALLOW CAMERA — LET THEM WATCH';
37
+ });
38
+
39
+ $('skip-cam').addEventListener('click', () => {
40
+ $('consent').classList.add('gone');
41
+ $('cam-status').textContent = 'no camera — the crowd watches a test pattern.';
42
+ });
43
+
44
+ $('stop-cam').addEventListener('click', () => {
45
+ capture.stop();
46
+ theater.clearVideo();
47
+ $('stop-cam').hidden = true;
48
+ $('cam-status').textContent = 'camera off — the device is released.';
49
+ });
50
+
51
+ // ---------------------------------------------------------------- click: alien card or poke
52
+
53
+ let lastPoke = 0;
54
+ theater.renderer.domElement.addEventListener('click', (e) => {
55
+ const id = theater.pick(e.clientX, e.clientY);
56
+ if (id !== null && id !== undefined) {
57
+ alienCard.open(id); // clicked an alien -> dossier card
58
+ return;
59
+ }
60
+ const now = Date.now(); // empty space -> existing poke behavior
61
+ if (now - lastPoke < 800) return;
62
+ lastPoke = now;
63
+ fetch('/api/poke', {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: '{}',
67
+ }).catch(() => {});
68
+ });
69
+
70
+ // ---------------------------------------------------------------- HUD + polling
71
+
72
+ const EVENT_LABELS = {
73
+ new_scene: 'NEW SCENE',
74
+ noticed: 'IT SEES US',
75
+ chant: 'THE CHANT RISES',
76
+ gasp: 'COLLECTIVE GASP',
77
+ ovation: 'STANDING OVATION',
78
+ wave: 'STADIUM WAVE',
79
+ boo: 'BOOOOO',
80
+ intermission: 'INTERMISSION',
81
+ };
82
+
83
+ let prevEventField = null;
84
+ let splashEl = null;
85
+
86
+ function splash(text) {
87
+ if (splashEl) splashEl.remove();
88
+ splashEl = document.createElement('div');
89
+ splashEl.id = 'event-label';
90
+ splashEl.textContent = text;
91
+ document.body.appendChild(splashEl);
92
+ const el = splashEl;
93
+ setTimeout(() => { if (el.isConnected) el.remove(); }, 1700);
94
+ }
95
+
96
+ function updateHud(s) {
97
+ $('scene-text').textContent = s.scene || 'awaiting first transmission from Earth…';
98
+ if (s.factions) {
99
+ $('f-love').textContent = s.factions.love ?? 0;
100
+ $('f-meh').textContent = s.factions.meh ?? 0;
101
+ $('f-hate').textContent = s.factions.hate ?? 0;
102
+ }
103
+ const pill = $('live-pill');
104
+ pill.textContent = s.live ? 'LIVE' : 'NO SIGNAL';
105
+ pill.classList.toggle('on', !!s.live);
106
+ $('thinking').hidden = !s.thinking;
107
+
108
+ if (s.event !== prevEventField) {
109
+ if (s.event && EVENT_LABELS[s.event]) splash(EVENT_LABELS[s.event]);
110
+ prevEventField = s.event;
111
+ }
112
+
113
+ alienCard.setRumor(s.rumor); // RUMOR MILL line under the factions HUD
114
+ }
115
+
116
+ let offlineSince = 0;
117
+
118
+ async function poll() {
119
+ try {
120
+ const res = await fetch('/api/state');
121
+ if (!res.ok) throw new Error('bad status');
122
+ const state = await res.json();
123
+ offlineSince = 0;
124
+ lastAliens = Array.isArray(state.aliens) ? state.aliens : [];
125
+ theater.applyState(state);
126
+ bubbles.applyState(state.aliens);
127
+ updateHud(state);
128
+ } catch (err) {
129
+ if (!offlineSince) offlineSince = Date.now();
130
+ if (Date.now() - offlineSince > 2500) {
131
+ const pill = $('live-pill');
132
+ pill.textContent = 'SERVER OFFLINE';
133
+ pill.classList.remove('on');
134
+ }
135
+ }
136
+ }
137
+
138
+ setInterval(poll, 500);
139
+ poll();
140
+
141
+ // bubbles track their aliens every animation frame so they ride the bob/wave
142
+ (function loop() {
143
+ bubbles.reposition(theater);
144
+ requestAnimationFrame(loop);
145
+ })();
theater/js/scene3d.js ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // scene3d.js — GLORBVISION amphitheater (B1 frontend).
2
+ // 120 sprite aliens in 8 curved rows x 15 seats facing a giant webcam screen.
3
+ // Sprite sheets come from /theater/assets/aliens/<archetype>.png (12 frames x 32px);
4
+ // when a sheet is missing we draw a placeholder blob sheet on a canvas so the
5
+ // theater always renders.
6
+ import * as THREE from 'three';
7
+
8
+ export const MOODS = ['calm', 'excited', 'annoyed', 'scared', 'delighted', 'confused'];
9
+ const MOOD_INDEX = Object.fromEntries(MOODS.map((m, i) => [m, i]));
10
+ const MOOD_SCALE = {
11
+ calm: { x: 1.00, y: 1.00 },
12
+ excited: { x: 0.95, y: 1.20 },
13
+ annoyed: { x: 1.05, y: 0.92 },
14
+ scared: { x: 1.30, y: 0.60 },
15
+ delighted: { x: 1.00, y: 1.10 },
16
+ confused: { x: 1.00, y: 1.00 },
17
+ };
18
+ const FACTION_COLORS = {
19
+ love: new THREE.Color('#ff6fa5'),
20
+ meh: new THREE.Color('#3e5c66'),
21
+ hate: new THREE.Color('#ff3b30'),
22
+ };
23
+ const CONFETTI_PALETTE = ['#ff6fa5', '#58e6e6', '#ffd35a', '#9dff5a', '#ff5ad6'];
24
+ const EVENT_DURATION = {
25
+ new_scene: 2, noticed: 4, chant: 6, gasp: 4,
26
+ ovation: 6, wave: 5, boo: 4, intermission: Infinity,
27
+ };
28
+
29
+ const SCREEN_C = new THREE.Vector3(0, 3.4, -10);
30
+ const SCREEN_H = 4.6; // screen plane height (world units)
31
+ const SPRITE_SIZE = 1.05;
32
+ const ROWS = 8, SEATS = 15;
33
+
34
+ function seatPosition(row, seat) {
35
+ const r = 5.5 + row * 1.35;
36
+ const theta = (seat / (SEATS - 1) - 0.5) * (140 * Math.PI / 180);
37
+ return new THREE.Vector3(
38
+ SCREEN_C.x + Math.sin(theta) * r,
39
+ 0.45 + row * 0.55,
40
+ SCREEN_C.z + Math.cos(theta) * r,
41
+ );
42
+ }
43
+
44
+ function pixelTex(tex) {
45
+ tex.magFilter = THREE.NearestFilter;
46
+ tex.minFilter = THREE.NearestFilter;
47
+ tex.generateMipmaps = false;
48
+ tex.colorSpace = THREE.SRGBColorSpace;
49
+ return tex;
50
+ }
51
+
52
+ function frameClone(base) {
53
+ const t = base.clone();
54
+ t.needsUpdate = true;
55
+ t.repeat.set(1 / 12, 1);
56
+ t.offset.set(0, 0);
57
+ return t;
58
+ }
59
+
60
+ function shadeHex(hex, f) {
61
+ const n = parseInt(String(hex).replace('#', ''), 16);
62
+ const r = Math.round(((n >> 16) & 255) * f);
63
+ const g = Math.round(((n >> 8) & 255) * f);
64
+ const b = Math.round((n & 255) * f);
65
+ return `rgb(${r},${g},${b})`;
66
+ }
67
+
68
+ // ---------------------------------------------------------------- placeholder art
69
+
70
+ function drawEye(ctx, mood, ex, ey, dark) {
71
+ const white = '#ffffff', pupil = '#1a0f30';
72
+ const disc = (x, y, r, col) => {
73
+ ctx.fillStyle = col;
74
+ ctx.beginPath();
75
+ ctx.arc(x, y, r, 0, Math.PI * 2);
76
+ ctx.fill();
77
+ };
78
+ switch (mood) {
79
+ case 'calm':
80
+ disc(ex, ey, 6, white); disc(ex, ey + 1, 2, pupil);
81
+ ctx.fillStyle = dark; ctx.fillRect(ex - 6, ey - 7, 12, 6);
82
+ break;
83
+ case 'excited':
84
+ disc(ex, ey, 7, white); disc(ex, ey, 3, pupil);
85
+ break;
86
+ case 'annoyed':
87
+ ctx.fillStyle = white; ctx.fillRect(ex - 6, ey - 2, 12, 4);
88
+ ctx.fillStyle = pupil; ctx.fillRect(ex - 6, ey - 2, 12, 2);
89
+ break;
90
+ case 'scared':
91
+ disc(ex, ey, 7, white); disc(ex, ey, 1.3, pupil);
92
+ break;
93
+ case 'delighted':
94
+ disc(ex, ey, 7, white);
95
+ ctx.fillStyle = pupil;
96
+ ctx.fillRect(ex - 1, ey - 4, 2, 8);
97
+ ctx.fillRect(ex - 4, ey - 1, 8, 2);
98
+ break;
99
+ case 'confused':
100
+ disc(ex, ey, 7, white);
101
+ ctx.strokeStyle = pupil; ctx.lineWidth = 1.5;
102
+ ctx.beginPath();
103
+ ctx.arc(ex, ey, 3.4, 0, Math.PI * 1.5);
104
+ ctx.arc(ex, ey, 1.6, Math.PI * 1.5, Math.PI * 3);
105
+ ctx.stroke();
106
+ break;
107
+ }
108
+ }
109
+
110
+ function makePlaceholderSheet(colorHex) {
111
+ const c = document.createElement('canvas');
112
+ c.width = 384; c.height = 32;
113
+ const ctx = c.getContext('2d');
114
+ const dark = shadeHex(colorHex, 0.45);
115
+ for (let m = 0; m < 6; m++) {
116
+ for (let p = 0; p < 2; p++) {
117
+ const ox = (m * 2 + p) * 32;
118
+ const mood = MOODS[m];
119
+ let w = 24, h = 18;
120
+ if (mood === 'excited') h = 22;
121
+ if (mood === 'scared') { w = 31; h = 11; }
122
+ const bob = p === 1 ? (mood === 'excited' ? -2 : 1) : 0;
123
+ const bx = Math.round(ox + 16 - w / 2);
124
+ const by = 30 - h + bob;
125
+ ctx.fillStyle = dark;
126
+ ctx.fillRect(bx - 1, by - 1, w + 2, h + 2);
127
+ ctx.fillStyle = colorHex;
128
+ ctx.fillRect(bx, by, w, h);
129
+ // antennae
130
+ ctx.fillStyle = dark;
131
+ ctx.fillRect(ox + 11, by - 4, 1, 4);
132
+ ctx.fillRect(ox + 20, by - 4, 1, 4);
133
+ ctx.fillRect(ox + 10, by - 6, 2, 2);
134
+ ctx.fillRect(ox + 19, by - 6, 2, 2);
135
+ drawEye(ctx, mood, ox + 16, by + Math.round(h * 0.45), dark);
136
+ }
137
+ }
138
+ return pixelTex(new THREE.CanvasTexture(c));
139
+ }
140
+
141
+ function makeScreenPlaceholder() {
142
+ const c = document.createElement('canvas');
143
+ c.width = 512; c.height = 384;
144
+ const ctx = c.getContext('2d');
145
+ ctx.fillStyle = '#07030f';
146
+ ctx.fillRect(0, 0, 512, 384);
147
+ ctx.fillStyle = 'rgba(88,230,230,0.10)';
148
+ for (let i = 0; i < 700; i++) {
149
+ ctx.fillRect(Math.random() * 512, Math.random() * 384, 2, 2);
150
+ }
151
+ ctx.fillStyle = '#58e6e6';
152
+ ctx.font = 'bold 34px monospace';
153
+ ctx.textAlign = 'center';
154
+ ctx.fillText('AWAITING EARTH FEED', 256, 180);
155
+ ctx.font = '18px monospace';
156
+ ctx.fillStyle = '#ffd35a';
157
+ ctx.fillText('the Blorps grow restless', 256, 218);
158
+ return pixelTex(new THREE.CanvasTexture(c));
159
+ }
160
+
161
+ function makeScanlineTex() {
162
+ const c = document.createElement('canvas');
163
+ c.width = 1; c.height = 2;
164
+ const ctx = c.getContext('2d');
165
+ ctx.clearRect(0, 0, 1, 2);
166
+ ctx.fillStyle = 'rgba(0,0,0,0.85)';
167
+ ctx.fillRect(0, 1, 1, 1);
168
+ const tex = new THREE.CanvasTexture(c);
169
+ tex.magFilter = THREE.NearestFilter;
170
+ tex.minFilter = THREE.NearestFilter;
171
+ tex.wrapS = THREE.RepeatWrapping;
172
+ tex.wrapT = THREE.RepeatWrapping;
173
+ tex.repeat.set(1, 150);
174
+ return tex;
175
+ }
176
+
177
+ function makeGlowTex() {
178
+ const c = document.createElement('canvas');
179
+ c.width = 32; c.height = 32;
180
+ const ctx = c.getContext('2d');
181
+ const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 15);
182
+ g.addColorStop(0, 'rgba(255,255,255,0.9)');
183
+ g.addColorStop(0.55, 'rgba(255,255,255,0.35)');
184
+ g.addColorStop(1, 'rgba(255,255,255,0)');
185
+ ctx.fillStyle = g;
186
+ ctx.fillRect(0, 0, 32, 32);
187
+ return new THREE.CanvasTexture(c);
188
+ }
189
+
190
+ function makeRingTex() {
191
+ const c = document.createElement('canvas');
192
+ c.width = 64; c.height = 64;
193
+ const ctx = c.getContext('2d');
194
+ ctx.strokeStyle = 'rgba(255,255,255,1)';
195
+ ctx.lineWidth = 5;
196
+ ctx.beginPath();
197
+ ctx.arc(32, 32, 27, 0, Math.PI * 2);
198
+ ctx.stroke();
199
+ return new THREE.CanvasTexture(c);
200
+ }
201
+
202
+ function makeFlameSheet() {
203
+ const c = document.createElement('canvas');
204
+ c.width = 64; c.height = 32;
205
+ const ctx = c.getContext('2d');
206
+ for (let f = 0; f < 4; f++) {
207
+ const ox = f * 16;
208
+ const sway = [0, 1.5, -1, 0.5][f];
209
+ ctx.fillStyle = '#ffb84d';
210
+ ctx.beginPath();
211
+ ctx.ellipse(ox + 8 + sway, 18, 5, 11 + f % 2, 0, 0, Math.PI * 2);
212
+ ctx.fill();
213
+ ctx.fillStyle = '#ffd35a';
214
+ ctx.beginPath();
215
+ ctx.ellipse(ox + 8 + sway * 0.6, 22, 3, 6, 0, 0, Math.PI * 2);
216
+ ctx.fill();
217
+ ctx.fillStyle = '#fff7d0';
218
+ ctx.beginPath();
219
+ ctx.ellipse(ox + 8 + sway * 0.3, 25, 1.5, 3, 0, 0, Math.PI * 2);
220
+ ctx.fill();
221
+ }
222
+ const tex = pixelTex(new THREE.CanvasTexture(c));
223
+ tex.repeat.set(1 / 4, 1);
224
+ return tex;
225
+ }
226
+
227
+ // ---------------------------------------------------------------- theater
228
+
229
+ export class Theater {
230
+ constructor(container) {
231
+ this.renderer = new THREE.WebGLRenderer({ antialias: false });
232
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
233
+ container.appendChild(this.renderer.domElement);
234
+
235
+ this.scene = new THREE.Scene();
236
+ this.scene.background = new THREE.Color('#100822');
237
+ this.scene.fog = new THREE.Fog(0x100822, 20, 44);
238
+
239
+ this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 120);
240
+ this.cameraBase = new THREE.Vector3(0, 8.8, 10.8);
241
+ this.camera.position.copy(this.cameraBase);
242
+ this.camera.lookAt(SCREEN_C);
243
+
244
+ this.ambient = new THREE.AmbientLight(0xffffff, 0.6);
245
+ this.ambientBaseColor = new THREE.Color(0xb9aaff);
246
+ this.ambient.color.copy(this.ambientBaseColor);
247
+ this.scene.add(this.ambient);
248
+
249
+ this.screenLight = new THREE.PointLight(0x58e6e6, 16, 34, 1.6);
250
+ this.screenLight.position.set(0, 4.2, -7.5);
251
+ this.scene.add(this.screenLight);
252
+
253
+ // ---- shared state
254
+ this.aliens = new Map(); // id -> alien record
255
+ this.prevEventField = undefined;
256
+ this.event = null;
257
+ this.eventStart = 0;
258
+ this.light = -1;
259
+ this.sound = -1;
260
+ this.live = false;
261
+ this.lightFactor = 1;
262
+ this.videoTex = null;
263
+ this.mirror = false;
264
+ this.screenAspect = 4 / 3;
265
+ this.lastNow = performance.now() / 1000;
266
+ this.lastStatic = 0;
267
+ this.lastChyron = 0;
268
+ this._v = new THREE.Vector3();
269
+ this._tileTarget = new THREE.Color();
270
+ this._raycaster = new THREE.Raycaster();
271
+ this._pickNDC = new THREE.Vector2();
272
+ this._pickSprites = [];
273
+
274
+ this._buildGround();
275
+ this._buildScreen();
276
+ this._buildTorches();
277
+ this._buildChantRing();
278
+ this._buildConfetti();
279
+
280
+ window.addEventListener('resize', () => this._resize());
281
+ this._resize();
282
+ this.renderer.setAnimationLoop(() => this._render());
283
+ }
284
+
285
+ // ------------------------------------------------------------- construction
286
+
287
+ _buildGround() {
288
+ const ground = new THREE.Mesh(
289
+ new THREE.CircleGeometry(30, 40),
290
+ new THREE.MeshLambertMaterial({ color: 0x1a0f30 }),
291
+ );
292
+ ground.rotation.x = -Math.PI / 2;
293
+ ground.position.y = -0.01;
294
+ this.scene.add(ground);
295
+
296
+ // a low stage apron in front of the screen
297
+ const stage = new THREE.Mesh(
298
+ new THREE.BoxGeometry(11.5, 0.5, 3),
299
+ new THREE.MeshLambertMaterial({ color: 0x2a1a4a }),
300
+ );
301
+ stage.position.set(SCREEN_C.x, 0.25, SCREEN_C.z + 1.2);
302
+ this.scene.add(stage);
303
+ }
304
+
305
+ _buildScreen() {
306
+ // black backing behind whatever aspect the feed has
307
+ const backing = new THREE.Mesh(
308
+ new THREE.PlaneGeometry(SCREEN_H * (16 / 9) + 0.2, SCREEN_H + 0.2),
309
+ new THREE.MeshBasicMaterial({ color: 0x000000 }),
310
+ );
311
+ backing.position.copy(SCREEN_C);
312
+ backing.position.z -= 0.03;
313
+ this.scene.add(backing);
314
+
315
+ this.placeholderTex = makeScreenPlaceholder();
316
+ this.screenMat = new THREE.MeshBasicMaterial({ map: this.placeholderTex });
317
+ this.screenMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), this.screenMat);
318
+ this.screenMesh.position.copy(SCREEN_C);
319
+ this._fitScreen();
320
+ this.scene.add(this.screenMesh);
321
+
322
+ // scanline overlay
323
+ const slMat = new THREE.MeshBasicMaterial({
324
+ map: makeScanlineTex(), transparent: true, opacity: 0.22, depthWrite: false,
325
+ });
326
+ const sl = new THREE.Mesh(
327
+ new THREE.PlaneGeometry(SCREEN_H * (16 / 9) + 0.2, SCREEN_H + 0.2), slMat,
328
+ );
329
+ sl.position.copy(SCREEN_C);
330
+ sl.position.z += 0.02;
331
+ this.scene.add(sl);
332
+
333
+ // intermission static overlay
334
+ this.staticCanvas = document.createElement('canvas');
335
+ this.staticCanvas.width = 128; this.staticCanvas.height = 96;
336
+ this.staticCtx = this.staticCanvas.getContext('2d');
337
+ this.staticTex = pixelTex(new THREE.CanvasTexture(this.staticCanvas));
338
+ this.staticMesh = new THREE.Mesh(
339
+ new THREE.PlaneGeometry(SCREEN_H * (16 / 9) + 0.2, SCREEN_H + 0.2),
340
+ new THREE.MeshBasicMaterial({ map: this.staticTex }),
341
+ );
342
+ this.staticMesh.position.copy(SCREEN_C);
343
+ this.staticMesh.position.z += 0.04;
344
+ this.staticMesh.visible = false;
345
+ this.scene.add(this.staticMesh);
346
+
347
+ // stone frame (emissive flashes on new_scene)
348
+ this.frameMat = new THREE.MeshLambertMaterial({
349
+ color: 0x2a1a4a, emissive: 0x58e6e6, emissiveIntensity: 0,
350
+ });
351
+ const fw = SCREEN_H * (16 / 9) + 1.0;
352
+ const fh = SCREEN_H + 1.0;
353
+ const t = 0.45, d = 0.5;
354
+ const mk = (w, h, x, y) => {
355
+ const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), this.frameMat);
356
+ m.position.set(SCREEN_C.x + x, SCREEN_C.y + y, SCREEN_C.z - 0.1);
357
+ this.scene.add(m);
358
+ };
359
+ mk(fw, t, 0, fh / 2 - t / 2);
360
+ mk(fw, t, 0, -fh / 2 + t / 2);
361
+ mk(t, fh, fw / 2 - t / 2, 0);
362
+ mk(t, fh, -fw / 2 + t / 2, 0);
363
+ // legs
364
+ const legMat = new THREE.MeshLambertMaterial({ color: 0x2a1a4a });
365
+ for (const sx of [-1, 1]) {
366
+ const leg = new THREE.Mesh(new THREE.BoxGeometry(0.5, SCREEN_C.y, 0.5), legMat);
367
+ leg.position.set(sx * (fw / 2 - 0.3), SCREEN_C.y / 2 - fh / 2 + 0.2, SCREEN_C.z - 0.1);
368
+ this.scene.add(leg);
369
+ }
370
+
371
+ // chyron strip under the screen
372
+ this.chyronCanvas = document.createElement('canvas');
373
+ this.chyronCanvas.width = 1024; this.chyronCanvas.height = 56;
374
+ this.chyronTex = pixelTex(new THREE.CanvasTexture(this.chyronCanvas));
375
+ const chyron = new THREE.Mesh(
376
+ new THREE.PlaneGeometry(fw, fw * 56 / 1024),
377
+ new THREE.MeshBasicMaterial({ map: this.chyronTex }),
378
+ );
379
+ chyron.position.set(SCREEN_C.x, SCREEN_C.y - fh / 2 - 0.35, SCREEN_C.z + 0.05);
380
+ this.scene.add(chyron);
381
+ this._drawChyron();
382
+ }
383
+
384
+ _fitScreen() {
385
+ const a = Math.min(this.screenAspect, 16 / 9);
386
+ this.screenMesh.scale.set((this.mirror ? -1 : 1) * SCREEN_H * a, SCREEN_H, 1);
387
+ }
388
+
389
+ _drawChyron() {
390
+ const ctx = this.chyronCanvas.getContext('2d');
391
+ ctx.fillStyle = '#07030f';
392
+ ctx.fillRect(0, 0, 1024, 56);
393
+ ctx.fillStyle = '#58e6e6';
394
+ ctx.fillRect(0, 0, 1024, 3);
395
+ ctx.font = 'bold 30px monospace';
396
+ ctx.textBaseline = 'middle';
397
+ ctx.fillStyle = '#ffd35a';
398
+ ctx.fillText('EARTH FEED — CYCLE 4,071 —', 24, 30);
399
+ ctx.fillStyle = this.live ? '#ff3b30' : '#3e5c66';
400
+ ctx.beginPath(); ctx.arc(540, 28, 9, 0, Math.PI * 2); ctx.fill();
401
+ ctx.fillStyle = this.live ? '#ffffff' : '#8fb3bd';
402
+ ctx.fillText(this.live ? 'LIVE' : 'NO SIGNAL', 558, 30);
403
+ this.chyronTex.needsUpdate = true;
404
+ }
405
+
406
+ _buildTorches() {
407
+ this.flameSheet = makeFlameSheet();
408
+ this.torches = [];
409
+ const fw = SCREEN_H * (16 / 9) + 1.0;
410
+ for (const sx of [-1, 1]) {
411
+ const tex = this.flameSheet.clone();
412
+ tex.needsUpdate = true;
413
+ tex.repeat.set(1 / 4, 1);
414
+ const spr = new THREE.Sprite(new THREE.SpriteMaterial({
415
+ map: tex, transparent: true, depthWrite: false,
416
+ }));
417
+ spr.position.set(sx * (fw / 2 + 1.1), 2.6, SCREEN_C.z + 0.4);
418
+ spr.scale.set(1.0, 2.0, 1);
419
+ this.scene.add(spr);
420
+ const pole = new THREE.Mesh(
421
+ new THREE.BoxGeometry(0.22, 2.4, 0.22),
422
+ new THREE.MeshLambertMaterial({ color: 0x2a1a4a }),
423
+ );
424
+ pole.position.set(spr.position.x, 1.0, spr.position.z);
425
+ this.scene.add(pole);
426
+ const light = new THREE.PointLight(0xffb84d, 4, 12, 1.8);
427
+ light.position.copy(spr.position);
428
+ this.scene.add(light);
429
+ this.torches.push({ spr, tex, light, phase: sx * 1.7 });
430
+ }
431
+ }
432
+
433
+ _buildChantRing() {
434
+ this.ringMat = new THREE.MeshBasicMaterial({
435
+ map: makeRingTex(), color: 0xffd35a, transparent: true, opacity: 0,
436
+ blending: THREE.AdditiveBlending, depthWrite: false,
437
+ });
438
+ this.ring = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), this.ringMat);
439
+ this.ring.rotation.x = -Math.PI / 2;
440
+ const front = seatPosition(0, 7);
441
+ this.ring.position.set(front.x, 0.12, front.z);
442
+ this.ring.visible = false;
443
+ this.scene.add(this.ring);
444
+ }
445
+
446
+ _buildConfetti() {
447
+ this.confetti = new THREE.Group();
448
+ this.confettiBits = [];
449
+ const c = document.createElement('canvas');
450
+ c.width = 8; c.height = 8;
451
+ const cctx = c.getContext('2d');
452
+ cctx.fillStyle = '#ffffff';
453
+ cctx.fillRect(0, 0, 8, 8);
454
+ const tex = pixelTex(new THREE.CanvasTexture(c));
455
+ for (let i = 0; i < 110; i++) {
456
+ const mat = new THREE.SpriteMaterial({
457
+ map: tex, color: CONFETTI_PALETTE[i % CONFETTI_PALETTE.length],
458
+ transparent: true, depthWrite: false,
459
+ });
460
+ const s = new THREE.Sprite(mat);
461
+ const bit = {
462
+ s,
463
+ x: (Math.random() - 0.5) * 18,
464
+ z: -8 + Math.random() * 12,
465
+ speed: 1.6 + Math.random() * 2.2,
466
+ phase: Math.random() * Math.PI * 2,
467
+ y: 6 + Math.random() * 7,
468
+ };
469
+ s.scale.set(0.14, 0.14, 1);
470
+ s.position.set(bit.x, bit.y, bit.z);
471
+ this.confetti.add(s);
472
+ this.confettiBits.push(bit);
473
+ }
474
+ this.confetti.visible = false;
475
+ this.scene.add(this.confetti);
476
+ }
477
+
478
+ // ------------------------------------------------------------- aliens
479
+
480
+ _ensureAliens(list) {
481
+ if (this.aliens.size || !Array.isArray(list) || !list.length) return;
482
+ const loader = new THREE.TextureLoader();
483
+ const glowTex = makeGlowTex();
484
+ const tileGeom = new THREE.PlaneGeometry(0.95, 0.95);
485
+ const pedestalGeom = new THREE.BoxGeometry(1, 1, 1);
486
+ const pedestalMat = new THREE.MeshLambertMaterial({ color: 0x2a1a4a });
487
+
488
+ // group archetypes so each sheet loads once
489
+ const byArch = new Map();
490
+ for (const a of list) {
491
+ if (!byArch.has(a.archetype)) byArch.set(a.archetype, { color: a.color || '#9dff5a', ids: [] });
492
+ byArch.get(a.archetype).ids.push(a.id);
493
+ }
494
+ const placeholders = new Map();
495
+ for (const [arch, info] of byArch) {
496
+ placeholders.set(arch, makePlaceholderSheet(info.color));
497
+ }
498
+
499
+ for (const a of list) {
500
+ const pos = seatPosition(a.row, a.seat);
501
+ const baseY = pos.y + 0.55;
502
+
503
+ const pedestal = new THREE.Mesh(pedestalGeom, pedestalMat);
504
+ pedestal.scale.set(1.04, pos.y, 1.04);
505
+ pedestal.position.set(pos.x, pos.y / 2, pos.z);
506
+ pedestal.rotation.y = Math.atan2(SCREEN_C.x - pos.x, SCREEN_C.z - pos.z);
507
+ this.scene.add(pedestal);
508
+
509
+ const tileMat = new THREE.MeshBasicMaterial({
510
+ map: glowTex, color: FACTION_COLORS.meh.clone(), transparent: true,
511
+ opacity: 0.3, blending: THREE.AdditiveBlending, depthWrite: false,
512
+ });
513
+ const tile = new THREE.Mesh(tileGeom, tileMat);
514
+ tile.rotation.x = -Math.PI / 2;
515
+ tile.position.set(pos.x, pos.y + 0.02, pos.z);
516
+ this.scene.add(tile);
517
+
518
+ const map = frameClone(placeholders.get(a.archetype));
519
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
520
+ map, transparent: true, alphaTest: 0.05, depthWrite: false,
521
+ }));
522
+ sprite.position.set(pos.x, baseY, pos.z);
523
+ sprite.scale.set(SPRITE_SIZE, SPRITE_SIZE, 1);
524
+ sprite.userData.alienId = a.id;
525
+ this.scene.add(sprite);
526
+ this._pickSprites.push(sprite);
527
+
528
+ this.aliens.set(a.id, {
529
+ id: a.id, row: a.row, seat: a.seat, archetype: a.archetype,
530
+ sprite, tileMat,
531
+ baseY,
532
+ phase: ((a.id * 2654435761) % 997) / 997,
533
+ mood: a.mood || 'calm',
534
+ sentiment: a.sentiment || 0,
535
+ frame: -1,
536
+ sx: 1, sy: 1,
537
+ });
538
+ }
539
+
540
+ // try to swap in the real sheets (B5's PNGs); keep placeholders on 404
541
+ for (const [arch, info] of byArch) {
542
+ loader.load(
543
+ `/theater/assets/aliens/${arch}.png`,
544
+ (tex) => {
545
+ pixelTex(tex);
546
+ for (const id of info.ids) {
547
+ const al = this.aliens.get(id);
548
+ if (!al) continue;
549
+ al.sprite.material.map = frameClone(tex);
550
+ al.sprite.material.needsUpdate = true;
551
+ al.frame = -1; // force offset refresh
552
+ }
553
+ },
554
+ undefined,
555
+ () => { /* sheet missing — placeholder blobs stay */ },
556
+ );
557
+ }
558
+ }
559
+
560
+ // ------------------------------------------------------------- state from server
561
+
562
+ applyState(state) {
563
+ if (!state || !Array.isArray(state.aliens)) return;
564
+ this._ensureAliens(state.aliens);
565
+ for (const a of state.aliens) {
566
+ const al = this.aliens.get(a.id);
567
+ if (!al) continue;
568
+ al.mood = MOOD_INDEX[a.mood] !== undefined ? a.mood : 'calm';
569
+ al.sentiment = typeof a.sentiment === 'number' ? a.sentiment : 0;
570
+ }
571
+ this.light = typeof state.light === 'number' ? state.light : -1;
572
+ this.sound = typeof state.sound === 'number' ? state.sound : -1;
573
+ const liveChanged = this.live !== !!state.live;
574
+ this.live = !!state.live;
575
+ if (liveChanged) this._drawChyron();
576
+
577
+ if (state.event !== this.prevEventField) {
578
+ this.prevEventField = state.event;
579
+ if (state.event && EVENT_DURATION[state.event] !== undefined) {
580
+ this.event = state.event;
581
+ this.eventStart = performance.now() / 1000;
582
+ }
583
+ }
584
+ // intermission lasts only while the server says so
585
+ if (this.event === 'intermission' && state.event !== 'intermission') {
586
+ this.event = null;
587
+ }
588
+ }
589
+
590
+ setVideo(video) {
591
+ if (this.videoTex) this.clearVideo();
592
+ this.videoTex = new THREE.VideoTexture(video); // auto-updates; never set needsUpdate
593
+ this.videoTex.colorSpace = THREE.SRGBColorSpace;
594
+ this.screenAspect = (video.videoWidth / video.videoHeight) || 4 / 3;
595
+ this.mirror = true; // selfie mirror
596
+ this.screenMat.map = this.videoTex;
597
+ this.screenMat.needsUpdate = true;
598
+ this._fitScreen();
599
+ }
600
+
601
+ clearVideo() {
602
+ if (this.videoTex) { this.videoTex.dispose(); this.videoTex = null; }
603
+ this.screenMat.map = this.placeholderTex;
604
+ this.screenMat.needsUpdate = true;
605
+ this.mirror = false;
606
+ this.screenAspect = 4 / 3;
607
+ this._fitScreen();
608
+ }
609
+
610
+ /** Alien id under client pixel (x,y) via raycast over the crowd sprites,
611
+ * or null when the click hit empty space (canvas fills the window, so
612
+ * client coords map straight to NDC). */
613
+ pick(x, y) {
614
+ if (!this._pickSprites.length) return null;
615
+ this._pickNDC.set(
616
+ (x / window.innerWidth) * 2 - 1,
617
+ -(y / window.innerHeight) * 2 + 1,
618
+ );
619
+ this._raycaster.setFromCamera(this._pickNDC, this.camera);
620
+ const hits = this._raycaster.intersectObjects(this._pickSprites, false);
621
+ for (const hit of hits) {
622
+ const id = hit.object.userData.alienId;
623
+ if (id !== undefined) return id;
624
+ }
625
+ return null;
626
+ }
627
+
628
+ /** Screen-space px position above an alien's head, or null when behind camera. */
629
+ projectAlien(id) {
630
+ const al = this.aliens.get(id);
631
+ if (!al) return null;
632
+ this._v.copy(al.sprite.position);
633
+ this._v.y += 0.78;
634
+ this._v.project(this.camera);
635
+ if (this._v.z > 1) return null;
636
+ return {
637
+ x: (this._v.x * 0.5 + 0.5) * window.innerWidth,
638
+ y: (-this._v.y * 0.5 + 0.5) * window.innerHeight,
639
+ };
640
+ }
641
+
642
+ // ------------------------------------------------------------- per-frame
643
+
644
+ _resize() {
645
+ const w = window.innerWidth, h = window.innerHeight;
646
+ this.renderer.setSize(w, h);
647
+ this.camera.aspect = w / h;
648
+ this.camera.updateProjectionMatrix();
649
+ }
650
+
651
+ _render() {
652
+ const now = performance.now() / 1000;
653
+ const dt = Math.min(now - this.lastNow, 0.1);
654
+ this.lastNow = now;
655
+
656
+ // expire event
657
+ let ev = this.event;
658
+ let et = now - this.eventStart;
659
+ if (ev && et > (EVENT_DURATION[ev] ?? 4)) { this.event = null; ev = null; }
660
+
661
+ // --- lighting
662
+ const targetFactor = ev === 'gasp' ? 0.1 : ev === 'boo' ? 0.45 : 1;
663
+ this.lightFactor += (targetFactor - this.lightFactor) * Math.min(1, dt * 6);
664
+ const lightVal = this.light < 0 ? 60 : this.light;
665
+ const base = 0.25 + 0.6 * (lightVal / 100);
666
+ this.ambient.intensity = base * this.lightFactor * 1.4;
667
+ this.ambient.color.copy(this.ambientBaseColor);
668
+ if (ev === 'boo') this.ambient.color.lerp(new THREE.Color(0xff3b30), 0.6);
669
+ this.screenLight.intensity = 16 * (0.4 + 0.6 * this.lightFactor);
670
+
671
+ // --- camera idle drift (±2°)
672
+ const drift = Math.sin(now * 0.13) * 0.035;
673
+ this.camera.position.set(
674
+ this.cameraBase.x + Math.sin(drift) * 14,
675
+ this.cameraBase.y + Math.sin(now * 0.09) * 0.25,
676
+ this.cameraBase.z,
677
+ );
678
+ this.camera.lookAt(SCREEN_C.x, SCREEN_C.y - 0.4, SCREEN_C.z);
679
+
680
+ // --- screen frame flash on new_scene
681
+ if (ev === 'new_scene') {
682
+ const k = Math.max(0, 1 - et / 2);
683
+ this.frameMat.emissiveIntensity = k * (0.55 + 0.45 * Math.sin(et * 22));
684
+ } else {
685
+ this.frameMat.emissiveIntensity *= Math.max(0, 1 - dt * 5);
686
+ }
687
+
688
+ // --- intermission static
689
+ this.staticMesh.visible = ev === 'intermission';
690
+ if (this.staticMesh.visible && now - this.lastStatic > 0.1) {
691
+ this.lastStatic = now;
692
+ const img = this.staticCtx.createImageData(128, 96);
693
+ for (let i = 0; i < img.data.length; i += 4) {
694
+ const v = Math.random() * 255;
695
+ img.data[i] = v * 0.7; img.data[i + 1] = v * 0.65; img.data[i + 2] = v;
696
+ img.data[i + 3] = 255;
697
+ }
698
+ this.staticCtx.putImageData(img, 0, 0);
699
+ this.staticTex.needsUpdate = true;
700
+ }
701
+
702
+ // --- chant pulse ring
703
+ if (ev === 'chant') {
704
+ this.ring.visible = true;
705
+ const cyc = (et % 2) / 2;
706
+ const s = 1 + cyc * 30;
707
+ this.ring.scale.set(s, s, 1);
708
+ this.ringMat.opacity = (1 - cyc) * 0.85;
709
+ } else {
710
+ this.ring.visible = false;
711
+ }
712
+
713
+ // --- confetti (ovation)
714
+ this.confetti.visible = ev === 'ovation';
715
+ if (this.confetti.visible) {
716
+ for (const b of this.confettiBits) {
717
+ b.y -= b.speed * dt;
718
+ if (b.y < 0.2) b.y = 6 + Math.random() * 7;
719
+ b.s.position.set(b.x + Math.sin(now * 3 + b.phase) * 0.4, b.y, b.z);
720
+ }
721
+ }
722
+
723
+ // --- torches (flicker amplitude from sound)
724
+ const soundVal = this.sound < 0 ? 20 : this.sound;
725
+ const amp = soundVal / 100;
726
+ for (const t of this.torches) {
727
+ const fps = 5 + amp * 9;
728
+ const fr = Math.floor(now * fps + t.phase * 3) % 4;
729
+ t.tex.offset.x = fr / 4;
730
+ t.spr.scale.y = 2.0 * (1 + 0.18 * amp * Math.sin(now * 15 + t.phase * 7));
731
+ t.light.intensity = (3.2 + Math.sin(now * 12 + t.phase * 5) * 2.6 * amp) * this.lightFactor;
732
+ }
733
+
734
+ // --- crowd
735
+ const freeze = ev === 'noticed' && et < 2;
736
+ const eyesWideMood = ev === 'gasp' ? 'excited' : freeze ? 'scared' : null;
737
+ const chantSync = ev === 'chant';
738
+ const waveProg = ev === 'wave' ? (et / 5) * (SEATS + 3) - 1.5 : -99;
739
+ const lerpK = Math.min(1, dt * 7);
740
+
741
+ for (const al of this.aliens.values()) {
742
+ const mood = eyesWideMood || al.mood;
743
+ const mi = MOOD_INDEX[mood];
744
+
745
+ let pose;
746
+ if (freeze) pose = 0;
747
+ else if (chantSync) pose = Math.floor(now * 4) % 2;
748
+ else pose = Math.floor(now * 2.4 + al.phase * 2) % 2;
749
+ const frame = mi * 2 + pose;
750
+ if (frame !== al.frame) {
751
+ al.frame = frame;
752
+ al.sprite.material.map.offset.x = frame / 12;
753
+ }
754
+
755
+ const tgt = MOOD_SCALE[mood];
756
+ let ty = tgt.y;
757
+ if (ev === 'ovation') ty *= 1.22;
758
+ al.sx += (tgt.x - al.sx) * lerpK;
759
+ al.sy += (ty - al.sy) * lerpK;
760
+ al.sprite.scale.set(SPRITE_SIZE * al.sx, SPRITE_SIZE * al.sy, 1);
761
+
762
+ let y = al.baseY;
763
+ if (!freeze) {
764
+ if (chantSync) y += Math.abs(Math.sin(now * 5)) * 0.22;
765
+ else y += Math.sin(now * 2 + al.phase * Math.PI * 2) * 0.045;
766
+ }
767
+ if (ev === 'wave') {
768
+ const d = waveProg - al.seat;
769
+ if (d > 0 && d < 2) y += Math.sin((d / 2) * Math.PI) * 0.55;
770
+ }
771
+ if (ev === 'ovation') y += 0.18;
772
+ al.sprite.position.y = y;
773
+
774
+ // faction floor tile — the contagion made visible
775
+ const s = al.sentiment;
776
+ const fc = s > 0.25 ? FACTION_COLORS.love : s < -0.25 ? FACTION_COLORS.hate : FACTION_COLORS.meh;
777
+ this._tileTarget.copy(fc);
778
+ if (chantSync) this._tileTarget.lerp(new THREE.Color(0xffd35a), 0.4);
779
+ al.tileMat.color.lerp(this._tileTarget, Math.min(1, dt * 3.5));
780
+ al.tileMat.opacity = 0.18 + 0.55 * Math.min(1, Math.abs(s));
781
+ }
782
+
783
+ this.renderer.render(this.scene, this.camera);
784
+ }
785
+ }
theater/js/thoughts.js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // HIVEMIND panel — a collapsible right-side live trace of the pipeline:
2
+ // frame gating -> VLM description -> codex scene -> batched LLM voicing ->
3
+ // contagion events. Self-contained: injects its own DOM + styles, polls
4
+ // GET /api/thoughts incrementally. Entries expand on click.
5
+
6
+ const KIND_META = {
7
+ vision: { label: 'VISION', color: 'var(--cyan, #58e6e6)' },
8
+ voice: { label: 'VOICE', color: 'var(--gold, #ffd35a)' },
9
+ event: { label: 'EVENT', color: 'var(--love, #ff6fa5)' },
10
+ system: { label: 'SYS', color: 'var(--meh, #8fb3bd)' },
11
+ };
12
+
13
+ const STYLE = `
14
+ #hivemind {
15
+ position: fixed; top: 64px; right: 0; bottom: 96px; z-index: 25;
16
+ width: 340px; max-width: 85vw;
17
+ display: flex; flex-direction: column;
18
+ background: rgba(10,5,24,.92);
19
+ border-left: 1px solid rgba(88,230,230,.35);
20
+ font: 11px/1.45 "Courier New", ui-monospace, monospace;
21
+ transition: transform .25s ease;
22
+ }
23
+ #hivemind.collapsed { transform: translateX(100%); }
24
+ #hivemind-toggle {
25
+ position: absolute; left: -30px; top: 8px; width: 30px; height: 110px;
26
+ border: 1px solid rgba(88,230,230,.35); border-right: none;
27
+ border-radius: 6px 0 0 6px; cursor: pointer;
28
+ background: rgba(10,5,24,.92); color: var(--cyan, #58e6e6);
29
+ writing-mode: vertical-rl; text-orientation: mixed;
30
+ font: bold 11px/30px "Courier New", monospace; letter-spacing: 2px;
31
+ user-select: none;
32
+ }
33
+ #hivemind-head {
34
+ padding: 8px 10px; color: var(--cyan, #58e6e6); letter-spacing: 2px;
35
+ border-bottom: 1px solid rgba(88,230,230,.25); font-weight: bold;
36
+ }
37
+ #hivemind-head .sub { color: #8fb3bd; font-weight: normal; letter-spacing: 0; display: block; }
38
+ #hivemind-list { flex: 1; overflow-y: auto; padding: 6px 8px; }
39
+ .hm-item { margin-bottom: 6px; border-left: 2px solid transparent; padding-left: 6px; cursor: pointer; }
40
+ .hm-item .hm-row { display: flex; gap: 6px; align-items: baseline; }
41
+ .hm-kind { font-weight: bold; font-size: 9px; letter-spacing: 1px; flex: none; }
42
+ .hm-title { color: #efeaff; }
43
+ .hm-ts { color: #6b6485; font-size: 9px; flex: none; margin-left: auto; }
44
+ .hm-body {
45
+ display: none; margin: 3px 0 4px; padding: 5px 7px;
46
+ background: rgba(255,255,255,.05); border-radius: 4px;
47
+ color: #c9c2e6; white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow-y: auto;
48
+ }
49
+ .hm-item.open .hm-body { display: block; }
50
+ .hm-item.has-body .hm-title::after { content: ' ▸'; color: #6b6485; }
51
+ .hm-item.open.has-body .hm-title::after { content: ' ▾'; }
52
+ `;
53
+
54
+ let lastId = 0;
55
+ let listEl = null;
56
+
57
+ function fmtTs(ts) {
58
+ const d = new Date(ts * 1000);
59
+ return d.toTimeString().slice(0, 8);
60
+ }
61
+
62
+ function addEntry(t) {
63
+ const meta = KIND_META[t.kind] || KIND_META.system;
64
+ const item = document.createElement('div');
65
+ item.className = 'hm-item' + (t.body ? ' has-body' : '');
66
+ item.style.borderLeftColor = meta.color;
67
+ const row = document.createElement('div');
68
+ row.className = 'hm-row';
69
+ const kind = document.createElement('span');
70
+ kind.className = 'hm-kind';
71
+ kind.style.color = meta.color;
72
+ kind.textContent = meta.label;
73
+ const title = document.createElement('span');
74
+ title.className = 'hm-title';
75
+ title.textContent = t.title;
76
+ const ts = document.createElement('span');
77
+ ts.className = 'hm-ts';
78
+ ts.textContent = fmtTs(t.ts);
79
+ row.append(kind, title, ts);
80
+ item.appendChild(row);
81
+ if (t.body) {
82
+ const body = document.createElement('div');
83
+ body.className = 'hm-body';
84
+ body.textContent = t.body;
85
+ item.appendChild(body);
86
+ item.addEventListener('click', () => item.classList.toggle('open'));
87
+ }
88
+ const stick = listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 30;
89
+ listEl.appendChild(item);
90
+ while (listEl.children.length > 250) listEl.removeChild(listEl.firstChild);
91
+ if (stick) listEl.scrollTop = listEl.scrollHeight;
92
+ }
93
+
94
+ async function poll() {
95
+ try {
96
+ const res = await fetch(`/api/thoughts?after=${lastId}`);
97
+ if (!res.ok) return;
98
+ const data = await res.json();
99
+ for (const t of data.thoughts || []) addEntry(t);
100
+ lastId = data.last || lastId;
101
+ } catch (e) { /* server restarting; retry next tick */ }
102
+ }
103
+
104
+ export function initThoughts() {
105
+ const style = document.createElement('style');
106
+ style.textContent = STYLE;
107
+ document.head.appendChild(style);
108
+
109
+ const panel = document.createElement('aside');
110
+ panel.id = 'hivemind';
111
+ panel.className = 'collapsed';
112
+ panel.innerHTML = `
113
+ <button id="hivemind-toggle" title="the aliens' thought process">🧠 HIVEMIND</button>
114
+ <div id="hivemind-head">HIVEMIND FEED
115
+ <span class="sub">live trace: camera → translator → codex → crowd → voices</span>
116
+ </div>
117
+ <div id="hivemind-list"></div>`;
118
+ document.body.appendChild(panel);
119
+ listEl = panel.querySelector('#hivemind-list');
120
+ panel.querySelector('#hivemind-toggle').addEventListener('click', () => {
121
+ panel.classList.toggle('collapsed');
122
+ });
123
+ setInterval(poll, 1000);
124
+ poll();
125
+ }