From dda3cf32719cf8e10e805d066e4b4aebaed7329a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 15:25:44 -0700 Subject: [PATCH] =?UTF-8?q?lairk:=20a=20spot=20is=20earned=20=E2=80=94=20s?= =?UTF-8?q?poken=20in=20Laer=20Klokken=20and=20@mentioned=20by=20someone?= =?UTF-8?q?=20else?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /api/lairk-roster reads the whole of chat-clock (cached 2 min) and returns every handle that has spoken there and been mentioned by another handle. lairk now draws only that roster, spread by its size, and the walk notice reads from it instead of the recent messages it happened to load (which missed older mentions). Looks load four at a time, online first. Co-Authored-By: Claude Opus 5.5 --- system/netlify/functions/lairk-roster.mjs | 68 +++++++++++++ .../public/aesthetic.computer/disks/lairk.mjs | 99 ++++++++++++------- 2 files changed, 134 insertions(+), 33 deletions(-) create mode 100644 system/netlify/functions/lairk-roster.mjs diff --git a/system/netlify/functions/lairk-roster.mjs b/system/netlify/functions/lairk-roster.mjs new file mode 100644 index 0000000000..46242df52f --- /dev/null +++ b/system/netlify/functions/lairk-roster.mjs @@ -0,0 +1,68 @@ +// lairk-roster, 26.09.23 +// GET: Who has a spot in `lairk` — every handle that has spoken in Laer +// Klokken (chat-clock) AND been @mentioned there by someone else. +// Read over the whole history, not a recent page, so an old mention +// still counts. The same list will gate walking in lairk. +// Response: { handles: ["jeffrey", ...], at: ISO } +// Redis caching: 2 min TTL. + +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { shell } from "../../backend/shell.mjs"; +import { getOrCompute, CACHE_TTLS } from "../../backend/cache.mjs"; + +// Mirrors validateHandle in lib/text.mjs: letters and digits, with single +// `.` or `_` between them. +const MENTION = /@([a-z0-9]+(?:[._][a-z0-9]+)*)/gi; + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, null); + if (event.httpMethod !== "GET") { + return respond(405, { message: "Method Not Allowed" }); + } + + try { + const result = await getOrCompute( + "lairk:roster", + async () => { + const database = await connect(); + const chat = database.db.collection("chat-clock"); + const kept = { deleted: { $ne: true }, user: { $exists: true } }; + + // Sub → handle, for everyone who has spoken. + const subs = await chat.distinct("user", kept); + const handleOf = new Map(); + const records = await database.db + .collection("@handles") + .find({ _id: { $in: subs } }) + .project({ handle: 1 }) + .toArray(); + for (const r of records) handleOf.set(r._id, r.handle.toLowerCase()); + const speakers = new Set(handleOf.values()); + + // Mentions, counted only when the mentioner is someone else. + const mentioned = new Set(); + const cursor = chat + .find({ ...kept, text: /@/ }) + .project({ user: 1, text: 1 }); + for await (const m of cursor) { + const by = handleOf.get(m.user); + for (const [, name] of String(m.text).matchAll(MENTION)) { + const h = name.toLowerCase(); + if (h !== by) mentioned.add(h); + } + } + + await database.disconnect(); + const handles = [...speakers].filter((h) => mentioned.has(h)).sort(); + shell.log(`🗼 lairk roster: ${handles.length} of ${speakers.size} speakers`); + return { handles, at: new Date().toISOString() }; + }, + CACHE_TTLS.CHAT, + ); + return respond(200, result); + } catch (error) { + shell.error?.("lairk-roster failed:", error); + return respond(500, { message: "Failed to build the lairk roster" }); + } +} diff --git a/system/public/aesthetic.computer/disks/lairk.mjs b/system/public/aesthetic.computer/disks/lairk.mjs index 09531f4a40..0afa82a2b8 100644 --- a/system/public/aesthetic.computer/disks/lairk.mjs +++ b/system/public/aesthetic.computer/disks/lairk.mjs @@ -11,11 +11,12 @@ with the exact math the renderer uses), the chat with `chat.mjs` in its `embedded` mode over the top. - Step 1 of the plan: nobody walks yet. Each handle stands at a default spot - on a ring around the tower, lit when online and dimmed when not. - Walking arrives in step 2, and only for a handle that has spoken in Laer - Klokken AND been @mentioned there by someone else (checked by the server - against `chat-clock`, never here — the notice below is a courtesy). + A spot is earned: only a handle that has spoken in Laer Klokken AND been + @mentioned there by someone else stands in lairk. The server reads the + whole of `chat-clock` for that (/api/lairk-roster); this piece only draws + the answer. Step 1 of the plan: nobody walks yet — each handle stands at a + default spot around the tower, lit when online and dimmed when not. + Walking (step 2) will use the same roster as its gate. */ /* #region 🏁 TODO @@ -64,6 +65,12 @@ let handsMinute = -1; // rebuild the forms when either lands or the handle comes online. const characters = new Map(); let rosterKey = ""; // Rebuilt when the handles in view change. +let eligible = null; // Set of handles with a spot, from /api/lairk-roster. +let eligibleAt = 0; // When it was last asked for, ms. +const ROSTER_MS = 120_000; // The server caches it for two minutes too. +const lookQueue = []; // Characters waiting for their colors and painting. +let looking = 0; // Look-ups in flight. +const LOOKS_AT_ONCE = 4; // 💬 Speech bubbles: handle -> { text, until }. const bubbles = new Map(); @@ -101,6 +108,9 @@ function boot({ api, Form, debug, send, hud, store, colon, params, get: getter } characters.clear(); bubbles.clear(); rosterKey = ""; + eligible = null; + eligibleAt = 0; + lookQueue.length = 0; ground = buildGround(Form); tower = buildTower(Form); @@ -220,6 +230,7 @@ function sim($) { if (performance.now() - lastLookAt > 5000) orbitAngle += 0.0012; // Drift. noticeNewMessages(); + if (Date.now() - eligibleAt > ROSTER_MS) askRoster(); updateRoster(); const minute = new Date().getMinutes() + new Date().getHours() * 60; @@ -520,18 +531,24 @@ function paintedBox(Form, x0, y0, z0, x1, y1, z1, texture) { // 🧍 Roster -// Everyone who has spoken in the loaded history, plus whoever is online. +// Who has a spot: the server's answer over the whole chat history. +function askRoster() { + eligibleAt = Date.now(); + fetch("/api/lairk-roster") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (Array.isArray(data?.handles)) eligible = new Set(data.handles); + }) + .catch(() => {}); +} + +// Everyone on the roster stands in lairk; the online ones are lit. function updateRoster() { - const sys = client.system; - const online = new Set((sys.onlineHandles || []).map(cleanHandle)); - const handles = new Set(); - for (const m of sys.messages) { - const h = cleanHandle(m.from); - if (h && h !== "log") handles.add(h); - } - for (const h of online) if (h) handles.add(h); + if (!eligible) return; // Nobody stands until the roster arrives. + const online = new Set((client.system.onlineHandles || []).map(cleanHandle)); + const handles = eligible; - const key = [...handles].sort().map((h) => (online.has(h) ? "+" : "-") + h).join(","); + const key = [...handles].map((h) => (online.has(h) ? "+" : "-") + h).join(","); if (key === rosterKey) return; rosterKey = key; @@ -546,17 +563,33 @@ function updateRoster() { } continue; } - const spot = homeSpot(h); + const spot = homeSpot(h, handles.size); const c = { handle: h, x: spot.x, z: spot.z, online: isOnline, colors: null, texture: null, dirty: true }; characters.set(h, c); - lookUp(c); + lookQueue.push(c); + } + // Online handles first, so the people in the room dress before the rest. + lookQueue.sort((a, b) => b.online - a.online); + pumpLooks(); +} + +// Works through lookQueue a few at a time. +function pumpLooks() { + while (looking < LOOKS_AT_ONCE && lookQueue.length > 0) { + const c = lookQueue.shift(); + if (!characters.has(c.handle)) continue; + looking += 1; + lookUp(c).finally(() => { + looking -= 1; + pumpLooks(); + }); } } // Ask for a handle's colors and its most recent painting; each redresses // the body when it lands. Misses leave the fallbacks in place. function lookUp(c) { - fetch(`/.netlify/functions/handle-colors?handle=${encodeURIComponent(c.handle)}`) + const colors = fetch(`/.netlify/functions/handle-colors?handle=${encodeURIComponent(c.handle)}`) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (Array.isArray(data?.colors) && data.colors.length > 0) { @@ -566,7 +599,7 @@ function lookUp(c) { }) .catch(() => {}); - fetch(`/media-collection?for=${encodeURIComponent(`@${c.handle}/painting`)}`) + const painting = fetch(`/media-collection?for=${encodeURIComponent(`@${c.handle}/painting`)}`) .then((res) => (res.ok ? res.json() : null)) .then((data) => { const latest = data?.files?.at?.(-1); // Oldest first; the last is newest. @@ -583,6 +616,8 @@ function lookUp(c) { c.dirty = true; }) .catch(() => {}); + + return Promise.all([colors, painting]); } // A painting at most 96 pixels on a side (a torso is a few pixels tall on @@ -605,11 +640,14 @@ function shrink(img, k) { return { width, height, pixels }; } -// Where a handle stands until it walks somewhere: a ring around the tower. -function homeSpot(handle) { +// Where a handle stands until it walks somewhere: a disc around the tower +// that widens with the roster so a big room doesn't pile up (square-root +// spacing keeps the density even out to the rim). +function homeSpot(handle, count = 1) { const hash = handleHash(handle); const angle = ((hash % 3600) / 3600) * PI * 2; - const radius = 4.5 + ((hash >>> 12) % 60) / 10; // 4.5 – 10.4 + const spread = clamp(sqrt(count) * 1.6, 6, 24); + const radius = 3.5 + sqrt(((hash >>> 12) % 1000) / 1000) * spread; return { x: sin(angle) * radius, z: cos(angle) * radius }; } @@ -668,19 +706,14 @@ function fit(text, chars) { return text.length > chars ? text.slice(0, max(0, chars - 3)) + "..." : text; } -// 🚶 The walking gate, as far as this client can see it. The server decides. +// 🚶 The walking gate is the roster: a spot in lairk is the right to walk +// it. Walking itself is step 2, so for now this only says where you stand. function walkNotice($) { const me = cleanHandle($.handle?.()); - if (!me) return "log in, speak in laer klokken, and get @mentioned to walk"; - const messages = client.system.messages; - const spoke = messages.some((m) => cleanHandle(m.from) === me); - const mentioned = messages.some( - (m) => - cleanHandle(m.from) !== me && - new RegExp(`@${me.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(m.text || ""), - ); - if (!spoke || !mentioned) return "you must be mentioned in laer klokken to walk"; - return "walking arrives soon"; + if (!me) return "log in to take your spot in lairk"; + if (!eligible) return "asking laer klokken who is here..."; + if (!eligible.has(me)) return "you must be mentioned in laer klokken to have a spot"; + return "walking arrives soon - your spot is waiting"; } // 🏷️ Name tags and bubbles, far to near. -- 2.51.2