The Committee — two cats judge your webcam (local MiniCPM + Llama via llama.cpp)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .claude/settings.local.json +24 -0
- .dockerignore +12 -0
- .gitignore +24 -0
- Dockerfile +26 -0
- README.md +64 -4
- app.py +182 -0
- app_kitchen.py +77 -0
- arduino/desk_companion.ino +123 -0
- companion/__init__.py +27 -0
- companion/crowd.py +1190 -0
- companion/duo.py +207 -0
- companion/model.py +92 -0
- companion/personas.py +262 -0
- companion/personas_data.json +1322 -0
- companion/pet_persona.py +271 -0
- companion/runner.py +138 -0
- companion/sensors.py +242 -0
- companion/text.py +69 -0
- companion/thoughts.py +41 -0
- companion/ui.py +38 -0
- companion/vlm.py +385 -0
- requirements.txt +14 -0
- run.sh +34 -0
- server.py +278 -0
- theater/assets/aliens/anthropologist.png +0 -0
- theater/assets/aliens/art_critic.png +0 -0
- theater/assets/aliens/conspiracist.png +0 -0
- theater/assets/aliens/devout.png +0 -0
- theater/assets/aliens/doom_prophet.png +0 -0
- theater/assets/aliens/elder.png +0 -0
- theater/assets/aliens/foodie.png +0 -0
- theater/assets/aliens/heckler.png +0 -0
- theater/assets/aliens/merch_vendor.png +0 -0
- theater/assets/aliens/romantic.png +0 -0
- theater/assets/aliens/sportscaster.png +0 -0
- theater/assets/aliens/tourist.png +0 -0
- theater/assets/props/confetti.png +0 -0
- theater/assets/props/pulse_ring.png +0 -0
- theater/assets/props/static.png +0 -0
- theater/assets/props/tile_glow.png +0 -0
- theater/assets/props/torch.png +0 -0
- theater/duo.html +168 -0
- theater/index.html +202 -0
- theater/js/aliencard.js +242 -0
- theater/js/bubbles.js +76 -0
- theater/js/capture.js +239 -0
- theater/js/duo.js +157 -0
- theater/js/main.js +145 -0
- theater/js/scene3d.js +785 -0
- 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:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 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 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 |
+
}
|