diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock
deleted file mode 100644
index 0d7e6cb..0000000
--- a/.claude/scheduled_tasks.lock
+++ /dev/null
@@ -1 +0,0 @@
-{"sessionId":"3d23f89e-6e34-42f0-9960-ef93d5f29ec6","pid":88905,"procStart":"Wed Apr 29 23:16:02 2026","acquiredAt":1777506620140}
\ No newline at end of file
diff --git a/pets.html b/pets.html
index a116b7d..d42ab6e 100644
--- a/pets.html
+++ b/pets.html
@@ -302,6 +302,55 @@
100% { transform: translate(var(--sway, 0), -110vh) scale(var(--end-scale, 1.1)); opacity: 0; }
}
+ /* !fireworks — scene-wide bursts */
+ .fx-fireworks {
+ position: fixed; inset: 0; pointer-events: none; overflow: hidden;
+ z-index: 160;
+ animation: fx-fade-out 1s ease-out 7.5s forwards;
+ }
+ .firework-particle {
+ position: absolute;
+ width: 6px; height: 6px;
+ border-radius: 50%;
+ box-shadow: 0 0 12px 2px rgba(255,255,255,0.55);
+ opacity: 0;
+ animation-name: firework-burst;
+ animation-timing-function: cubic-bezier(.25, .8, .35, 1);
+ animation-fill-mode: forwards;
+ }
+ @keyframes firework-burst {
+ 0% { transform: translate(0, 0) scale(0.5); opacity: 0; }
+ 8% { opacity: 1; }
+ 100% { transform: translate(var(--dx), var(--dy)) scale(0.4); opacity: 0; }
+ }
+
+ /* Per-pet smoke — wispy gray puffs from upper face */
+ .smoke {
+ position: absolute;
+ top: -14px;
+ left: 50%;
+ margin-left: -35px;
+ width: 70px;
+ height: 90px;
+ opacity: 0;
+ pointer-events: none;
+ }
+ .smoke svg { width: 100%; height: 100%; overflow: visible; }
+ .smoke-puff {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation-name: smoke-puff;
+ animation-timing-function: ease-out;
+ animation-fill-mode: forwards;
+ opacity: 0;
+ }
+ .pet.smoking .smoke { opacity: 1; }
+ @keyframes smoke-puff {
+ 0% { transform: translate(0, 0) scale(var(--smoke-start, 0.2)); opacity: 0; }
+ 20% { opacity: var(--smoke-peak, 0.7); }
+ 100% { transform: translate(var(--smoke-dx, 0), var(--smoke-dy, -55px)) scale(var(--smoke-end, 1.8)); opacity: 0; }
+ }
+
/* !quake — shakes the stage */
.fx-quake { animation: quake 0.55s linear; }
@keyframes quake {
@@ -325,6 +374,24 @@
100% { translate: 0 0; }
}
+ /* Barrel roll — Star Fox style: arc up while rotating 360 */
+ .pet.rolling .pet-flip { animation: barrel-roll 0.75s cubic-bezier(.4,.0,.4,1); }
+ @keyframes barrel-roll {
+ 0% { translate: 0 0; rotate: 0deg; }
+ 35% { translate: 0 -32px; rotate: 130deg; }
+ 65% { translate: 0 -32px; rotate: 230deg; }
+ 100% { translate: 0 0; rotate: 360deg; }
+ }
+
+ /* Teleport — fade out, JS moves the pet, fade in */
+ .pet.teleporting { animation: teleport 0.7s ease-in-out; }
+ @keyframes teleport {
+ 0% { opacity: 1; }
+ 35% { opacity: 0; }
+ 65% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+
/* Dance — bigger sway, double spin, finale flourish */
.pet.dancing .pet-flip { animation: dance 3s cubic-bezier(.45,.0,.45,1); }
@keyframes dance {
@@ -591,6 +658,30 @@ const HEART_SVG = `
`;
const FART_GREENS = ['#9bbf2f', '#aacc3a', '#b8d650', '#8eb02a', '#7ea020', '#c4dc66', '#a3c544', '#88a52a', '#d4e878'];
+const SMOKE_GRAYS = ['#dadada', '#cccccc', '#bbbbbb', '#aaaaaa', '#c8c8c8', '#9c9c9c', '#e8e8e8'];
+
+function randomSmokeSvg() {
+ const n = 5 + Math.floor(Math.random() * 4);
+ const rand = (a, b) => a + Math.random() * (b - a);
+ let body = '';
+ let maxDur = 0;
+ for (let i = 0; i < n; i++) {
+ const cx = rand(20, 50);
+ const cy = rand(55, 75);
+ const r = rand(7, 16);
+ const fill = SMOKE_GRAYS[Math.floor(Math.random() * SMOKE_GRAYS.length)];
+ const peak = rand(0.45, 0.75);
+ const dx = rand(-14, 14);
+ const dy = rand(-35, -65);
+ const startScale = rand(0.15, 0.32);
+ const endScale = rand(1.4, 2.4);
+ const duration = rand(1.5, 2.8);
+ const delay = rand(0, 0.6);
+ if (duration + delay > maxDur) maxDur = duration + delay;
+ body += ``;
+ }
+ return { svg: ``, durationMs: maxDur * 1000 + 100 };
+}
function randomFartSvg() {
const n = 4 + Math.floor(Math.random() * 5); // 4–8 puffs
@@ -723,6 +814,9 @@ class Pet {
this.fartEl = document.createElement('div');
this.fartEl.className = 'fart';
+ this.smokeEl = document.createElement('div');
+ this.smokeEl.className = 'smoke';
+
this.labelEl = document.createElement('div');
this.labelEl.className = 'pet-label';
@@ -730,6 +824,7 @@ class Pet {
this.el.appendChild(this.zzzEl);
this.el.appendChild(this.heartEl);
this.el.appendChild(this.fartEl);
+ this.el.appendChild(this.smokeEl);
this.el.appendChild(this.labelEl);
this.setHandle(handle);
@@ -898,6 +993,51 @@ class Pet {
this.lastMessage = performance.now();
}
+ roll() {
+ this.el.classList.remove('rolling');
+ void this.el.offsetWidth;
+ this.el.classList.add('rolling');
+ setTimeout(() => this.el.classList.remove('rolling'), 800);
+ this.lastMessage = performance.now();
+ }
+
+ smoke() {
+ this.el.classList.remove('smoking');
+ void this.el.offsetWidth;
+ const { svg, durationMs } = randomSmokeSvg();
+ this.smokeEl.innerHTML = svg;
+ this.el.classList.add('smoking');
+ setTimeout(() => {
+ this.el.classList.remove('smoking');
+ this.smokeEl.innerHTML = '';
+ }, durationMs);
+ this.lastMessage = performance.now();
+ }
+
+ sleep() {
+ this.setState('sleep');
+ this.stateTimer = 0;
+ this.nextDecision = 12 + Math.random() * 12;
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ teleport() {
+ this.el.classList.remove('teleporting');
+ void this.el.offsetWidth;
+ this.el.classList.add('teleporting');
+ // Move during the invisible middle of the fade
+ setTimeout(() => {
+ this.x = Math.random() * (window.innerWidth - 110);
+ // Sometimes flip direction for variety
+ if (Math.random() < 0.5) this.dir *= -1;
+ this.applyTransform();
+ }, 320);
+ setTimeout(() => this.el.classList.remove('teleporting'), 720);
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
showMessage(rawText) {
if (!rawText) return;
const safe = redactBadWords(String(rawText)).slice(0, 100).trim();
@@ -1040,7 +1180,39 @@ async function getProfile(did) {
/* ---------- Bang-command scene effects ---------- */
-const fxState = { party: false, snow: false, rain: false, confetti: false, hearts: false, quake: false };
+const fxState = { party: false, snow: false, rain: false, confetti: false, hearts: false, quake: false, fireworks: false };
+
+function triggerFireworks() {
+ if (fxState.fireworks) return;
+ fxState.fireworks = true;
+ const overlay = document.createElement('div');
+ overlay.className = 'fx-fireworks';
+ document.body.appendChild(overlay);
+ const colors = ['#ff5577', '#ffd93d', '#3affc8', '#ff8e3c', '#c84fff', '#3ad4ff', '#ffffff', '#ff3a8e'];
+ const burstCount = 7;
+ for (let b = 0; b < burstCount; b++) {
+ const cx = 12 + Math.random() * 76;
+ const cy = 12 + Math.random() * 45;
+ const color = colors[Math.floor(Math.random() * colors.length)];
+ const burstDelay = b * (0.4 + Math.random() * 0.7);
+ const particleCount = 28 + Math.floor(Math.random() * 18);
+ for (let i = 0; i < particleCount; i++) {
+ const p = document.createElement('div');
+ p.className = 'firework-particle';
+ p.style.left = `${cx}vw`;
+ p.style.top = `${cy}vh`;
+ p.style.background = color;
+ const angle = Math.random() * Math.PI * 2;
+ const distance = 60 + Math.random() * 140;
+ p.style.setProperty('--dx', `${(Math.cos(angle) * distance).toFixed(1)}px`);
+ p.style.setProperty('--dy', `${(Math.sin(angle) * distance + 80).toFixed(1)}px`);
+ p.style.animationDelay = `${burstDelay.toFixed(2)}s`;
+ p.style.animationDuration = `${(1.0 + Math.random() * 0.8).toFixed(2)}s`;
+ overlay.appendChild(p);
+ }
+ }
+ setTimeout(() => { overlay.remove(); fxState.fireworks = false; }, 8500);
+}
function triggerRain() {
if (fxState.rain) return;
@@ -1270,6 +1442,10 @@ async function handleChatMessage(authorDid, record) {
jump: new Set(['jump', 'spring', 'springen', 'springt', 'hop', '🦘']),
dance: new Set(['dance', 'tanz', 'tanze', 'tanzen', 'dans', 'dansen', '💃', '🕺']),
fart: new Set(['fart', 'farts', 'farted', 'farting', 'scheet', 'scheten', 'furz', '💨']),
+ roll: new Set(['roll', 'rolls', 'barrelroll', 'rolle', 'rollen', 'spin', '🌀']),
+ teleport: new Set(['teleport', 'tp', 'warp', 'beam', '✨']),
+ sleep: new Set(['sleep', 'sleeping', 'snooze', 'rest', 'schlafen', 'schlaf', 'slapen', 'slaap', '😴', '💤']),
+ smoke: new Set(['smoke', 'smoking', 'cig', 'cigarette', 'rauch', 'rauchen', 'rook', 'roken', '🚬']),
wider: new Set(['wide', 'wider', 'breit', 'breiter', 'breed', 'breder', 'wijd', 'wijder', 'stretch']),
narrower: new Set(['narrow', 'narrower', 'schmal', 'schmaler', 'smal', 'thin', 'dun']),
taller: new Set(['tall', 'taller', 'lang', 'länger', 'laenger', 'hoog', 'hoger']),
@@ -1287,6 +1463,7 @@ async function handleChatMessage(authorDid, record) {
if (/!confetti\b/i.test(text)) { triggerConfetti(); bangFired = true; }
if (/!hearts?\b/i.test(text)) { triggerHearts(); bangFired = true; }
if (/!(quake|earthquake|shake)\b/i.test(text)) { triggerQuake(); bangFired = true; }
+ if (/!fireworks?\b/i.test(text)) { triggerFireworks(); bangFired = true; }
// Tokenize: split on whitespace, strip ASCII edge-punctuation, leave letters/digits/emoji intact.
const tokens = text.split(/\s+/).map(t => t.replace(/^[!?.,;:'"()\[\]{}*]+|[!?.,;:'"()\[\]{}*]+$/g, ''));
@@ -1333,6 +1510,12 @@ async function handleChatMessage(authorDid, record) {
if (counts.fart) {
for (let i = 0; i < counts.fart; i++) setTimeout(() => pet.fart(), i * 600);
}
+ if (counts.roll) {
+ for (let i = 0; i < counts.roll; i++) setTimeout(() => pet.roll(), i * 800);
+ }
+ if (counts.teleport) pet.teleport();
+ if (counts.sleep) pet.sleep();
+ if (counts.smoke) pet.smoke();
if (counts.wider) pet.setStretchX(pet.stretchX * Math.pow(1.25, counts.wider));
if (counts.narrower) pet.setStretchX(pet.stretchX / Math.pow(1.25, counts.narrower));
if (counts.taller) pet.setStretchY(pet.stretchY * Math.pow(1.25, counts.taller));
diff --git a/scripts/drain-chat.sh b/scripts/drain-chat.sh
new file mode 100755
index 0000000..2b3e76c
--- /dev/null
+++ b/scripts/drain-chat.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+# Atomically drain a chat log written by listen-chat.ts.
+# Renames the log out of the way, prints its contents, then deletes it.
+# usage: drain-chat.sh [chat-file] (default: chat.txt)
+set -euo pipefail
+
+src=${1:-chat.txt}
+pending=$src.pending
+
+if [ -f "$pending" ]; then
+ cat "$pending"
+ rm -f "$pending"
+fi
+
+if [ -f "$src" ]; then
+ mv "$src" "$pending"
+ cat "$pending"
+ rm -f "$pending"
+fi
diff --git a/scripts/listen-chat.ts b/scripts/listen-chat.ts
index 4a02b70..4d056d7 100644
--- a/scripts/listen-chat.ts
+++ b/scripts/listen-chat.ts
@@ -5,13 +5,9 @@ import { resolve } from 'node:path';
const COLLECTION = 'place.stream.chat.message';
const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe';
-const streamerDid = process.argv[2];
-const outFile = resolve(process.argv[3] ?? 'chat.txt');
-
-if (!streamerDid || !streamerDid.startsWith('did:')) {
- console.error('usage: tsx scripts/listen-chat.ts [out-file]');
- process.exit(1);
-}
+const args = process.argv.slice(2);
+const streamerDid = args[0]?.startsWith('did:') ? args[0] : undefined;
+const outFile = resolve((streamerDid ? args[1] : args[0]) ?? 'chat.txt');
const url = `${JETSTREAM_URL}?wantedCollections=${encodeURIComponent(COLLECTION)}`;
@@ -35,7 +31,9 @@ type JetstreamEvent = {
};
function connect() {
- console.error(`[listen-chat] connecting, filtering streamer=${streamerDid}`);
+ console.error(
+ `[listen-chat] connecting, ${streamerDid ? `filtering streamer=${streamerDid}` : 'no streamer filter'}`,
+ );
const ws = new WebSocket(url);
ws.addEventListener('open', () => console.error('[listen-chat] connected'));
@@ -50,7 +48,8 @@ function connect() {
const c = msg.commit;
if (!c || c.operation !== 'create' || c.collection !== COLLECTION) return;
const rec = c.record;
- if (!rec || rec.streamer !== streamerDid) return;
+ if (!rec) return;
+ if (streamerDid && rec.streamer !== streamerDid) return;
const line = `${msg.did}: ${rec.text}`;
appendFileSync(outFile, line + '\n');
diff --git a/scripts/refresh-obs-source.ts b/scripts/refresh-obs-source.ts
new file mode 100755
index 0000000..85c6bf8
--- /dev/null
+++ b/scripts/refresh-obs-source.ts
@@ -0,0 +1,88 @@
+#!/usr/bin/env tsx
+// Refresh an OBS Browser Source via obs-websocket v5.
+// usage: refresh-obs-source.ts
+// env: OBS_WEBSOCKET_URL (default ws://localhost:4455), OBS_WEBSOCKET_PASSWORD
+import { createHash } from 'node:crypto';
+
+const sourceName = process.argv[2];
+if (!sourceName) {
+ console.error('usage: refresh-obs-source.ts ');
+ process.exit(2);
+}
+
+const url = process.env.OBS_WEBSOCKET_URL ?? 'ws://localhost:4455';
+const password = process.env.OBS_WEBSOCKET_PASSWORD ?? '';
+
+function sha256b64(input: string) {
+ return createHash('sha256').update(input).digest('base64');
+}
+
+const ws = new WebSocket(url);
+const requestId = crypto.randomUUID();
+let identified = false;
+
+ws.addEventListener('open', () => {
+ console.error(`[refresh-obs-source] connected to ${url}`);
+});
+
+ws.addEventListener('message', (ev) => {
+ const msg = JSON.parse(ev.data as string) as { op: number; d: any };
+
+ if (msg.op === 0) {
+ // Hello → Identify
+ const auth = msg.d.authentication;
+ const identify: any = { rpcVersion: 1 };
+ if (auth) {
+ if (!password) {
+ console.error('[refresh-obs-source] server requires auth, set OBS_WEBSOCKET_PASSWORD');
+ process.exit(1);
+ }
+ identify.authentication = sha256b64(sha256b64(password + auth.salt) + auth.challenge);
+ }
+ ws.send(JSON.stringify({ op: 1, d: identify }));
+ return;
+ }
+
+ if (msg.op === 2) {
+ // Identified → send refresh request
+ identified = true;
+ ws.send(
+ JSON.stringify({
+ op: 6,
+ d: {
+ requestType: 'PressInputPropertiesButton',
+ requestId,
+ requestData: { inputName: sourceName, propertyName: 'refreshnocache' },
+ },
+ }),
+ );
+ return;
+ }
+
+ if (msg.op === 7 && msg.d.requestId === requestId) {
+ const ok = msg.d.requestStatus?.result;
+ if (ok) {
+ console.error(`[refresh-obs-source] refreshed "${sourceName}"`);
+ ws.close();
+ process.exit(0);
+ } else {
+ console.error(
+ `[refresh-obs-source] failed: ${msg.d.requestStatus?.code} ${msg.d.requestStatus?.comment ?? ''}`,
+ );
+ ws.close();
+ process.exit(1);
+ }
+ }
+});
+
+ws.addEventListener('error', (ev) => {
+ console.error('[refresh-obs-source] error', ev);
+ process.exit(1);
+});
+
+ws.addEventListener('close', (ev) => {
+ if (!identified) {
+ console.error(`[refresh-obs-source] closed before identify (${ev.code})`);
+ process.exit(1);
+ }
+});