@homie-rocks/studio 0.22.0 → 0.24.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 (45) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +59 -0
  3. package/bin/homie-studio.mjs +68 -2
  4. package/chat/CHAT.md +101 -0
  5. package/chat/OWNERS.md +78 -0
  6. package/lib/build.mjs +20 -2
  7. package/lib/chat-cli.mjs +89 -0
  8. package/lib/cloudflare.mjs +5 -2
  9. package/lib/scaffold.mjs +59 -2
  10. package/lib/shop.mjs +270 -0
  11. package/lib/template-history.json +77 -0
  12. package/netplay/NETPLAY.md +194 -4
  13. package/netplay/netplay.ts +156 -3
  14. package/package.json +7 -1
  15. package/port/index.ts +3 -2
  16. package/port/view.ts +238 -0
  17. package/shop/SELLING.md +83 -0
  18. package/shop/SHOP.md +154 -0
  19. package/shop/shop.ts +130 -0
  20. package/site/SITE.md +22 -1
  21. package/starters/ember-vale/src/main.ts +22 -2
  22. package/starters/gem-rush/src/main.ts +21 -1
  23. package/starters/gem-rush-3d/src/main.ts +22 -1
  24. package/worker/account-page.mjs +14 -2
  25. package/worker/agents.mjs +3 -3
  26. package/worker/chat-page.mjs +556 -0
  27. package/worker/chat-store.mjs +161 -0
  28. package/worker/chat-words.mjs +46 -0
  29. package/worker/chat.mjs +419 -0
  30. package/worker/index.mjs +189 -7
  31. package/worker/office-page.mjs +120 -5
  32. package/worker/office.mjs +173 -6
  33. package/worker/pages.mjs +25 -8
  34. package/worker/players.mjs +8 -0
  35. package/worker/referrals.mjs +328 -0
  36. package/worker/room.mjs +291 -11
  37. package/worker/servers.mjs +5 -1
  38. package/worker/shop-page.mjs +532 -0
  39. package/worker/shop-rules.mjs +277 -0
  40. package/worker/shop-store.mjs +217 -0
  41. package/worker/shop.mjs +686 -0
  42. package/worker/site.mjs +4 -1
  43. package/worker/stats.mjs +4 -1
  44. package/worker/stripe.mjs +149 -0
  45. package/worker/version.mjs +1 -1
package/worker/index.mjs CHANGED
@@ -51,6 +51,13 @@
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
57
+ * /shop/, /api/shop/... the studio's shop (worker/shop.mjs, shop/SHOP.md): its items in real money, the studio's
58
+ * OWN Stripe Checkout, the signed webhook, what a player owns (/api/player/owns), refunds;
59
+ * closed until the owner's restricted key is in; never on a kids server or a television
60
+ * /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
54
61
  *
55
62
  * A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
56
63
  * `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
@@ -80,6 +87,8 @@ import {
80
87
  } from './servers.mjs';
81
88
  import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
82
89
  import { talks } from './brain.mjs';
90
+ import { REACTIONS, reviewChat } from './chat.mjs';
91
+ import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
83
92
  import { doorPage, serverPage, serversPage } from './site.mjs';
84
93
  import { isLocalOrigin, qrSvg } from './qr.mjs';
85
94
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
@@ -99,6 +108,8 @@ import {
99
108
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
100
109
  usePlayers(playerAccounts);
101
110
  import { STUDIO_VERSION_TAG } from './version.mjs';
111
+ import { roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
112
+ import { arrivalCookie, manifestReferrals } from './referrals.mjs';
102
113
  import { licenseOf, remixAllowed, remixRow } from './license.mjs';
103
114
 
104
115
  export { SEAT_MAX } from './seats.mjs';
@@ -232,6 +243,8 @@ function named(cat, env) {
232
243
  async function roomsOf(env, games, { servers = null } = {}) {
233
244
  const rooms = [];
234
245
  const live = {};
246
+ // Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
247
+ const chatRows = await chatRowsOf(env);
235
248
  await Promise.all(games.map(async (g) => {
236
249
  let list = [];
237
250
  try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
@@ -242,7 +255,8 @@ async function roomsOf(env, games, { servers = null } = {}) {
242
255
  const sid = r.server ?? roomServer(r.name);
243
256
  const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
244
257
  if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
245
- rooms.push(roomView(g, r, seatsOf(g), srv));
258
+ const chat = chatOf(g, srv, chatRows);
259
+ rooms.push(roomView(g, r, seatsOf(g), srv, { chat: chat.mode !== 'off' && chat.hub }));
246
260
  }
247
261
  }));
248
262
  rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
@@ -381,6 +395,30 @@ function mediaRow(e, origin, kind, cat) {
381
395
  * wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
382
396
  */
383
397
  const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
398
+ /**
399
+ * 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
400
+ * server (on the public server, any signed-in account). One D1 read for a signed-in player, none for a guest.
401
+ */
402
+ async function chatWho(env, game, srv, holder) {
403
+ const pid = holders(holder).find((x) => x.startsWith('p-'))?.slice(2) ?? null;
404
+ if (!pid || !env.DB) return { acct: false, member: false };
405
+ let acct = false;
406
+ try { acct = Number((await env.DB.prepare('SELECT guest FROM players WHERE id = ?1').bind(pid).first())?.guest) === 0; } catch { acct = false; }
407
+ const member = acct && (!srv || srv.id === 'public' || Boolean(await memberOf(env, game, srv.id, pid)));
408
+ return { acct, member };
409
+ }
410
+
411
+ /** Reports a browser (and its address) may make: 8 in 10 minutes, counted in this Worker's memory and forgotten. */
412
+ const reportsMade = new Map();
413
+ function reportAllowed(key, now = Date.now()) {
414
+ const list = (reportsMade.get(key) ?? []).filter((at) => now - at < 10 * 60_000);
415
+ if (list.length >= 8) { reportsMade.set(key, list); return false; }
416
+ list.push(now);
417
+ reportsMade.set(key, list);
418
+ if (reportsMade.size > 5000) for (const [k, v] of reportsMade) if (!v.some((at) => now - at < 10 * 60_000)) reportsMade.delete(k);
419
+ return true;
420
+ }
421
+
384
422
  function notAHomie(request) {
385
423
  if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
386
424
  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 +457,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
419
457
  ...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
420
458
  // The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
421
459
  ...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
460
+ // Room chat (section 19): this browser hides chat ("Show chat" off), or its player's own lines stay off their character.
461
+ ...(url.searchParams.get('chat') === '0' ? { chatOff: true } : {}),
462
+ ...(url.searchParams.get('bub') === '0' ? { bubbleOff: true } : {}),
422
463
  ...(device ? { device } : {}),
423
464
  want,
424
465
  ...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
@@ -426,6 +467,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
426
467
  ...(meta?.movement ? { movement: meta.movement } : {}),
427
468
  // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
428
469
  ...(meta?.saves && want === 'play' ? { saves: true } : {}),
470
+ // The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
471
+ // watcher has no shop; the shell itself says "kids" on a kids server and shows only a code on a television.
472
+ ...(!agent && !watching && shellShop(cat, game) ? { shop: true } : {}),
429
473
  };
430
474
  let html = await res.text();
431
475
  const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
@@ -504,7 +548,18 @@ function finish(res, path) {
504
548
 
505
549
  export default {
506
550
  async fetch(request, env, ctx) {
507
- return finish(await route(request, env, ctx), new URL(request.url).pathname);
551
+ const url = new URL(request.url);
552
+ const res = finish(await route(request, env, ctx), url.pathname);
553
+ // A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
554
+ // that pays referrals. The page is answered first; a cookie is added to it only then.
555
+ if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
556
+ try {
557
+ const cat = await catalogue(env, url.origin);
558
+ const cookie = await arrivalCookie(request, env, url, shopOf(cat));
559
+ if (cookie) { const out = new Response(res.body, res); out.headers.append('set-cookie', cookie); return out; }
560
+ } catch { /* an arrival is never worth a failed page */ }
561
+ }
562
+ return res;
508
563
  },
509
564
  };
510
565
 
@@ -527,6 +582,11 @@ async function route(request, env, ctx) {
527
582
  }
528
583
  if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? ownerRoutes(request, env, url, { catalogueOf: getAll });
529
584
  if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
585
+ // The shop (0.24.0) before player accounts: /api/player/owns is the shop's.
586
+ {
587
+ const shopped = await shopRoutes(request, env, ctx, url, { catalogueOf: getAll });
588
+ if (shopped) return shopped;
589
+ }
530
590
  if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
531
591
  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' } });
532
592
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
@@ -587,6 +647,10 @@ async function route(request, env, ctx) {
587
647
  ...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
588
648
  // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
589
649
  ...(played ? { played } : {}),
650
+ // The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
651
+ // signed statements as a referrer, with the public half of its statement key and, if it sells, its terms.
652
+ ...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
653
+ ...(await manifestReferrals(env, url.origin, shopOf(cat), cat.studio).then((r) => (r ? { referrals: r } : {})).catch(() => ({}))),
590
654
  }, 200, { 'access-control-allow-origin': '*' });
591
655
  }
592
656
  if (path === '/api/games') {
@@ -850,11 +914,15 @@ async function route(request, env, ctx) {
850
914
  const local = isLocalOrigin(url.origin);
851
915
  let qr = null;
852
916
  if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
853
- return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server });
917
+ // The shop on a television is a code to buy on a phone (the TV never sells; never on a kids server).
918
+ const sh = shellShop(cat, game, { kids: pol.kids });
919
+ let shopQr = null;
920
+ if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
921
+ return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
854
922
  }
855
923
  await countVisit(request, env, ctx, game, 'play');
856
924
  shareDaily(cat, url, ctx);
857
- return playPage(cat, meta, { ticket, owner: d.owner, launch, server });
925
+ return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
858
926
  }
859
927
  if (sub === 'watch') {
860
928
  // 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 +939,7 @@ async function route(request, env, ctx) {
871
939
  const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
872
940
  await countVisit(request, env, ctx, game, 'watch');
873
941
  shareDaily(cat, url, ctx);
874
- return watchPage(cat, meta, { room: asked, ticket, policy });
942
+ return watchPage(cat, meta, { room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
875
943
  }
876
944
  if (sub === 'api/watch') {
877
945
  if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
@@ -912,6 +980,27 @@ async function route(request, env, ctx) {
912
980
  },
913
981
  });
914
982
  }
983
+ if (sub === 'api/chat/report') {
984
+ // A player reports one chat line (section 19): the room's own copy of it is what is kept, never the reporter's words.
985
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'a report comes from this site\'s own pages' }, 403);
986
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
987
+ let body = {};
988
+ try { body = JSON.parse((await request.text()).slice(0, 2048) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
989
+ const room = String(body.room ?? '');
990
+ const id = String(body.id ?? '');
991
+ 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);
992
+ const reason = REPORT_REASONS.includes(body.reason) ? body.reason : 'other';
993
+ const key = `${BROWSER_KEY.test(String(body.b ?? '')) ? body.b : ''}|${request.headers.get('cf-connecting-ip') ?? ''}`;
994
+ 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);
995
+ let line = null;
996
+ 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; }
997
+ if (!line) return json({ ok: false, error: 'gone', message: 'That message is no longer in the room.' }, 404);
998
+ if (line.by === 'studio') return json({ ok: false, error: 'studio', message: 'That is the studio\'s own line.' }, 400);
999
+ try { await fileReport(env, { game, room, line, reason }); } catch (error) {
1000
+ 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);
1001
+ }
1002
+ return json({ ok: true, message: 'Thanks for telling the studio. They will look at it.' });
1003
+ }
915
1004
  if (sub === '__net' || sub === '__watch') {
916
1005
  if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
917
1006
  const room = url.searchParams.get('room') || 'main';
@@ -942,8 +1031,19 @@ async function route(request, env, ctx) {
942
1031
  const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
943
1032
  if (!access.ok) return new Response('this server is not open to you', { status: 403 });
944
1033
  }
1034
+ // Room chat (section 19): the room's rules, and who this socket is to them: an account with a passkey (not a
1035
+ // guest), a member of this server, or another site's page watching from elsewhere (homie.rocks's room page).
1036
+ const chat = chatOf(meta, srv, await chatRowsOf(env));
1037
+ const { acct, member } = await chatWho(env, game, srv, who);
1038
+ const origin = request.headers.get('origin');
1039
+ const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
1040
+ const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
1041
+ // A supporter's badge (the shop, 0.24.0): what the player's account owns, looked up here, so a hello can never claim
1042
+ // one; none on a kids server, none for an AI or a watcher.
1043
+ const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
1044
+ const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
945
1045
  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)}` : ''}`;
1046
+ 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)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}`;
947
1047
  return stub.fetch(new Request(target, request));
948
1048
  }
949
1049
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
@@ -1031,6 +1131,12 @@ export class Table {
1031
1131
  this.brainUsed = { neurons: 0, micros: 0 };
1032
1132
  this.brainPending = { calls: {}, neurons: 0, micros: 0 };
1033
1133
  this.brainReadAt = 0;
1134
+ /** 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. */
1135
+ this.chatCap = null;
1136
+ this.chatUsed = 0;
1137
+ this.chatReadAt = 0;
1138
+ this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
1139
+ this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
1034
1140
  ctx.blockConcurrencyWhile(async () => {
1035
1141
  this.saved = (await ctx.storage.get('net')) ?? null;
1036
1142
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
@@ -1055,9 +1161,63 @@ export class Table {
1055
1161
  if (this.saved) this.room.restore(this.saved);
1056
1162
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1057
1163
  this.vocabRead = this.readVocab(game).catch(() => false);
1164
+ // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
1165
+ this.room.review = (text, opts) => this.reviewChat(text, opts);
1058
1166
  return this.room;
1059
1167
  }
1060
1168
 
1169
+ /**
1170
+ * One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
1171
+ * budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
1172
+ * floor alone has decided (`by` says which), and the message goes out.
1173
+ */
1174
+ async reviewChat(text, { links = 'block' } = {}) {
1175
+ if (!this.env.AI) return { ok: true, by: 'none' };
1176
+ if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
1177
+ if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
1178
+ try {
1179
+ const v = await reviewChat(this.env, text, { links });
1180
+ this.chatUsed += v.neurons;
1181
+ this.chatPend.neurons += v.neurons;
1182
+ this.chatPend[v.ok ? 'ok' : 'held'] += 1;
1183
+ return { ok: v.ok, by: 'ai', why: v.why, ms: v.ms, p: v.p };
1184
+ } catch (error) {
1185
+ this.chatPend.error += 1;
1186
+ try { console.log(JSON.stringify({ ev: 'chat-review-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ }
1187
+ return { ok: true, by: 'error' };
1188
+ }
1189
+ }
1190
+
1191
+ async readChatDay() {
1192
+ const day = await chatDay(this.env, today());
1193
+ this.chatCap = day.budget.neurons;
1194
+ this.chatUsed = day.used.neurons + this.chatPend.neurons;
1195
+ this.chatReadAt = Date.now();
1196
+ }
1197
+
1198
+ /** Chat's counts into D1 (stats_daily): lines, reactions, held by why, reviews and neurons. Never a message or a sender. */
1199
+ async flushChat() {
1200
+ const s = this.room?.stats;
1201
+ if (!s || !this.env.DB) return;
1202
+ const game = this.game ?? '';
1203
+ const rows = [];
1204
+ for (const [k, metric] of [['chatLines', 'chat-lines'], ['chatReacts', 'chat-reacts']]) {
1205
+ const d = (s[k] ?? 0) - (this.chatSent[k] ?? 0);
1206
+ if (d > 0) { rows.push(counter(this.env, { metric, subject: game, n: d })); this.chatSent[k] = s[k]; }
1207
+ }
1208
+ for (const [why, n] of Object.entries(s.chatHeld ?? {})) {
1209
+ const d = n - (this.chatSent.held[why] ?? 0);
1210
+ if (d > 0) { rows.push(counter(this.env, { metric: 'chat-held', subject: game, source: why, n: d })); this.chatSent.held[why] = n; }
1211
+ }
1212
+ const p = this.chatPend;
1213
+ 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] }));
1214
+ const neurons = Math.floor(p.neurons);
1215
+ if (neurons) rows.push(counter(this.env, { metric: 'chat-neurons', subject: game, n: neurons }));
1216
+ this.chatPend = { neurons: p.neurons - neurons, ok: 0, held: 0, error: 0 };
1217
+ const list = rows.filter(Boolean);
1218
+ if (list.length) await this.env.DB.batch(list).catch(() => {});
1219
+ }
1220
+
1061
1221
  /** The game's agents.json from the build (the only words an AI in this room may say), once. */
1062
1222
  async readVocab(game) {
1063
1223
  if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
@@ -1156,6 +1316,12 @@ export class Table {
1156
1316
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
1157
1317
  // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
1158
1318
  if (url.pathname === '/__facts') return json({ ...room.officeFacts(), ...(this.house ? { brains: this.house.facts() } : {}) });
1319
+ // 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.
1320
+ if (url.pathname === '/__chat') {
1321
+ const id = url.searchParams.get('id');
1322
+ const r = room.chatLog.find((x) => x.id === id);
1323
+ 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 });
1324
+ }
1159
1325
  if (url.pathname === '/__office') {
1160
1326
  const ctl = await request.json().catch(() => null);
1161
1327
  const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
@@ -1172,6 +1338,9 @@ export class Table {
1172
1338
  if (room.seatCap !== max) room.setSeats(max, perAddress(max));
1173
1339
  // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
1174
1340
  const pol = decodeFacts(url.searchParams.get('pol'));
1341
+ // The room's chat rules ride beside it (they may be longer than a policy): section 19.
1342
+ const chatRules = decodeFacts(url.searchParams.get('chat'), 16384);
1343
+ if (pol && chatRules && typeof chatRules === 'object') pol.chat = chatRules;
1175
1344
  if (pol) room.setPolicy(pol);
1176
1345
  if (this.house) this.house.sync();
1177
1346
  const agent = decodeFacts(url.searchParams.get('ag'));
@@ -1188,14 +1357,25 @@ export class Table {
1188
1357
  watch: url.searchParams.get('w') === '1',
1189
1358
  // An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
1190
1359
  ...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
1360
+ // A badge the player's account owns (the shop): the Worker looked it up; a hello cannot set one.
1361
+ ...(url.pathname === '/__net' && /^[\p{L}\p{N} .'&+-]{1,16}$/u.test(url.searchParams.get('bd') ?? '') ? { badge: url.searchParams.get('bd') } : {}),
1191
1362
  watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
1192
1363
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
1193
1364
  qa: isQa(request),
1365
+ // Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
1366
+ // another site (homie.rocks's room page) watching from elsewhere.
1367
+ acct: url.searchParams.get('acct') === '1',
1368
+ member: url.searchParams.get('mem') === '1',
1369
+ hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
1194
1370
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
1195
1371
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
1196
1372
  buffered: () => 0,
1197
1373
  };
1198
1374
  if (url.pathname === '/__watch') {
1375
+ // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
1376
+ // (room chat, 0.23.0: another site's page can open one too).
1377
+ const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
1378
+ 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
1379
  const w = room.watch(conn);
1200
1380
  // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
1201
1381
  server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
@@ -1247,8 +1427,10 @@ export class Table {
1247
1427
  if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
1248
1428
  n += 1;
1249
1429
  if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
1430
+ // Room chat's counts, once a minute (section 19).
1431
+ if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
1250
1432
  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; } }
1433
+ 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
1434
  }, 250);
1253
1435
  }
1254
1436
 
@@ -10,7 +10,7 @@ import { SKILLS } from './agents.mjs';
10
10
  import { KIDS_LINE, POLICY_WORDS, SERVER_LIMITS } from './servers.mjs';
11
11
  import { esc } from './site.mjs';
12
12
 
13
- const PRIVATE = {
13
+ export const PRIVATE = {
14
14
  'content-type': 'text/html; charset=utf-8',
15
15
  'cache-control': 'no-store, private',
16
16
  'x-robots-tag': 'noindex, nofollow',
@@ -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');
@@ -718,7 +832,7 @@ async function officeScriptHash() {
718
832
  return scriptHash;
719
833
  }
720
834
 
721
- function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
835
+ export function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
722
836
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
723
837
  <meta name="robots" content="noindex, nofollow"><title>${esc(title)}</title><style>:root{--accent:${accentOf(cat)}}${CSS}</style></head><body><div class="wrap">${body}</div>${script ? `<script>${script}</script>` : ''}</body></html>`;
724
838
  }
@@ -728,12 +842,13 @@ export async function officePage(cat, headers = {}) {
728
842
  const name = cat.studio?.name ?? 'Studio';
729
843
  const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
730
844
  const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
731
- <div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>
845
+ <div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
732
846
  <form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
733
847
  <form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
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>
@@ -756,7 +871,7 @@ export function lockedPage(cat, { what = 'office', ask = null } = {}) {
756
871
  return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
757
872
  }
758
873
 
759
- const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
874
+ const VERB = { refund: 'Refund it', 'shop-settle': 'Mark it paid', kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
760
875
 
761
876
  /** The owner's one tap for what their AI asked, and what came of it. */
762
877
  export function confirmPage(cat, ask, { missing = false } = {}) {
@@ -768,7 +883,7 @@ export function confirmPage(cat, ask, { missing = false } = {}) {
768
883
  const verb = ask.action?.op === 'mute' && ask.action.off ? 'Unmute' : ask.action?.op === 'close' && ask.action.reopen ? 'Open it again' : VERB[ask.action?.op] ?? 'Yes';
769
884
  body = `<h1>${esc(name)} <small>Your AI asks</small></h1>
770
885
  <p style="font-size:19px;line-height:1.45;max-width:46ch">${esc(ask.what)}</p>
771
- <form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
886
+ <form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member', 'refund'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
772
887
  <p class="note">Only you can confirm this: your AI asked through the studio's office key, which cannot say yes. It lasts until ${esc(new Date(ask.expiresAt).toISOString().slice(11, 16))} UTC.</p>`;
773
888
  } else {
774
889
  const said = { done: 'Done.', failed: 'That did not work.', cancelled: 'Not done: you said no.', expired: 'This ask ran out (15 minutes). Your AI can ask again.', working: 'Working on it…' }[ask.state] ?? ask.state;