@homie-rocks/studio 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * ROOM CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): the owner's chat rules per game
3
3
  * and per server, and the reports players make. Never the chat itself: what people say lives a few minutes in the
4
- * room's memory and nowhere else (worker/room.mjs). A report keeps the one message it is about, for 30 days.
4
+ * room's memory and nowhere else (worker/room.mjs), unless an owner turns a room's `history` on (0.29.0: then
5
+ * worker/lounge-store.mjs keeps it for that many days). A report keeps the one message it is about, for 30 days.
5
6
  *
6
7
  * chat_rules (game, server, rules JSON, updated_at): server '' is the whole game; a server's row wins over it
7
8
  * chat_reports one reported message: its words, its sender's room name and (when signed in) account id, the room,
package/worker/chat.mjs CHANGED
@@ -20,6 +20,9 @@
20
20
  *
21
21
  * Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
22
22
  * NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
23
+ * A room keeps its words longer only when its owner turns `history` on (a number of days, 0.29.0; off for every game
24
+ * by default, never on a kids server): then the Table writes what was said, never a reaction, to the studio's own D1
25
+ * (worker/lounge-store.mjs) and forgets it after that many days.
23
26
  */
24
27
  import { WORDS_B64 } from './chat-words.mjs';
25
28
  import { clefRun, localAiOf } from './brain.mjs';
@@ -55,6 +58,8 @@ export const CHAT_LIMITS = Object.freeze({
55
58
  /** A typed message: `max` is the room's, at most this (homie.rocks's LINE_MAX). */
56
59
  text: 280, minText: 20, name: 24,
57
60
  lineText: 60, lines: 12, extraEmoji: 3, words: 64, word: 32, slowMax: 120,
61
+ /** `history`: the most days a room may keep what was said (0, the default, keeps nothing past the window). */
62
+ historyMax: 90,
58
63
  /** The room's window: the last `keep` messages from the last `keepMs` (memory only), for a page that just opened. */
59
64
  keep: 50, keepMs: 15 * 60_000,
60
65
  /** The same words from the same person within this long are not sent again. */
@@ -77,9 +82,12 @@ export const CHAT_RATES = Object.freeze({
77
82
  /** A game with no "chat" in its game.json: emoji and quick lines for everyone, typing for signed-in players (reviewed). */
78
83
  export const CHAT_DEFAULTS = Object.freeze({
79
84
  mode: 'text', who: 'signed-in', react: 'anyone', slow: 2, max: 140, links: 'block', swears: 'block', ai: true,
80
- bubbles: true, watchers: true, hub: true,
85
+ bubbles: true, watchers: true, hub: true, history: 0,
81
86
  });
82
87
 
88
+ /** How long a room with these rules keeps what was said: its `history` days, else the window's few minutes. */
89
+ export const windowMsOf = (rules) => (rules?.history > 0 ? rules.history * 86_400_000 : CHAT_LIMITS.keepMs);
90
+
83
91
  const ID = /^[a-z][a-z0-9_]{0,15}$/;
84
92
  const PICTO = /\p{Extended_Pictographic}/u;
85
93
  const oneLine = (v, max) => [...String(v ?? '').normalize('NFKC').replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029]+/gu, ' ').replace(/\s+/g, ' ').trim()].slice(0, max).join('').trim();
@@ -151,6 +159,8 @@ export function normalizeChat(raw, { kids = false, speech = 'game' } = {}) {
151
159
  links: r.links === 'allow' ? 'allow' : 'block',
152
160
  swears: r.swears === 'allow' ? 'allow' : 'block',
153
161
  ai: bool('ai'), bubbles: bool('bubbles'), watchers: bool('watchers'), hub: bool('hub'),
162
+ // Kept words (0.29.0): only when the owner asks, never on a kids server or a capped one, never with chat off.
163
+ history: kids || capped || mode === 'off' ? 0 : int(r.history, 0, CHAT_LIMITS.historyMax, CHAT_DEFAULTS.history),
154
164
  emoji: [...REACTIONS.map((x) => ({ ...x })), ...extraEmoji(r.emoji)],
155
165
  lines: quickLines(r.lines),
156
166
  block: wordList(r.block), allow: wordList(r.allow),
@@ -191,6 +201,7 @@ export function checkChatRules(body) {
191
201
  for (const k of ['who', 'react']) if (b[k] !== undefined) { if (!CHAT_WHO.includes(b[k])) return bad(`${k} is anyone, signed-in or members`); f[k] = b[k]; }
192
202
  if (b.slow !== undefined) { const n = Math.floor(Number(b.slow)); if (!Number.isFinite(n) || n < 0 || n > CHAT_LIMITS.slowMax) return bad(`slow is 0 to ${CHAT_LIMITS.slowMax} seconds`); f.slow = n; }
193
203
  if (b.max !== undefined) { const n = Math.floor(Number(b.max)); if (!Number.isFinite(n) || n < CHAT_LIMITS.minText || n > CHAT_LIMITS.text) return bad(`max is ${CHAT_LIMITS.minText} to ${CHAT_LIMITS.text} characters`); f.max = n; }
204
+ if (b.history !== undefined) { const n = Math.floor(Number(b.history)); if (!Number.isFinite(n) || n < 0 || n > CHAT_LIMITS.historyMax) return bad(`history is 0 (keep nothing) to ${CHAT_LIMITS.historyMax} days`); f.history = n; }
194
205
  if (b.links !== undefined) { if (!['block', 'allow'].includes(b.links)) return bad('links is block or allow'); f.links = b.links; }
195
206
  if (b.swears !== undefined) { if (!['block', 'allow'].includes(b.swears)) return bad('swears is block or allow'); f.swears = b.swears; }
196
207
  for (const k of ['ai', 'bubbles', 'watchers', 'hub']) if (b[k] !== undefined) f[k] = b[k] === true || b[k] === 'on' || b[k] === 'true';
@@ -208,6 +219,7 @@ export function chatProblems(raw) {
208
219
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['"chat" is an object (or false for no chat)'];
209
220
  const out = [];
210
221
  if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
222
+ if (raw.history !== undefined && !(Number.isInteger(raw.history) && raw.history >= 0 && raw.history <= CHAT_LIMITS.historyMax)) out.push(`chat.history is a number of days, 0 to ${CHAT_LIMITS.historyMax} (0 keeps nothing past the room's few minutes)`);
211
223
  for (const k of ['who', 'react']) if (raw[k] !== undefined && !CHAT_WHO.includes(raw[k])) out.push(`chat.${k} is anyone, signed-in or members`);
212
224
  // Where chat sits on the screen is the page's (game.json "screen": { "chat" }), never the room's rules.
213
225
  for (const k of ['corner', 'place', 'at']) if (raw[k] !== undefined && (k !== 'at' || typeof raw.at === 'string')) out.push(`chat.${k}: where chat sits on the screen goes in "screen": { "chat": { "at": … } }`);
@@ -67,6 +67,8 @@ export function robotsTxt(cat, origin, { preview = false } = {}) {
67
67
  'Disallow: /shop/thanks',
68
68
  'Disallow: /shop/parent/',
69
69
  'Disallow: /__homie',
70
+ // The Lounge's socket and APIs (0.29.0); its page may be crawled.
71
+ ...(cat.studio?.lounge ? ['Disallow: /lounge/api/', 'Disallow: /lounge/__'] : []),
70
72
  ...games.flatMap((id) => [`Disallow: /${id}/play`, `Disallow: /${id}/tv`, `Disallow: /${id}/watch`, `Disallow: /${id}/live`, `Disallow: /${id}/invite`, `Disallow: /${id}/api/`, `Disallow: /${id}/__`, `Disallow: /${id}/s/*/play`, `Disallow: /${id}/s/*/tv`, `Disallow: /${id}/s/*/home`]),
71
73
  '',
72
74
  `Sitemap: ${origin}/sitemap.xml`,
@@ -96,6 +98,8 @@ export function sitemapEntries(cat, { all = null } = {}) {
96
98
  if (songs.length) { add('/music/', newest(songs.map(mediaDate))); for (const e of songs) add(`/music/${e.slug}/`, mediaDate(e)); }
97
99
  if (videos.length) { add('/videos/', newest(videos.map(mediaDate))); for (const e of videos) add(`/videos/${e.slug}/`, mediaDate(e)); }
98
100
  if (posts.length) { add('/posts/', newest(posts.map(postDate))); for (const p of posts) add(`/posts/${p.slug}/`, postDate(p)); }
101
+ // The Lounge (0.29.0), when studio.json turns it on: its page (what is said there is not in the page).
102
+ if (cat.studio?.lounge) add('/lounge/');
99
103
  for (const p of ownPages(cat, all)) add(p);
100
104
  return out;
101
105
  }
@@ -210,6 +214,7 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
210
214
  }
211
215
  out.push('', '## Optional', '');
212
216
  if (games.length) out.push(`- [Rooms](${origin}/rooms/): every public room playing now (${origin}/api/rooms is the same as JSON)`);
217
+ if (cat.studio?.lounge) out.push(`- [${label(cat.studio.lounge.name ?? 'The Lounge')}](${origin}/lounge/): the studio's community room: play nights, live rooms and chat (${origin}/lounge/api/now is its play nights and rules as JSON; what people say there is not published)`);
213
218
  out.push(`- [Studio manifest](${origin}/.well-known/homie-studio.json): the games, songs, videos and posts as JSON`);
214
219
  out.push(`- [Sitemap](${origin}/sitemap.xml): every public page`);
215
220
  if (!full) out.push(`- [Everything, in full](${origin}/llms-full.txt): each game's whole description, how to play and credits, and every post's text`);
package/worker/index.mjs CHANGED
@@ -92,6 +92,8 @@ import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts
92
92
  import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
93
93
  import { REACTIONS, reviewChat } from './chat.mjs';
94
94
  import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
95
+ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs';
96
+ import { isLoungePath, loungeRoutes } from './lounge.mjs';
95
97
  import { doorPage, serverPage, serversPage } from './site.mjs';
96
98
  import { isLocalOrigin, qrSvg } from './qr.mjs';
97
99
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
@@ -601,6 +603,11 @@ async function route(request, env, ctx) {
601
603
  if (shopped) return shopped;
602
604
  }
603
605
  if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
606
+ // The Lounge (0.29.0, chat/LOUNGE.md): the studio's own community room, when studio.json turns it on.
607
+ if (isLoungePath(path)) {
608
+ const lounged = await loungeRoutes(request, env, ctx, url, { catalogueOf: getCat, roomsOf });
609
+ if (lounged) return lounged;
610
+ }
604
611
  if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
605
612
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
606
613
  if (path === '/api/stats') {
@@ -658,6 +665,8 @@ async function route(request, env, ctx) {
658
665
  // The studio's posts, for the hub (the full text is in /posts/feed.json).
659
666
  posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
660
667
  ...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
668
+ // The studio's Lounge (0.29.0), for a directory that shows lounges: its page and its public facts.
669
+ ...(cat.studio?.lounge ? { lounge: { name: cat.studio.lounge.name, page: `${url.origin}/lounge/`, now: `${url.origin}/lounge/api/now` } } : {}),
661
670
  // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
662
671
  ...(played ? { played } : {}),
663
672
  // The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
@@ -1200,11 +1209,30 @@ export class Table {
1200
1209
  this.vocabRead = this.readVocab(game).catch(() => false);
1201
1210
  // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
1202
1211
  this.room.review = (text, opts) => this.reviewChat(text, opts);
1212
+ // Kept chat (0.29.0): a room whose rules keep `history` writes what was said to D1 and deletes what is taken down,
1213
+ // one after another (a line removed the moment it went out is never written after its delete).
1214
+ this.room.onPublished = (rec) => this.keptQueue(() => keepLines(this.env, this.game, this.code, [rec]));
1215
+ this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
1203
1216
  // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
1204
1217
  this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1205
1218
  return this.room;
1206
1219
  }
1207
1220
 
1221
+ /** Kept chat's writes, in order, each finished before the next (D1 calls from one room never pass each other). */
1222
+ keptQueue(fn) {
1223
+ if (!this.env.DB) return;
1224
+ this.kept = (this.kept ?? Promise.resolve()).then(fn).catch((error) => { try { console.log(JSON.stringify({ ev: 'chat-history-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ } });
1225
+ this.ctx.waitUntil(this.kept);
1226
+ }
1227
+
1228
+ /** A room that keeps history puts what it kept back in its window before its first page opens (once per opening). */
1229
+ async hydrateHistory(room) {
1230
+ const days = room.chatRules().history;
1231
+ if (!(days > 0) || room.hydrated || !this.env.DB) return;
1232
+ room.hydrated = true;
1233
+ try { await this.kept; room.hydrate(await historyOf(this.env, this.game, this.code, { days })); } catch { /* the window alone (before migration 0009) */ }
1234
+ }
1235
+
1208
1236
  /** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
1209
1237
  async decideOn() {
1210
1238
  if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
@@ -1411,14 +1439,16 @@ export class Table {
1411
1439
  // A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
1412
1440
  if (url.pathname === '/__chat') {
1413
1441
  const id = url.searchParams.get('id');
1414
- const r = room.chatLog.find((x) => x.id === id);
1415
- return json({ ok: Boolean(r), line: r ? { id: r.id, kind: r.kind, text: r.text ?? null, glyph: r.glyph ?? null, name: r.name, seat: r.seat, at: r.at, by: r.by, player: r.from?.player ?? null } : null });
1442
+ // A line older than the window is in the room's kept history, when it keeps one (0.29.0).
1443
+ const r = room.chatLog.find((x) => x.id === id) ?? (room.chatRules().history > 0 ? await keptLine(this.env, this.game, this.code, id) : null);
1444
+ return json({ ok: Boolean(r), line: r ? { id: r.id, kind: r.kind, text: r.text ?? null, glyph: r.glyph ?? null, name: r.name, seat: r.seat, at: r.at, by: r.by, player: r.from?.player ?? null, owner: Boolean(r.owner), mod: Boolean(r.mod) } : null });
1416
1445
  }
1417
1446
  if (url.pathname === '/__office') {
1418
1447
  const ctl = await request.json().catch(() => null);
1419
1448
  const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
1420
1449
  if (why) return json({ ok: false, error: 'refused', why }, 403);
1421
- const res = room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
1450
+ // A Lounge card waits for the studio's review (0.29.0); every other control answers at once.
1451
+ const res = await room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
1422
1452
  // AI talk turned off (a policy) or a guide kicked: the house guides follow at once.
1423
1453
  if (this.house) this.house.sync();
1424
1454
  await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
@@ -1436,6 +1466,8 @@ export class Table {
1436
1466
  if (pol) room.setPolicy(pol);
1437
1467
  if (this.house) this.house.sync();
1438
1468
  const agent = decodeFacts(url.searchParams.get('ag'));
1469
+ // Kept chat (0.29.0): the room's history is back in its window before this page hears it.
1470
+ await this.hydrateHistory(room);
1439
1471
  const [client, server] = Object.values(new WebSocketPair());
1440
1472
  server.accept();
1441
1473
  const via = url.searchParams.get('via');
@@ -1459,6 +1491,9 @@ export class Table {
1459
1491
  acct: url.searchParams.get('acct') === '1',
1460
1492
  member: url.searchParams.get('mem') === '1',
1461
1493
  hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
1494
+ // The Lounge (0.29.0): the signed-in account's name and a moderator's mark, the Worker's word on a watch socket.
1495
+ ...(url.pathname === '/__watch' && url.searchParams.get('nm') ? { name: String(url.searchParams.get('nm')).slice(0, 40) } : {}),
1496
+ ...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
1462
1497
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
1463
1498
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
1464
1499
  buffered: () => 0,