@homie-rocks/studio 0.53.0 → 0.55.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 (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
@@ -25,12 +25,12 @@ import { toolIdentity } from './tool-identity.mjs';
25
25
  * THE OWNER sees counts and names (`players.count`, `players.list`), never a passkey, a session or an email.
26
26
  * A player sees, downloads (/api/player/export) and deletes (/api/player/delete) everything of theirs.
27
27
  */
28
- import { handleFor } from './room.mjs';
28
+ import { handleFor } from './room-protocol.mjs';
29
29
  import { isAiName } from './agents.mjs';
30
30
  import { cookieValue } from './stats.mjs';
31
31
  import { adoptGuest, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
32
32
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
33
- import { ACCOUNT_JS, accountPage } from './account-page.mjs';
33
+
34
34
  import { adoptShopStatements, migrated as shopMigrated, forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
35
35
  import { forgetPlayerHistory, playerHistory } from './lounge-store.mjs';
36
36
 
@@ -230,7 +230,7 @@ async function sessionOf(request, env, url = new URL(request.url)) {
230
230
  writes.push(env.DB.prepare('UPDATE player_sessions SET expires_at = ?2 WHERE hash = ?1').bind(hash, now + days * DAY));
231
231
  session.cookies.push(sessionCookie(url, token, days));
232
232
  }
233
- if (writes.length) await env.DB.batch(writes).catch(() => {});
233
+ if (writes.length) await env.DB.batch(writes);
234
234
  return session;
235
235
  }
236
236
 
@@ -240,6 +240,8 @@ async function newGuest(request, env, url, addressLimit = PLAYER_LIMITS.newPlaye
240
240
  if (addressLimit !== null && limited(`${counter}:${ip}`, addressLimit, HOUR)) return { error: fail(429, 'rate', 'too many new players from this address; try again later') };
241
241
  const capped = (counter !== 'shop-new' || env.PLAYER_LIMIT_DAILY !== undefined) && await dailyCapReached(env);
242
242
  if (capped) return { error: fail(503, 'busy', 'this studio has taken all the new players it takes in one day; try again tomorrow') };
243
+ // Studios without scheduled functions still reclaim idle guests on this write path.
244
+ if (Math.random() < 0.02) await pruneGuests(env);
243
245
  const id = newPlayerId();
244
246
  const now = Date.now();
245
247
  const name = handleFor(id);
@@ -248,7 +250,6 @@ async function newGuest(request, env, url, addressLimit = PLAYER_LIMITS.newPlaye
248
250
  env.DB.prepare('INSERT INTO players (id, name, named, guest, owner, created_at, seen_at) VALUES (?1, ?2, 0, 1, 0, ?3, ?3)').bind(id, name, now),
249
251
  s.statement,
250
252
  ]);
251
- if (Math.random() < 0.02) pruneGuests(env).catch(() => {});
252
253
  return { session: { player: { id, name, named: false, guest: true, owner: false, since: now }, cookies: [s.cookie], fresh: true } };
253
254
  }
254
255
 
@@ -472,12 +473,12 @@ async function gameOf(catalogueOf, id) {
472
473
  export async function playerRoutes(request, env, ctx, url, { catalogueOf }) {
473
474
  const path = url.pathname;
474
475
  const read = request.method === 'GET' || request.method === 'HEAD';
475
- if (path === '/_homie/account.js') return new Response(ACCOUNT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
476
+ if (path === '/_homie/account.js') return new Response((await import('./account-page.mjs')).ACCOUNT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
476
477
  if (path === '/account') return Response.redirect(`${url.origin}/account/${url.search}`, 301);
477
478
  if (path === '/account/') {
478
479
  if (!read) return fail(405, 'method', 'GET only');
479
480
  const cat = await catalogueOf();
480
- return accountPage(cat, { origin: url.origin, next: nextOf(url.searchParams.get('next')), owner: OWNER_KEY.test(url.searchParams.get('owner') ?? '') ? url.searchParams.get('owner') : null, link: LINK.test(url.searchParams.get('k') ?? '') ? url.searchParams.get('k') : null, mode: ['verify', 'recover'].includes(url.searchParams.get('do')) ? url.searchParams.get('do') : null, features: features(env) });
481
+ return (await import('./account-page.mjs')).accountPage(cat, { origin: url.origin, next: nextOf(url.searchParams.get('next')), owner: OWNER_KEY.test(url.searchParams.get('owner') ?? '') ? url.searchParams.get('owner') : null, link: LINK.test(url.searchParams.get('k') ?? '') ? url.searchParams.get('k') : null, mode: ['verify', 'recover'].includes(url.searchParams.get('do')) ? url.searchParams.get('do') : null, features: features(env) });
481
482
  }
482
483
  if (!path.startsWith('/api/player/')) return null;
483
484
  const f = features(env);
@@ -737,7 +738,7 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
737
738
  const answer = json({ ok: true, message: 'If that address belongs to an account here, a link is on its way. It works once, for 30 minutes.' });
738
739
  if (!owner) return answer;
739
740
  const k = await issueChallenge(env, 'recover-link', owner.player, null, PLAYER_LIMITS.linkMinutes);
740
- await sendMail(env, email, `Sign in to ${await studioName()}`, `Someone (hopefully you) asked to get back into your player account on ${await studioName()}.\n\nOpen this link on the device you want to use, and make a new passkey there:\n${url.origin}/account/?do=recover&k=${k}\n\nIt works once, for 30 minutes. If you did not ask, ignore this email: nothing changes.\n`).catch(() => {});
741
+ await sendMail(env, email, `Sign in to ${await studioName()}`, `Someone (hopefully you) asked to get back into your player account on ${await studioName()}.\n\nOpen this link on the device you want to use, and make a new passkey there:\n${url.origin}/account/?do=recover&k=${k}\n\nIt works once, for 30 minutes. If you did not ask, ignore this email: nothing changes.\n`).catch(error => console.warn("Recovery mail delivery failed:",error.message));
741
742
  return answer;
742
743
  }
743
744
 
@@ -1,7 +1,7 @@
1
1
  import { definitiveRefusal, blockingJobsSQL } from './payment-policy.mjs';
2
2
  import { orderFact, offerVersion, orderState } from './purchase-core.mjs';
3
3
  import { problem } from './purchase-problem.mjs';
4
- import { saveRecording, recordProviderResult, recordingFailure, reconcileRecordings } from './payment-recovery.mjs';
4
+ import { saveRecording, recordProviderId, recordProviderResult, recordingFailure, reconcileRecordings } from './payment-recovery.mjs';
5
5
  import { machineCapabilities, notePaymentRefusal } from './payment-capabilities.mjs';
6
6
  import { purchaseResponse } from './purchase-response.mjs';
7
7
  /** One durable purchase, regardless of transport. No seller browser page is involved. */
@@ -262,12 +262,14 @@ function recordingClient(env, o, providerClient) {
262
262
  client.paymentIntents = Object.create(providerClient.paymentIntents);
263
263
  client.paymentIntents.create = async (params, options) => {
264
264
  params = { ...params, metadata: { ...params.metadata, homie: "purchase-v1", order: o.id } };
265
- const id = await saveRecording(env, o, params, options);
265
+ const id = await saveRecording(env, o, params, { ...options, version: options?.apiVersion ?? providerClient.getApiField?.('version') });
266
266
  try {
267
267
  const saved = await env.DB.prepare('SELECT state,payload FROM purchase_jobs WHERE id = ?1').bind(id).first();
268
268
  if (saved.state === 'manual_review') throw new Error('Stripe recording was refused; the seller must handle any refund manually.');
269
269
  const original = JSON.parse(saved.payload);
270
- let pi = await create(original.params, original.options);
270
+ const {version,...originalOptions}=original.options;
271
+ let pi = await create(original.params, { ...originalOptions, apiVersion: version });
272
+ await recordProviderId(env,o,id,pi);
271
273
  if (pi.status === 'processing') pi = await providerClient.paymentIntents.retrieve(pi.id);
272
274
  if (pi.status === 'requires_action') pi = await providerClient.paymentIntents.cancel(pi.id);
273
275
  await recordProviderResult(env, o, id, pi);
@@ -0,0 +1,22 @@
1
+ /** Private per-delivery fence, never a deploy binding. */
2
+ export const RECONCILE_GUARD=Symbol('reconcileGuard');
3
+
4
+ /** Statements may be prepared before an await, so check at execution, including batches. */
5
+ export function guardedDatabase(db,check) {
6
+ const originals=new WeakMap();
7
+ const statement=raw=>{
8
+ const wrapped=new Proxy(raw,{get(target,key){
9
+ if(key==='bind')return (...args)=>statement(target.bind(...args));
10
+ if(['run','first','all','raw'].includes(key))return async(...args)=>{await check();return target[key](...args);};
11
+ return Reflect.get(target,key);
12
+ }});
13
+ originals.set(wrapped,raw);return wrapped;
14
+ };
15
+ return new Proxy(db,{get(target,key){
16
+ if(key==='prepare')return query=>statement(target.prepare(query));
17
+ if(key==='batch')return async statements=>{await check();return target.batch(statements.map(s=>originals.get(s)??s));};
18
+ if(key==='exec')return async(...args)=>{await check();return target.exec(...args);};
19
+ if(key==='withSession')return (...args)=>guardedDatabase(target.withSession(...args),check);
20
+ const value=Reflect.get(target,key);return typeof value==='function'?value.bind(target):value;
21
+ }});
22
+ }
@@ -0,0 +1,78 @@
1
+ export const METADATA_REVISION=Symbol('metadata revision');
2
+ export const ORIGINAL_DB=Symbol('original database');
3
+ export const MUTATIONS=Symbol('request mutations');
4
+ export const MUTATION_VERSION=Symbol('mutation version');
5
+ export const databaseBinding=db=>db?.[ORIGINAL_DB]??db;
6
+ const metadata=new WeakMap();
7
+ const metadataQueries=new Set(['SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500','SELECT * FROM servers LIMIT 2000','SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000']);
8
+ /** Cache only completed metadata, stamped by a trigger-maintained revision. Pending
9
+ * D1 operations belong to one request. Cold metadata is one atomic batch.
10
+ */
11
+ export function requestDatabase(DB, request, state={mutated:false}, binding=databaseBinding(DB)) {
12
+ if(!DB)return DB;
13
+ // Start at the primary so launch/role revocations are current. Later reads in
14
+ // this session may use replicas at or beyond that bookmark. This also meets
15
+ // any bookmark returned by an earlier request without trusting stale auth.
16
+ if(DB===binding&&typeof DB.withSession==='function')DB=DB.withSession('first-primary');
17
+ let revision,records,rows,liveRooms;
18
+ const prefetchRooms=false;
19
+ const url=new URL(request.url),match=request.method==='GET'&&/^\/([a-z0-9][a-z0-9-]{0,39})\/api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(url.pathname);
20
+ const recordQuery=match&&(match[3]?'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3':'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id');
21
+ const stamp=()=>revision??=(async()=>{
22
+ const query=DB.prepare("SELECT value FROM meta WHERE key='metadata-revision'");
23
+ const cached=metadata.get(binding);
24
+ // The revision and its rows must be from the same snapshot, including when
25
+ // another request changes metadata while this request is awaiting D1.
26
+ const queries=[...metadataQueries];
27
+ const cold=!cached;
28
+ const statements=[query,...(cold?queries.map(sql=>DB.prepare(sql)):[])];
29
+ if(recordQuery)statements.push(DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined)));
30
+ if(prefetchRooms)statements.push(DB.prepare('SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'));
31
+ const result=await DB.batch(statements);
32
+ if(prefetchRooms)liveRooms=result.at(-1);
33
+ let version=result[0].results?.[0]?.value??null;
34
+ if(recordQuery)records=result.at(-1);
35
+ if(cold)rows=new Map(queries.map((sql,i)=>[sql,result[i+1]]));
36
+ else if(cached.version===version)rows=cached.rows;
37
+ else {
38
+ const refreshed=await DB.batch([query,...queries.map(sql=>DB.prepare(sql))]);
39
+ version=refreshed[0].results?.[0]?.value??null;
40
+ rows=new Map(queries.map((sql,i)=>[sql,refreshed[i+1]]));
41
+ }
42
+ if(version!==null)metadata.set(binding,{version,rows});
43
+ return version;
44
+ })();
45
+ const writing=sql=>{if(!/^\s*(SELECT|PRAGMA|EXPLAIN)\b/i.test(sql)){if(/\bapp_records\b/.test(sql)){records=null;revision=null;}if(/\b(office_games|servers|chat_rules)\b/.test(sql)){revision=null;metadata.delete(binding);}if(!/\bstats_daily\b/.test(sql)){state.mutated=true;state.version=(state.version??0)+1;}}};
46
+ const prepare=(sql,args=[])=>{
47
+ const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
48
+ const read=async()=>{
49
+ if(sql==='SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'&&prefetchRooms){await stamp();return liveRooms;}
50
+ if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
51
+ if(!metadataQueries.has(sql))return original().all();
52
+ await stamp();
53
+ return rows.get(sql);
54
+ };
55
+ return {bind:(...args)=>prepare(sql,args),first:async column=>{writing(sql);if(!metadataQueries.has(sql)&&sql!==recordQuery)return original().first(column);const row=(await read()).results?.[0]??null;return column&&row?row[column]:row;},all:()=>{writing(sql);return read();},run:()=>{writing(sql);return original().run();},raw:(...args)=>{writing(sql);return original().raw(...args);},_original:original,_sql:sql};
56
+ };
57
+ const methods={[METADATA_REVISION]:stamp,[ORIGINAL_DB]:binding,get [MUTATIONS](){return state.mutated;},get [MUTATION_VERSION](){return state.version??0;},prepare,batch:statements=>{for(const s of statements)writing(s._sql??'UPDATE');return DB.batch(statements.map(s=>s._original?.()??s));},exec:(sql,...args)=>{writing(sql);return binding.exec(sql,...args);},withSession:(...args)=>requestDatabase(binding.withSession(...args),request,state,binding)};
58
+ return new Proxy(methods,{get(target,key){if(key in target)return target[key];const value=DB[key];return typeof value==='function'?value.bind(DB):value;}});
59
+ }
60
+
61
+ /** A background task may register more work while it settles. */
62
+ export async function drainPending(pending) {
63
+ for(let done=0;done<pending.length;){const end=pending.length;await Promise.allSettled(pending.slice(done,end));done=end;}
64
+ }
65
+
66
+ /** Keep the runtime context intact and also deliver writes registered after the
67
+ * response's original drain finished (for example from response-stream work). */
68
+ export function trackBackground(ctx,deliver) {
69
+ const pending=[];let finished=false;
70
+ const context=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
71
+ if(key==='waitUntil')return work=>{
72
+ const task=Promise.resolve(work);pending.push(task);ctx.waitUntil(task);
73
+ if(finished)ctx.waitUntil(Promise.allSettled([task]).then(deliver));
74
+ };
75
+ const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
76
+ }}):ctx;
77
+ return {context,async finish(){await drainPending(pending);finished=true;await deliver();}};
78
+ }
@@ -0,0 +1,18 @@
1
+ import {GAME_FILES,PART_FILES} from './asset-paths.mjs';
2
+ export const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
3
+ status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
4
+ });
5
+ export function finish(res, path) {
6
+ if (!res || res.status === 101 || res.webSocket) return res;
7
+ if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
8
+ const cache = res.headers.get('cache-control') ?? '';
9
+ const addNt = !/no-transform/i.test(cache);
10
+ // GAME PARTS: a shared part's preview page is framed by the part's page and the hub, sandboxed (worker/parts.mjs).
11
+ const playerFrame = /^\/[a-z0-9][a-z0-9-]{0,39}\/play\/embed$/.test('/' + path.split('/').filter(Boolean).join('/')) && res.status === 200 && /frame-ancestors/.test(res.headers.get('content-security-policy') ?? '');
12
+ const addXfo = !playerFrame && !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
13
+ if (!addNt && !addXfo) return res;
14
+ const headers = new Headers(res.headers);
15
+ if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
16
+ if (addXfo) headers.set('x-frame-options', 'DENY');
17
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
18
+ }
@@ -0,0 +1,216 @@
1
+ /** Chat authorization, rate limits and history for a room. The relay owns its shared state. */
2
+
3
+
4
+
5
+
6
+
7
+ import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, windowMsOf } from './chat.mjs';
8
+ import { CONTROL_LIMITS, oneLine, handleFor, randomId } from './room-protocol.mjs';
9
+ export class RoomChat {
10
+ constructor(room) {this.room=room;}
11
+ chatRules() {
12
+ if (this.room.policy.chat) return this.room.policy.chat;
13
+ const sig = `${this.room.policy.kids}|${this.room.policy.speech}`;
14
+ if (!this.room.defaultChat || this.room.defaultChat.sig !== sig) this.room.defaultChat = { sig, rules: normalizeChat(null, { kids: this.room.policy.kids, speech: this.room.policy.speech }) };
15
+ return this.room.defaultChat.rules;
16
+ }
17
+ recentChat(now = this.room.now()) {
18
+ const keep = windowMsOf(this.room.chatRules());
19
+ return this.room.chatLog.filter((r) => now - r.at <= keep).slice(-CHAT_LIMITS.keep);
20
+ }
21
+ hydrate(recs) {
22
+ const have = new Set(this.room.chatLog.map((r) => r.id));
23
+ const old = (Array.isArray(recs) ? recs : []).filter((r) => r && typeof r.id === 'string' && !have.has(r.id));
24
+ this.room.chatLog = [...old, ...this.room.chatLog].sort((a, b) => a.at - b.at).slice(-CHAT_LIMITS.keep);
25
+ this.room.hydrated = true;
26
+ return old.length;
27
+ }
28
+ wireLine(r) {
29
+ const base = { id: r.id, at: r.at, name: r.name, seat: r.seat, colour: r.colour, by: r.by, ...(r.bubble ? { bubble: true } : {}), ...(r.acct ? { acct: true } : {}), ...(r.owner ? { owner: true } : {}), ...(r.mod ? { mod: true } : {}), ...(r.kept ? { kept: true } : {}) };
30
+ if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
31
+ // A show-what-you-made card (0.29.0): a page that knows cards draws one; any other shows its words.
32
+ return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}), ...(r.kind === 'card' && r.card ? { card: r.card } : {}) };
33
+ }
34
+ watchIdOf(conn) {
35
+ let id = this.room.watchIds.get(conn);
36
+ if (!id) { id = randomId(6); this.room.watchIds.set(conn, id); }
37
+ return id;
38
+ }
39
+ chatter(client, conn) {
40
+ let g = client;
41
+ if (!g && conn) {
42
+ const mine = this.room.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
43
+ g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
44
+ }
45
+ const via = String(g?.via ?? conn?.via ?? '');
46
+ const owner = via.split('~').includes('o');
47
+ const seat = g ? g.seat : null;
48
+ const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.room.watchIdOf(conn ?? g?.conn ?? {})}`);
49
+ // A watcher's name: its game's, else the account name the Worker vouched for (the Lounge), else a handle.
50
+ const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || oneLine(conn?.name, CHAT_LIMITS.name) || handle;
51
+ return {
52
+ key: g ? `c:${g.id}` : `w:${this.room.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
53
+ player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
54
+ // A moderator the owner named (the Lounge): only the Worker's word, shown on their lines.
55
+ mod: Boolean(conn?.mod) && !owner,
56
+ owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
57
+ watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
58
+ };
59
+ }
60
+ takeToken(map, key, rate, now) {
61
+ let b = map.get(key);
62
+ if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
63
+ b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
64
+ b.at = now;
65
+ if (b.tokens < 1) return false;
66
+ b.tokens -= 1;
67
+ return true;
68
+ }
69
+ onChat({ client = null, conn = null }, m) {
70
+ const now = this.room.now();
71
+ const rules = this.room.chatRules();
72
+ const who = this.room.chatter(client, conn);
73
+ const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
74
+ const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
75
+ const stats = this.room.stats;
76
+ const reply = (why, extra = {}) => {
77
+ stats.chatHeld = stats.chatHeld ?? {};
78
+ stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
79
+ const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
80
+ if (who.conn) this.room.sendText(who.conn, out);
81
+ return { ok: false, why };
82
+ };
83
+ if (who.agent) return reply('ai_seat');
84
+ if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
85
+ if (who.hub && !rules.hub) return reply('hub');
86
+ if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
87
+ const need = kind === 'text' ? rules.who : rules.react;
88
+ if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
89
+ if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
90
+ const mute = this.room.muteOf({ token: who.token, browser: who.browser, player: who.player });
91
+ if (mute) return reply('muted', { until: mute.until });
92
+ const b = this.room.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
93
+ this.room.chatBuckets.set(who.key, b);
94
+ if (!this.room.takeToken(this.room.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
95
+ if (who.ip && !this.room.takeToken(this.room.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
96
+ if (kind !== 'react' && rules.slow && !who.owner && now - b.lastSayAt < rules.slow * 1000) return reply('slow', { until: b.lastSayAt + rules.slow * 1000, slow: rules.slow });
97
+ let rec;
98
+ if (kind === 'react') {
99
+ const k = String(m.kind ?? m.react ?? '');
100
+ const r = rules.emoji.find((x) => x.k === k);
101
+ if (!r) return reply('unknown');
102
+ // A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
103
+ while (this.room.chatReacts.length && this.room.chatReacts[0] <= now - 1000) this.room.chatReacts.shift();
104
+ if (this.room.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
105
+ this.room.chatReacts.push(now);
106
+ rec = { kind: 'react', react: r.k, glyph: r.e };
107
+ } else if (kind === 'line') {
108
+ const id = String(m.say ?? m.line ?? '');
109
+ const l = rules.lines.find((x) => x.id === id);
110
+ if (!l) return reply('unknown');
111
+ rec = { kind: 'line', say: l.id, text: l.text };
112
+ } else {
113
+ const text = cleanText(m.text, rules.max);
114
+ if (!text) return reply('empty');
115
+ const f = floor(text, rules);
116
+ if (!f.ok) return reply(f.why);
117
+ if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
118
+ b.lastText = text.toLowerCase();
119
+ b.lastTextAt = now;
120
+ rec = { kind: 'text', text };
121
+ }
122
+ if (kind !== 'react') b.lastSayAt = now;
123
+ Object.assign(rec, {
124
+ name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
125
+ bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner, ...(who.mod ? { mod: true } : {}),
126
+ // Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
127
+ from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
128
+ });
129
+ if (kind === 'text' && rules.ai && typeof this.room.review === 'function' && !who.owner) {
130
+ if (this.room.chatPending >= CHAT_LIMITS.pending) return reply('busy');
131
+ this.room.chatPending += 1;
132
+ const t0 = now;
133
+ Promise.resolve()
134
+ .then(() => this.room.review(rec.text, { room: this.room.code, links: rules.links }))
135
+ .then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
136
+ .then((v) => {
137
+ this.room.chatPending = Math.max(0, this.room.chatPending - 1);
138
+ if (v && v.ok === false) {
139
+ stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
140
+ // What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
141
+ stats.chatAiWhy = stats.chatAiWhy ?? {};
142
+ const why = String(v.why ?? 'held').slice(0, 24);
143
+ stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
144
+ reply('ai');
145
+ return;
146
+ }
147
+ if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
148
+ rec.review = { by: v?.by ?? 'ai', ms: this.room.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
149
+ // A mute that came while it was being reviewed still holds it.
150
+ if (this.room.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
151
+ this.room.publishChat(rec, who.conn, n);
152
+ });
153
+ return { ok: true, pending: true };
154
+ }
155
+ return this.room.publishChat(rec, who.conn, n);
156
+ }
157
+ publishChat(rec, senderConn = null, n = null) {
158
+ const now = this.room.now();
159
+ rec.id = randomId(6);
160
+ rec.at = now;
161
+ this.room.chatLog.push(rec);
162
+ const keep = windowMsOf(this.room.chatRules());
163
+ while (this.room.chatLog.length > CHAT_LIMITS.keep || (this.room.chatLog.length && now - this.room.chatLog[0].at > keep)) this.room.chatLog.shift();
164
+ const msg = this.room.wireLine(rec);
165
+ const text = JSON.stringify(msg);
166
+ const mine = n ? JSON.stringify({ ...msg, n }) : text;
167
+ const hub = this.room.chatRules().hub;
168
+ for (const o of this.room.live()) if (!this.room.lite(o)) this.room.sendText(o.conn, o.conn === senderConn ? mine : text);
169
+ for (const w of this.room.watchers) if (!w.hub || hub) this.room.sendText(w, w === senderConn ? mine : text);
170
+ if (rec.kind === 'react') this.room.stats.chatReacts = (this.room.stats.chatReacts ?? 0) + 1;
171
+ else this.room.stats.chatLines = (this.room.stats.chatLines ?? 0) + 1;
172
+ // Kept chat (0.29.0): a room whose rules keep history hands what was said to the Table (never a reaction).
173
+ if (rec.kind !== 'react' && this.room.chatRules().history > 0 && typeof this.room.onPublished === 'function') { try { this.room.onPublished(rec); } catch { /* kept next time */ } }
174
+ return { ok: true, id: rec.id };
175
+ }
176
+ unsay(ids) {
177
+ const gone = new Set(ids);
178
+ const before = this.room.chatLog.length;
179
+ this.room.chatLog = this.room.chatLog.filter((r) => !gone.has(r.id));
180
+ const text = JSON.stringify({ t: 'unline', ids: [...gone] });
181
+ for (const o of this.room.live()) if (!this.room.lite(o)) this.room.sendText(o.conn, text);
182
+ for (const w of this.room.watchers) this.room.sendText(w, text);
183
+ // Kept chat: what is taken down here is deleted where it was kept, older lines than the window too.
184
+ if (typeof this.room.onUnsaid === 'function' && gone.size) { try { this.room.onUnsaid([...gone]); } catch { /* best effort */ } }
185
+ return before - this.room.chatLog.length;
186
+ }
187
+ ownLine(r, conn) {
188
+ const f = r?.from ?? {};
189
+ if (!conn || r?.kind === 'studio') return false;
190
+ return Boolean((conn.player && f.player && f.player === conn.player) || (conn.browser && f.browser && f.browser === conn.browser && !f.player));
191
+ }
192
+ unsayOwn(conn, id) {
193
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return { ok: false, error: 'no-line' };
194
+ const r = this.room.chatLog.find((x) => x.id === id);
195
+ if (!r || r.kind === 'studio') return { ok: false, error: 'no-line' };
196
+ if (!this.room.ownLine(r, conn)) return { ok: false, error: 'not-yours' };
197
+ this.room.unsay([id]);
198
+ this.room.stats.chatOwnRemoved = (this.room.stats.chatOwnRemoved ?? 0) + 1;
199
+ return { ok: true, removed: 1 };
200
+ }
201
+ holdSender(line, minutes, now, { kick = false, message = '', purge = false } = {}) {
202
+ const f = line.from ?? {};
203
+ if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
204
+ const until = now + minutes * 60_000;
205
+ const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
206
+ if (kick) this.room.bans = [...this.room.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
207
+ else this.room.mutes = [...this.room.mutes, hold].slice(-CONTROL_LIMITS.mutes);
208
+ this.room.tellBrowser(hold, kick ? { t: 'kicked', room: this.room.code, until, message } : { t: 'muted', room: this.room.code, until });
209
+ const removed = purge ? this.room.unsay(this.room.linesOf(f)) : 0;
210
+ this.room.tellWatchers();
211
+ return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
212
+ }
213
+ linesOf(from) {
214
+ if (!from) return [];
215
+ return this.room.chatLog.filter((r) => (from.client && r.from?.client === from.client) || (from.token && r.from?.token === from.token) || (from.browser && r.from?.browser === from.browser) || (from.player && r.from?.player === from.player)).map((r) => r.id);
216
+ }}