@homie-rocks/studio 0.21.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +92 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +81 -2
  7. package/chat/CHAT.md +101 -0
  8. package/chat/OWNERS.md +78 -0
  9. package/lib/art-cli.mjs +296 -0
  10. package/lib/art-tools.mjs +331 -0
  11. package/lib/asset-check.mjs +189 -0
  12. package/lib/asset-import.mjs +136 -0
  13. package/lib/asset-manifest.mjs +318 -0
  14. package/lib/build.mjs +32 -2
  15. package/lib/chat-cli.mjs +89 -0
  16. package/lib/cloudflare.mjs +5 -2
  17. package/lib/codex.mjs +43 -1
  18. package/lib/colour.mjs +160 -0
  19. package/lib/decisions.mjs +800 -0
  20. package/lib/directory.mjs +9 -1
  21. package/lib/library.mjs +169 -0
  22. package/lib/mcp-tools.mjs +22 -4
  23. package/lib/mcp.mjs +6 -3
  24. package/lib/optimise.mjs +380 -0
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +39 -2
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +76 -0
  33. package/mcp/ui/card.css +15 -0
  34. package/mcp/ui/cast.js +62 -0
  35. package/mcp/ui/decision.js +61 -0
  36. package/mcp/ui/lineup.js +68 -0
  37. package/mcp/ui/rights.js +30 -0
  38. package/mcp/ui/style.js +154 -0
  39. package/netplay/NETPLAY.md +191 -3
  40. package/netplay/netplay.ts +150 -3
  41. package/package.json +15 -2
  42. package/port/index.ts +3 -2
  43. package/port/view.ts +238 -0
  44. package/site/SITE.md +11 -1
  45. package/starters/ember-vale/src/main.ts +22 -2
  46. package/starters/gem-rush/src/main.ts +21 -1
  47. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  48. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  49. package/starters/gem-rush-3d/credits.json +31 -0
  50. package/starters/gem-rush-3d/game.json +11 -0
  51. package/starters/gem-rush-3d/index.html +20 -0
  52. package/starters/gem-rush-3d/lab.json +19 -0
  53. package/starters/gem-rush-3d/src/main.ts +2107 -0
  54. package/starters/gem-rush-3d/style.json +66 -0
  55. package/starters/gem-rush-3d/tunables.json +13 -0
  56. package/worker/agents.mjs +3 -3
  57. package/worker/chat-page.mjs +556 -0
  58. package/worker/chat-store.mjs +161 -0
  59. package/worker/chat-words.mjs +46 -0
  60. package/worker/chat.mjs +419 -0
  61. package/worker/index.mjs +153 -8
  62. package/worker/office-page.mjs +115 -0
  63. package/worker/office.mjs +137 -6
  64. package/worker/pages.mjs +32 -9
  65. package/worker/qr.mjs +11 -0
  66. package/worker/room.mjs +288 -10
  67. package/worker/servers.mjs +5 -1
  68. package/worker/site.mjs +9 -4
  69. package/worker/stats.mjs +4 -1
  70. package/worker/version.mjs +1 -1
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,8 +83,10 @@ 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
- import { qrSvg } from './qr.mjs';
89
+ import { isLocalOrigin, qrSvg } from './qr.mjs';
85
90
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
86
91
  import {
87
92
  SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
@@ -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 } : {}),
@@ -846,13 +881,15 @@ async function route(request, env, ctx) {
846
881
  try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
847
882
  }
848
883
  const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
884
+ // Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
885
+ const local = isLocalOrigin(url.origin);
849
886
  let qr = null;
850
- try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
851
- return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner: d.owner, launch, server });
887
+ if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
888
+ return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server });
852
889
  }
853
890
  await countVisit(request, env, ctx, game, 'play');
854
891
  shareDaily(cat, url, ctx);
855
- 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)) });
856
893
  }
857
894
  if (sub === 'watch') {
858
895
  // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
@@ -869,7 +906,7 @@ async function route(request, env, ctx) {
869
906
  const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
870
907
  await countVisit(request, env, ctx, game, 'watch');
871
908
  shareDaily(cat, url, ctx);
872
- 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)) });
873
910
  }
874
911
  if (sub === 'api/watch') {
875
912
  if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
@@ -910,6 +947,27 @@ async function route(request, env, ctx) {
910
947
  },
911
948
  });
912
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
+ }
913
971
  if (sub === '__net' || sub === '__watch') {
914
972
  if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
915
973
  const room = url.searchParams.get('room') || 'main';
@@ -940,8 +998,15 @@ async function route(request, env, ctx) {
940
998
  const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
941
999
  if (!access.ok) return new Response('this server is not open to you', { status: 403 });
942
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)) };
943
1008
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
944
- 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)}` : ''}`;
945
1010
  return stub.fetch(new Request(target, request));
946
1011
  }
947
1012
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
@@ -1029,6 +1094,12 @@ export class Table {
1029
1094
  this.brainUsed = { neurons: 0, micros: 0 };
1030
1095
  this.brainPending = { calls: {}, neurons: 0, micros: 0 };
1031
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: {} };
1032
1103
  ctx.blockConcurrencyWhile(async () => {
1033
1104
  this.saved = (await ctx.storage.get('net')) ?? null;
1034
1105
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
@@ -1053,9 +1124,63 @@ export class Table {
1053
1124
  if (this.saved) this.room.restore(this.saved);
1054
1125
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1055
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);
1056
1129
  return this.room;
1057
1130
  }
1058
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
+
1059
1184
  /** The game's agents.json from the build (the only words an AI in this room may say), once. */
1060
1185
  async readVocab(game) {
1061
1186
  if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
@@ -1154,6 +1279,12 @@ export class Table {
1154
1279
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
1155
1280
  // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
1156
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
+ }
1157
1288
  if (url.pathname === '/__office') {
1158
1289
  const ctl = await request.json().catch(() => null);
1159
1290
  const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
@@ -1170,6 +1301,9 @@ export class Table {
1170
1301
  if (room.seatCap !== max) room.setSeats(max, perAddress(max));
1171
1302
  // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
1172
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;
1173
1307
  if (pol) room.setPolicy(pol);
1174
1308
  if (this.house) this.house.sync();
1175
1309
  const agent = decodeFacts(url.searchParams.get('ag'));
@@ -1189,11 +1323,20 @@ export class Table {
1189
1323
  watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
1190
1324
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
1191
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',
1192
1331
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
1193
1332
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
1194
1333
  buffered: () => 0,
1195
1334
  };
1196
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 }); }
1197
1340
  const w = room.watch(conn);
1198
1341
  // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
1199
1342
  server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
@@ -1245,8 +1388,10 @@ export class Table {
1245
1388
  if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
1246
1389
  n += 1;
1247
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(() => {}));
1248
1393
  if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
1249
- 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; } }
1250
1395
  }, 250);
1251
1396
  }
1252
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>