@homie-rocks/studio 0.22.0 → 0.23.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.
package/worker/index.mjs CHANGED
@@ -51,6 +51,9 @@
51
51
  * /account/ a player's account: a passkey, a name, their data (worker/players.mjs, saves/SAVES.md)
52
52
  * /api/player/... sign in and up, saves, lifetime stats and memorials (the game's saves bridge, via the
53
53
  * play shell), export and delete
54
+ * /<game>/api/chat/report a player reports one chat line (POST, this site's pages): the room's own copy of it is kept
55
+ * for the owner, 30 days (worker/chat-store.mjs); room chat itself rides the sockets above
56
+ * (NETPLAY.md section 19) and is never stored
54
57
  *
55
58
  * A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
56
59
  * `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
@@ -80,6 +83,8 @@ import {
80
83
  } from './servers.mjs';
81
84
  import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
82
85
  import { talks } from './brain.mjs';
86
+ import { REACTIONS, reviewChat } from './chat.mjs';
87
+ import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
83
88
  import { doorPage, serverPage, serversPage } from './site.mjs';
84
89
  import { isLocalOrigin, qrSvg } from './qr.mjs';
85
90
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
@@ -232,6 +237,8 @@ function named(cat, env) {
232
237
  async function roomsOf(env, games, { servers = null } = {}) {
233
238
  const rooms = [];
234
239
  const live = {};
240
+ // Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
241
+ const chatRows = await chatRowsOf(env);
235
242
  await Promise.all(games.map(async (g) => {
236
243
  let list = [];
237
244
  try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
@@ -242,7 +249,8 @@ async function roomsOf(env, games, { servers = null } = {}) {
242
249
  const sid = r.server ?? roomServer(r.name);
243
250
  const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
244
251
  if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
245
- rooms.push(roomView(g, r, seatsOf(g), srv));
252
+ const chat = chatOf(g, srv, chatRows);
253
+ rooms.push(roomView(g, r, seatsOf(g), srv, { chat: chat.mode !== 'off' && chat.hub }));
246
254
  }
247
255
  }));
248
256
  rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
@@ -381,6 +389,30 @@ function mediaRow(e, origin, kind, cat) {
381
389
  * wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
382
390
  */
383
391
  const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
392
+ /**
393
+ * Who a browser is to a room's chat (section 19): signed in with a passkey (an account, not a guest), and a member of the
394
+ * server (on the public server, any signed-in account). One D1 read for a signed-in player, none for a guest.
395
+ */
396
+ async function chatWho(env, game, srv, holder) {
397
+ const pid = holders(holder).find((x) => x.startsWith('p-'))?.slice(2) ?? null;
398
+ if (!pid || !env.DB) return { acct: false, member: false };
399
+ let acct = false;
400
+ try { acct = Number((await env.DB.prepare('SELECT guest FROM players WHERE id = ?1').bind(pid).first())?.guest) === 0; } catch { acct = false; }
401
+ const member = acct && (!srv || srv.id === 'public' || Boolean(await memberOf(env, game, srv.id, pid)));
402
+ return { acct, member };
403
+ }
404
+
405
+ /** Reports a browser (and its address) may make: 8 in 10 minutes, counted in this Worker's memory and forgotten. */
406
+ const reportsMade = new Map();
407
+ function reportAllowed(key, now = Date.now()) {
408
+ const list = (reportsMade.get(key) ?? []).filter((at) => now - at < 10 * 60_000);
409
+ if (list.length >= 8) { reportsMade.set(key, list); return false; }
410
+ list.push(now);
411
+ reportsMade.set(key, list);
412
+ if (reportsMade.size > 5000) for (const [k, v] of reportsMade) if (!v.some((at) => now - at < 10 * 60_000)) reportsMade.delete(k);
413
+ return true;
414
+ }
415
+
384
416
  function notAHomie(request) {
385
417
  if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
386
418
  return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
@@ -419,6 +451,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
419
451
  ...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
420
452
  // The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
421
453
  ...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
454
+ // Room chat (section 19): this browser hides chat ("Show chat" off), or its player's own lines stay off their character.
455
+ ...(url.searchParams.get('chat') === '0' ? { chatOff: true } : {}),
456
+ ...(url.searchParams.get('bub') === '0' ? { bubbleOff: true } : {}),
422
457
  ...(device ? { device } : {}),
423
458
  want,
424
459
  ...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
@@ -854,7 +889,7 @@ async function route(request, env, ctx) {
854
889
  }
855
890
  await countVisit(request, env, ctx, game, 'play');
856
891
  shareDaily(cat, url, ctx);
857
- return playPage(cat, meta, { ticket, owner: d.owner, launch, server });
892
+ return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)) });
858
893
  }
859
894
  if (sub === 'watch') {
860
895
  // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
@@ -871,7 +906,7 @@ async function route(request, env, ctx) {
871
906
  const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
872
907
  await countVisit(request, env, ctx, game, 'watch');
873
908
  shareDaily(cat, url, ctx);
874
- return watchPage(cat, meta, { room: asked, ticket, policy });
909
+ return watchPage(cat, meta, { room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
875
910
  }
876
911
  if (sub === 'api/watch') {
877
912
  if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
@@ -912,6 +947,27 @@ async function route(request, env, ctx) {
912
947
  },
913
948
  });
914
949
  }
950
+ if (sub === 'api/chat/report') {
951
+ // A player reports one chat line (section 19): the room's own copy of it is what is kept, never the reporter's words.
952
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'a report comes from this site\'s own pages' }, 403);
953
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
954
+ let body = {};
955
+ try { body = JSON.parse((await request.text()).slice(0, 2048) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
956
+ const room = String(body.room ?? '');
957
+ const id = String(body.id ?? '');
958
+ if (!ROOM_ID.test(room) || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return json({ ok: false, error: 'bad-request', message: 'room and id name the message' }, 400);
959
+ const reason = REPORT_REASONS.includes(body.reason) ? body.reason : 'other';
960
+ const key = `${BROWSER_KEY.test(String(body.b ?? '')) ? body.b : ''}|${request.headers.get('cf-connecting-ip') ?? ''}`;
961
+ if (!reportAllowed(key)) return json({ ok: false, error: 'rate', message: 'That is a lot of reports: the studio has them. Try again in a few minutes.' }, 429);
962
+ let line = null;
963
+ try { line = (await (await env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__chat?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}&op=line&id=${encodeURIComponent(id)}`)).json()).line ?? null; } catch { line = null; }
964
+ if (!line) return json({ ok: false, error: 'gone', message: 'That message is no longer in the room.' }, 404);
965
+ if (line.by === 'studio') return json({ ok: false, error: 'studio', message: 'That is the studio\'s own line.' }, 400);
966
+ try { await fileReport(env, { game, room, line, reason }); } catch (error) {
967
+ return json({ ok: false, error: 'not-migrated', message: `Reports need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
968
+ }
969
+ return json({ ok: true, message: 'Thanks for telling the studio. They will look at it.' });
970
+ }
915
971
  if (sub === '__net' || sub === '__watch') {
916
972
  if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
917
973
  const room = url.searchParams.get('room') || 'main';
@@ -942,8 +998,15 @@ async function route(request, env, ctx) {
942
998
  const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
943
999
  if (!access.ok) return new Response('this server is not open to you', { status: 403 });
944
1000
  }
1001
+ // Room chat (section 19): the room's rules, and who this socket is to them: an account with a passkey (not a
1002
+ // guest), a member of this server, or another site's page watching from elsewhere (homie.rocks's room page).
1003
+ const chat = chatOf(meta, srv, await chatRowsOf(env));
1004
+ const { acct, member } = await chatWho(env, game, srv, who);
1005
+ const origin = request.headers.get('origin');
1006
+ const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
1007
+ const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
945
1008
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
946
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
1009
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
947
1010
  return stub.fetch(new Request(target, request));
948
1011
  }
949
1012
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
@@ -1031,6 +1094,12 @@ export class Table {
1031
1094
  this.brainUsed = { neurons: 0, micros: 0 };
1032
1095
  this.brainPending = { calls: {}, neurons: 0, micros: 0 };
1033
1096
  this.brainReadAt = 0;
1097
+ /** Room chat's review (section 19): the studio's day (budget and use, read at most once a minute) and what is not counted yet. */
1098
+ this.chatCap = null;
1099
+ this.chatUsed = 0;
1100
+ this.chatReadAt = 0;
1101
+ this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
1102
+ this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
1034
1103
  ctx.blockConcurrencyWhile(async () => {
1035
1104
  this.saved = (await ctx.storage.get('net')) ?? null;
1036
1105
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
@@ -1055,9 +1124,63 @@ export class Table {
1055
1124
  if (this.saved) this.room.restore(this.saved);
1056
1125
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1057
1126
  this.vocabRead = this.readVocab(game).catch(() => false);
1127
+ // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
1128
+ this.room.review = (text, opts) => this.reviewChat(text, opts);
1058
1129
  return this.room;
1059
1130
  }
1060
1131
 
1132
+ /**
1133
+ * One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
1134
+ * budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
1135
+ * floor alone has decided (`by` says which), and the message goes out.
1136
+ */
1137
+ async reviewChat(text, { links = 'block' } = {}) {
1138
+ if (!this.env.AI) return { ok: true, by: 'none' };
1139
+ if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
1140
+ if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
1141
+ try {
1142
+ const v = await reviewChat(this.env, text, { links });
1143
+ this.chatUsed += v.neurons;
1144
+ this.chatPend.neurons += v.neurons;
1145
+ this.chatPend[v.ok ? 'ok' : 'held'] += 1;
1146
+ return { ok: v.ok, by: 'ai', why: v.why, ms: v.ms, p: v.p };
1147
+ } catch (error) {
1148
+ this.chatPend.error += 1;
1149
+ try { console.log(JSON.stringify({ ev: 'chat-review-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ }
1150
+ return { ok: true, by: 'error' };
1151
+ }
1152
+ }
1153
+
1154
+ async readChatDay() {
1155
+ const day = await chatDay(this.env, today());
1156
+ this.chatCap = day.budget.neurons;
1157
+ this.chatUsed = day.used.neurons + this.chatPend.neurons;
1158
+ this.chatReadAt = Date.now();
1159
+ }
1160
+
1161
+ /** Chat's counts into D1 (stats_daily): lines, reactions, held by why, reviews and neurons. Never a message or a sender. */
1162
+ async flushChat() {
1163
+ const s = this.room?.stats;
1164
+ if (!s || !this.env.DB) return;
1165
+ const game = this.game ?? '';
1166
+ const rows = [];
1167
+ for (const [k, metric] of [['chatLines', 'chat-lines'], ['chatReacts', 'chat-reacts']]) {
1168
+ const d = (s[k] ?? 0) - (this.chatSent[k] ?? 0);
1169
+ if (d > 0) { rows.push(counter(this.env, { metric, subject: game, n: d })); this.chatSent[k] = s[k]; }
1170
+ }
1171
+ for (const [why, n] of Object.entries(s.chatHeld ?? {})) {
1172
+ const d = n - (this.chatSent.held[why] ?? 0);
1173
+ if (d > 0) { rows.push(counter(this.env, { metric: 'chat-held', subject: game, source: why, n: d })); this.chatSent.held[why] = n; }
1174
+ }
1175
+ const p = this.chatPend;
1176
+ for (const k of ['ok', 'held', 'error']) if (p[k]) rows.push(counter(this.env, { metric: 'chat-reviews', subject: game, source: k, n: p[k] }));
1177
+ const neurons = Math.floor(p.neurons);
1178
+ if (neurons) rows.push(counter(this.env, { metric: 'chat-neurons', subject: game, n: neurons }));
1179
+ this.chatPend = { neurons: p.neurons - neurons, ok: 0, held: 0, error: 0 };
1180
+ const list = rows.filter(Boolean);
1181
+ if (list.length) await this.env.DB.batch(list).catch(() => {});
1182
+ }
1183
+
1061
1184
  /** The game's agents.json from the build (the only words an AI in this room may say), once. */
1062
1185
  async readVocab(game) {
1063
1186
  if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
@@ -1156,6 +1279,12 @@ export class Table {
1156
1279
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
1157
1280
  // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
1158
1281
  if (url.pathname === '/__facts') return json({ ...room.officeFacts(), ...(this.house ? { brains: this.house.facts() } : {}) });
1282
+ // 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.
1283
+ if (url.pathname === '/__chat') {
1284
+ const id = url.searchParams.get('id');
1285
+ const r = room.chatLog.find((x) => x.id === id);
1286
+ 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 });
1287
+ }
1159
1288
  if (url.pathname === '/__office') {
1160
1289
  const ctl = await request.json().catch(() => null);
1161
1290
  const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
@@ -1172,6 +1301,9 @@ export class Table {
1172
1301
  if (room.seatCap !== max) room.setSeats(max, perAddress(max));
1173
1302
  // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
1174
1303
  const pol = decodeFacts(url.searchParams.get('pol'));
1304
+ // The room's chat rules ride beside it (they may be longer than a policy): section 19.
1305
+ const chatRules = decodeFacts(url.searchParams.get('chat'), 16384);
1306
+ if (pol && chatRules && typeof chatRules === 'object') pol.chat = chatRules;
1175
1307
  if (pol) room.setPolicy(pol);
1176
1308
  if (this.house) this.house.sync();
1177
1309
  const agent = decodeFacts(url.searchParams.get('ag'));
@@ -1191,11 +1323,20 @@ export class Table {
1191
1323
  watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
1192
1324
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
1193
1325
  qa: isQa(request),
1326
+ // Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
1327
+ // another site (homie.rocks's room page) watching from elsewhere.
1328
+ acct: url.searchParams.get('acct') === '1',
1329
+ member: url.searchParams.get('mem') === '1',
1330
+ hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
1194
1331
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
1195
1332
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
1196
1333
  buffered: () => 0,
1197
1334
  };
1198
1335
  if (url.pathname === '/__watch') {
1336
+ // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
1337
+ // (room chat, 0.23.0: another site's page can open one too).
1338
+ const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
1339
+ if (room.watchers.size >= 256 || same >= 24) { try { server.close(1013, 'too many watching'); } catch { /* gone */ } return new Response(null, { status: 101, webSocket: client }); }
1199
1340
  const w = room.watch(conn);
1200
1341
  // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
1201
1342
  server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
@@ -1247,8 +1388,10 @@ export class Table {
1247
1388
  if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
1248
1389
  n += 1;
1249
1390
  if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
1391
+ // Room chat's counts, once a minute (section 19).
1392
+ if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
1250
1393
  if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
1251
- if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
1394
+ if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); this.ctx.waitUntil(this.flushChat().catch(() => {})); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
1252
1395
  }, 250);
1253
1396
  }
1254
1397
 
@@ -105,6 +105,16 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
105
105
  .warnbox { margin: 10px 16px; padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(255,179,92,.45); color: var(--warn); font-size: 14px; }
106
106
  .secret { font: 600 13px/1.4 ui-monospace, Menlo, monospace; word-break: break-all; padding: 10px; border-radius: 10px; background: var(--bg); border: 1px solid var(--warn); }
107
107
  .chip.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
108
+ /* Room chat (0.23.0): a room's last minutes, and each game's rules and reports. */
109
+ .chatlog { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg); border: 1px solid var(--line); }
110
+ .chatlog h4 { margin: 0 0 6px; font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--dim); }
111
+ .chatlog .cl { display: grid; grid-template-columns: minmax(80px, auto) minmax(0, 1fr) auto auto; gap: 4px 10px; align-items: center; padding: 6px 0; border-top: 1px dashed var(--line); font-size: 14px; }
112
+ .chatlog .cl:first-of-type { border-top: 0; }
113
+ .chatlog .cl .txt { overflow-wrap: anywhere; }
114
+ .chatlog .cl.react .txt { font-size: 18px; }
115
+ .chatlog .cl.studio b { color: var(--accent); }
116
+ .chatbox .inv span { overflow-wrap: anywhere; }
117
+ @media (max-width: 720px) { .chatlog .cl { grid-template-columns: 1fr; } }
108
118
  /* The New server form: one panel, grouped, every control labelled, help under it in the office's quiet grey. */
109
119
  .newsrv { display: grid; gap: 16px; max-width: 820px; margin-top: 10px; padding: 16px; border-radius: 14px; background: var(--panel2); border: 1px solid var(--line); }
110
120
  .newsrv [hidden] { display: none !important; }
@@ -588,6 +598,103 @@ export const OFFICE_SCRIPT = String.raw`(function () {
588
598
  return box;
589
599
  }
590
600
 
601
+ /* ---------------------------------------------------------------- room chat (0.23.0, NETPLAY.md section 19) */
602
+ var CHAT_MODES = [['off', 'Off'], ['emoji', 'Emoji'], ['lines', 'Emoji + quick lines'], ['text', 'Typing too']];
603
+ var CHAT_WHO = [['anyone', 'Anyone'], ['signed-in', 'Signed in'], ['members', 'Members']];
604
+ var CHAT_MODE_HELP = { off: 'No chat at all in this game\'s rooms.', emoji: 'Players and watchers send the five reactions (and the game\'s own); they float up every screen.', lines: 'Reactions and the game\'s own quick lines ("Good game!"): nothing anyone types. What a kids or beginner server always keeps to.', text: 'Reactions, quick lines and typed messages. Every typed message passes the word list first, and this studio\'s Workers AI when the review is on.' };
605
+ function ago(ms) { var s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? s + ' s ago' : Math.round(s / 60) + ' min ago'; }
606
+ /** The game's chat rules (for the whole game, or one server), the review's day, and the reports players made. */
607
+ function chatBox(g) {
608
+ var c = g.chat || {};
609
+ var box = el('div', 'servers chatbox');
610
+ var key = g.id + '/chat';
611
+ var d = S.drafts[key] || (S.drafts[key] = { server: '' });
612
+ var target = d.server ? ((c.servers || []).filter(function (x) { return x.id === d.server; })[0] || {}) : null;
613
+ var r = Object.assign({}, (target && target.rules) || c.rules || {}, d.edit || {});
614
+ var head = el('h3', '', 'Room chat');
615
+ var summary = el('div', 'line dim');
616
+ var from = c.from === 'office' ? 'your rules' : c.from === 'game.json' ? 'the game\'s game.json' : 'Homie\'s defaults';
617
+ summary.textContent = (c.rules ? ({ off: 'Off', emoji: 'Emoji only', lines: 'Emoji and quick lines', text: 'Typing allowed' }[c.rules.mode] || c.rules.mode) : '–') + (c.rules && c.rules.mode === 'text' ? ' · typing: ' + c.rules.who + (c.rules.ai ? ' · AI review on' : ' · word list only') : '') + ' · from ' + from + '.';
618
+ add(box, head, summary);
619
+ if (!S.open[key]) {
620
+ add(box, add(el('div', 'line'), btn('Change chat rules', 'ghost small', function () { S.open[key] = true; render(); })));
621
+ } else {
622
+ var form = el('form', 'newsrv');
623
+ var scope = sel([['', 'The whole game']].concat((g.servers || []).map(function (x) { return [x.id, 'Server: ' + x.name]; })), d.server, 'Rules for');
624
+ scope.onchange = function () { d.server = scope.value; d.edit = {}; render(); };
625
+ var set = function (k, v) { d.edit = d.edit || {}; d.edit[k] = v; };
626
+ var mode = segment(g.id + 'cmode', CHAT_MODES, r.mode, 'What chat allows', function (v) { set('mode', v); help.textContent = CHAT_MODE_HELP[v]; });
627
+ var help = el('p', 'help', CHAT_MODE_HELP[r.mode] || '');
628
+ var who = segment(g.id + 'cwho', CHAT_WHO, r.who, 'Who may type', function (v) { set('who', v); });
629
+ var react = segment(g.id + 'creact', CHAT_WHO, r.react, 'Who may react', function (v) { set('react', v); });
630
+ var slow = num(r.slow === undefined ? 2 : r.slow, 0, 120, 'Slow mode, seconds'); slow.onchange = function () { set('slow', Number(slow.value)); };
631
+ var max = num(r.max || 140, 20, 280, 'Longest message'); max.onchange = function () { set('max', Number(max.value)); };
632
+ var check = function (k, label, on) { var x = el('input'); x.type = 'checkbox'; x.checked = Boolean(on); x.onchange = function () { set(k, x.checked); }; return add(el('label', 'check'), x, el('span', '', label)); };
633
+ var words = el('input'); words.type = 'text'; words.placeholder = 'Words to hold, comma separated (word* holds it inside words too)'; words.value = (d.edit && d.edit.block !== undefined ? d.edit.block : (target ? (target.office || {}).block : (c.office || {}).block) || []).join(', ');
634
+ words.onchange = function () { set('block', words.value.split(',').map(function (w) { return w.trim(); }).filter(Boolean)); };
635
+ add(form,
636
+ row('Rules for', null, scope, el('p', 'help', 'A server\'s rules sit over the game\'s; a kids or beginner server always keeps chat to emoji and quick lines.')),
637
+ row('Chat', null, mode.box, help),
638
+ row('Who may type', null, who.box, el('p', 'help', 'Signed in: a player account with a passkey (no password, no email). Members: players who belong to the room\'s server.')),
639
+ row('Emoji and lines', null, react.box, el('p', 'help', 'Who may send reactions and quick lines. Anyone keeps the room lively for guests.')),
640
+ row('Slow mode', null, add(el('div', 'line'), slow, el('span', 'dim', 'seconds between one person\'s messages')), null),
641
+ row('Longest message', null, add(el('div', 'line'), max, el('span', 'dim', 'characters')), null),
642
+ row('Checks', null, add(el('div', 'ctl'),
643
+ check('ai', 'Review typed messages with this studio\'s Workers AI (Cloudflare\'s Clef decision model)', r.ai !== false),
644
+ check('links', 'Allow links (they are never clickable)', r.links === 'allow'),
645
+ check('swears', 'Allow swearing (slurs, sexual words and threats are always held)', r.swears === 'allow')), el('p', 'help', 'The word list always runs, and needs nothing. The review adds what a list misses (bullying, a stranger asking a kid where they live), within a day\'s budget of Cloudflare\'s free allocation; past it, the word list alone decides.')),
646
+ row('Your words', null, words, null),
647
+ row('Shown', null, add(el('div', 'ctl'),
648
+ check('bubbles', 'Over the speaker\'s character, in games that draw it', r.bubbles !== false),
649
+ check('watchers', 'Watchers may send too (not just read)', r.watchers !== false),
650
+ check('hub', 'On homie.rocks\'s page for the room', r.hub !== false)), null));
651
+ var acts = el('div', 'acts');
652
+ add(acts,
653
+ btn('Save', '', function () {
654
+ var body = Object.assign({ game: g.id }, d.server ? { server: d.server } : {}, d.edit || {});
655
+ if (Object.keys(d.edit || {}).length === 0) { toast('Nothing changed.'); return; }
656
+ act('/_studio/api/chat/rules', body, 'Chat rules saved: every live room has them now.').then(function (res) { if (res && res.ok) { d.edit = {}; S.open[key] = false; } });
657
+ }),
658
+ btn('Back to the game\'s own', 'ghost', function () { act('/_studio/api/chat/rules', Object.assign({ game: g.id, reset: true }, d.server ? { server: d.server } : {}), 'Back to the game\'s own chat rules.').then(function () { d.edit = {}; }); }),
659
+ btn('Close', 'ghost', function () { S.open[key] = false; d.edit = {}; render(); }));
660
+ form.appendChild(acts);
661
+ form.onsubmit = function (e) { e.preventDefault(); };
662
+ box.appendChild(form);
663
+ }
664
+ // What players reported: one message each, kept 30 days or until you dismiss it.
665
+ var reps = c.reports || [];
666
+ if (reps.length) {
667
+ add(box, el('h3', '', 'Reports (' + reps.length + ')'));
668
+ reps.forEach(function (p) {
669
+ var rowEl = el('div', 'inv');
670
+ add(rowEl, el('b', '', p.name || 'Someone'), el('span', '', '"' + p.text + '"'), el('span', 'chip muted', p.reasonText || p.reason), el('span', 'faint', p.room + ' · ' + ago(Date.now() - (S.skew || 0) - p.at)), p.player ? el('span', 'faint', 'account ' + p.player.slice(0, 10) + '…') : null,
671
+ btn('Dismiss', 'ghost small', function () { act('/_studio/api/chat/report', { id: p.id }, 'Report dismissed (deleted).'); }));
672
+ box.appendChild(rowEl);
673
+ });
674
+ }
675
+ return box;
676
+ }
677
+ /** A room's last minutes of chat, with Remove, Mute and Kick from a line. */
678
+ function roomChat(g, r, now) {
679
+ var ch = r.chat;
680
+ if (!ch || !(ch.lines || []).length) return null;
681
+ var box = el('div', 'chatlog');
682
+ add(box, el('h4', '', 'Chat · last ' + ch.lines.length + (ch.lines.length === 1 ? ' message' : ' messages')));
683
+ ch.lines.slice().reverse().slice(0, 20).forEach(function (l) {
684
+ var rowEl = el('div', 'cl' + (l.kind === 'react' ? ' react' : '') + (l.by === 'studio' ? ' studio' : ''));
685
+ add(rowEl, el('b', '', l.by === 'studio' ? 'Studio' : l.name), el('span', 'txt', l.kind === 'react' ? (l.glyph || '') : (l.text || '')),
686
+ el('span', 'faint', ago(now - l.at) + (l.by === 'watcher' ? ' · watching' : l.by === 'hub' ? ' · on homie.rocks' : l.seat !== null && l.seat !== undefined ? ' · seat ' + (l.seat + 1) : '') + (l.review && l.review.by === 'ai' ? ' · reviewed' : '')));
687
+ if (l.by !== 'studio') {
688
+ add(rowEl, add(el('span', 'pacts'),
689
+ btn('Remove', 'ghost small', function () { act('/_studio/api/chat/remove', { game: g.id, room: r.room, id: l.id }, 'Removed from every screen.'); }),
690
+ btn('Mute', 'warn small', function () { act('/_studio/api/mute', { game: g.id, room: r.room, line: l.id, minutes: hold(), purge: true }, (l.name || 'They') + ' is muted for ' + hold() + ' min; their messages are down.'); }),
691
+ armed('Kick', 'bad', function () { act('/_studio/api/kick', { game: g.id, room: r.room, line: l.id, minutes: hold(), purge: true }, (l.name || 'They') + ' was removed from ' + r.label + ' for ' + hold() + ' min.'); })));
692
+ }
693
+ box.appendChild(rowEl);
694
+ });
695
+ return box;
696
+ }
697
+
591
698
  function person(g, r, c, now) {
592
699
  var row = el('div', 'person');
593
700
  var who = el('div', 'who');
@@ -649,6 +756,8 @@ export const OFFICE_SCRIPT = String.raw`(function () {
649
756
  people.appendChild(el('div', 'held', 'Guides\' brain: ' + (r.brains.brain || 'script') + (r.brains.why ? ' · ' + r.brains.why : '') + ' · ' + r.brains.calls + ' calls' + (last.length ? ' · ' + last.join('; ') : '')));
650
757
  }
651
758
  (r.bans || []).forEach(function (b) { people.appendChild(el('div', 'held', 'Kicked: ' + (b.name || 'someone') + ', may come back in ' + dur(b.until - now) + (b.address ? ' (their network too)' : ''))); });
759
+ var said = roomChat(g, r, now);
760
+ if (said) people.appendChild(said);
652
761
  box.appendChild(people);
653
762
  }
654
763
  return box;
@@ -662,6 +771,10 @@ export const OFFICE_SCRIPT = String.raw`(function () {
662
771
  document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
663
772
  var at = document.activeElement;
664
773
  var focus = at && root.contains(at) && (/INPUT|SELECT/.test(at.tagName) || Boolean(at.closest && at.closest('.newsrv')));
774
+ // The day's chat review across the studio: what it used of its budget, and what it held.
775
+ var cd = d.chat;
776
+ var chatEl = document.getElementById('chatday');
777
+ if (chatEl && cd) chatEl.textContent = 'Chat review today: ' + (cd.ai ? Math.round(cd.used.neurons).toLocaleString() + ' of ' + Math.round(cd.budget.neurons).toLocaleString() + ' neurons, ' + cd.used.reviews + ' reviewed, ' + cd.used.held + ' held' + (cd.used.errors ? ', ' + cd.used.errors + ' unanswered (the word list decided)' : '') : 'off (no Workers AI binding on this Worker yet: deploy once more; the word list decides meanwhile)') + '.';
665
778
  // Never redraw under a typing owner (or one working in the New server form), a control waiting for its second
666
779
  // tap, or a pass secret shown once.
667
780
  if (focus || root.querySelector('[data-armed="1"]') || root.querySelector('.secret')) return;
@@ -676,6 +789,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
676
789
  // A build from before servers (netplay rev 5 or older): what it cannot do yet, and the fix.
677
790
  if (g.build && g.build.predates && (g.servers || []).some(function (x) { return x.id !== 'public' && (x.aiSeats || x.guides); })) card.appendChild(el('div', 'warnbox', 'This build predates servers (netplay rev ' + (g.build.netplayRev || '5 or older') + '): reserved AI seats stay empty and its bots don\'t read the dial. Rebuild with @homie-rocks/studio 0.16.'));
678
791
  card.appendChild(serversBox(g));
792
+ card.appendChild(chatBox(g));
679
793
  card.appendChild(passes(g));
680
794
  if (g.launch === 'invite' || (g.invites && g.invites.length)) card.appendChild(invites(g));
681
795
  var rooms = el('div', 'rooms');
@@ -734,6 +848,7 @@ export async function officePage(cat, headers = {}) {
734
848
  <select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
735
849
  <label for="hold" style="margin-left:auto">Kick, mute and close for</label><select id="hold"><option value="5">5 min</option><option value="10" selected>10 min</option><option value="30">30 min</option><option value="60">1 hour</option><option value="1440">a day</option></select></form>
736
850
  <p class="dim" id="status" role="status"></p>
851
+ <p class="dim" id="chatday"></p>
737
852
  <div id="games" aria-live="polite"><p class="empty">Reading the rooms…</p></div>
738
853
  <div class="toast" id="toast" role="status" hidden></div>
739
854
  <p class="note">Servers: each game's named room pools. Open: anyone, AI with a pass marked AI. Humans only: no AI at all. Hybrid: some seats in every room are AI companions; the party votes their level. Beginner: new players, AI guides, quick lines only. Changing a server reaches its live rooms at once; AI leave after the round when a server becomes humans-only.</p>