@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/room.mjs CHANGED
@@ -34,14 +34,22 @@
34
34
  * `agent:do` (a goal of the game's agents.json, at most one every 3 s). It says only `say:<lineId>` lines of that
35
35
  * vocabulary, with arguments that fit, and only on a server whose AI may talk: the relay drops everything else an AI
36
36
  * says (`stats.agentDrops`). The Table's own house agents (worker/agents.mjs) are peers like any other, on a loopback.
37
+ *
38
+ * ROOM CHAT (revision 8, NETPLAY.md section 19): anyone in the room (a player, a watcher, the big screen's page, a
39
+ * homie.rocks room page) says a line (`say`) or a reaction (`react`) on the game's socket or the shell's watch socket.
40
+ * The relay checks the room's chat rules (`policy.chat`: what may be sent, who may send it, slow mode), runs the floor
41
+ * on typed text (worker/chat.mjs), waits for the studio's review when there is one (the Table's Workers AI), and fans
42
+ * the line out to every socket and every watching shell. It keeps the last few minutes in memory for a page that just
43
+ * opened, and nothing anywhere else.
37
44
  * =============================================================================
38
45
  */
39
46
  import { aiName, skillPreset, stripAi } from './agents.mjs';
40
47
  import { checkArgs, talks, vocabularyOf } from './brain.mjs';
48
+ import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat } from './chat.mjs';
41
49
 
42
50
  export const NET_VERSION = 1;
43
51
  /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
44
- export const NET_REVISION = 7;
52
+ export const NET_REVISION = 8;
45
53
  const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
46
54
  /**
47
55
  * A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
@@ -77,6 +85,8 @@ export function normalizePolicy(p) {
77
85
  bots: p.bots === 'off' ? 'off' : 'fill', level: Math.min(levelMax, int(p.level, 1, 5, 3)), levelMax,
78
86
  speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game', kids,
79
87
  brain: kind === 'humans-only' ? 'off' : ['off', 'script', 'workers-ai', 'owner-key'].includes(p.brain) ? p.brain : 'script',
88
+ // Revision 8: the room's chat rules (section 19), capped by the server's speech and kids.
89
+ ...(p.chat !== undefined ? { chat: normalizeChat(p.chat === false ? { mode: 'off' } : p.chat, { kids, speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game' }) } : {}),
80
90
  };
81
91
  }
82
92
  const policySig = (p) => JSON.stringify({ ...p, at: 0 });
@@ -91,6 +101,8 @@ const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
91
101
  /** Size caps (bytes of the JSON text as received), for a room of up to 16 seats. */
92
102
  export const LIMITS = Object.freeze({
93
103
  hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
104
+ // Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
105
+ say: 1536, react: 256,
94
106
  });
95
107
  /**
96
108
  * What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
@@ -100,7 +112,7 @@ export const LIMITS = Object.freeze({
100
112
  export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
101
113
  export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
102
114
  /** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
103
- export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8 });
115
+ export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10 });
104
116
  /** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
105
117
  export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
106
118
  /** The keyed state channel: at most this many keys and bytes per room. */
@@ -276,6 +288,19 @@ export class NetRoom {
276
288
  this.askedMax = null;
277
289
  /** A launch change waiting for the round to finish: who may stay (ticket kinds), and when it applies at the latest. */
278
290
  this.regate = null;
291
+ /**
292
+ * Room chat (revision 8, section 19): the last few minutes of lines and reactions (memory only, never stored), each
293
+ * sender's buckets, the studio's review of typed text (the Table sets `review`; null: the floor alone decides), and
294
+ * the messages waiting for it.
295
+ */
296
+ this.chatLog = [];
297
+ this.chatBuckets = new Map();
298
+ this.chatAddress = new Map();
299
+ this.chatReacts = [];
300
+ this.chatPending = 0;
301
+ this.review = null;
302
+ this.watchIds = new WeakMap();
303
+ this.defaultChat = null;
279
304
  }
280
305
 
281
306
  /* ------------------------------------------------------------ sockets */
@@ -285,12 +310,17 @@ export class NetRoom {
285
310
  const now = this.now();
286
311
  const c = {
287
312
  id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
313
+ // Room chat (section 19): the Worker's word that this socket's account is a signed-in one (a passkey, not a guest),
314
+ // and that it belongs to this room's server.
315
+ acct: conn.acct === true, member: conn.member === true,
288
316
  device: 'desk', want: 'play', canHost: true, hidden: false, waiting: false, joinedAt: now, lastSeen: now, lastSnapAt: 0,
289
317
  rates: new Map(), drops: [], errAt: 0,
290
318
  // A watcher (section 16): the Worker's word for a socket opened through a watch door, else the hello's own.
291
319
  watch: conn.watch === true, policy: WATCH_POLICIES.includes(conn.watchPolicy) ? conn.watchPolicy : 'follow', follow: null, followWhy: null,
292
320
  // An agent (section 17): only the Worker's word, from a verified pass, makes one; its hello cannot.
293
321
  agentWord: conn.agent && typeof conn.agent === 'object' ? conn.agent : null, agent: null, caps: new Set(), speechAt: [],
322
+ // A badge the studio's Worker verified (the shop: a supporter's); a hello cannot set one.
323
+ badge: typeof conn.badge === 'string' && conn.badge.length <= 16 ? conn.badge : null,
294
324
  // Revision 7: the latest view the host showed this AI (what its arguments are checked against), and its pace.
295
325
  lastView: null, viewAt: 0, doAt: 0,
296
326
  };
@@ -312,16 +342,21 @@ export class NetRoom {
312
342
  // A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
313
343
  const held = this.heldNotice(conn);
314
344
  if (held) this.sendText(conn, JSON.stringify(held));
345
+ // Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
346
+ // studio holds out of the room, or a room it closed).
347
+ if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => this.wireLine(r)) }));
315
348
  const rate = [];
316
349
  return {
317
350
  onClose: () => this.watchers.delete(conn),
318
351
  onMessage: (text) => {
319
352
  const now = this.now();
320
353
  while (rate.length && rate[0] <= now - 1000) rate.shift();
321
- if (rate.length >= 4 || String(text).length > 256) return;
354
+ if (rate.length >= 10 || String(text).length > LIMITS.say) return;
322
355
  rate.push(now);
323
356
  let m = null;
324
357
  try { m = JSON.parse(String(text)); } catch { return; }
358
+ // Room chat from the shell (the play page, the big screen's page, the watch page, a homie.rocks room page).
359
+ if (m && (m.t === 'say' || m.t === 'react')) { this.onChat({ conn }, m); return; }
325
360
  if (!m || m.t !== 'vote' || !conn.browser) return;
326
361
  const c = this.live().find((o) => o.browser === conn.browser && o.seat !== null && !o.agent && !o.watch);
327
362
  if (c) this.onVote(c, m, conn);
@@ -342,7 +377,7 @@ export class NetRoom {
342
377
  host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
343
378
  hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
344
379
  roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
345
- peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}) }; }
380
+ peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}) }; }
346
381
  /** A hands-`host` agent (section 17): no game client of its own, so no snapshots, checkpoints or state. */
347
382
  lite(c) { return Boolean(c.agent && c.agent.hands === 'host'); }
348
383
  seatedHumans() { return this.live().filter((c) => c.seat !== null && !c.agent); }
@@ -358,7 +393,7 @@ export class NetRoom {
358
393
  levelNow() { return Math.max(1, Math.min(this.policy.levelMax, this.level ?? this.policy.level)); }
359
394
  policyOut() {
360
395
  const level = this.levelNow();
361
- return { ...this.policy, skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
396
+ return { ...this.policy, chat: publicChat(this.chatRules()), skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
362
397
  }
363
398
  /** Send to every client in the room (a lite agent too: it hears join/leave, roster, round, policy and votes). */
364
399
  broadcast(msg) {
@@ -550,6 +585,12 @@ export class NetRoom {
550
585
  this.onVote(c, m);
551
586
  return;
552
587
  }
588
+ case 'say':
589
+ case 'react': {
590
+ // Room chat (section 19): the game's own chat UI (`net.say`, `net.react`), checked like the shell's.
591
+ this.onChat({ client: c }, m);
592
+ return;
593
+ }
553
594
  case 'ping': {
554
595
  const wasHidden = c.hidden;
555
596
  c.hidden = m.hid === true;
@@ -866,6 +907,216 @@ export class NetRoom {
866
907
  for (const o of this.live()) if (o !== c && this.lite(o) && o.id !== this.hostId) this.send(o, out);
867
908
  }
868
909
 
910
+ /* ------------------------------------------------------------ room chat (revision 8, section 19) */
911
+
912
+ /** The room's chat rules: the Worker's (in the policy), else the defaults for this server's speech and kids. */
913
+ chatRules() {
914
+ if (this.policy.chat) return this.policy.chat;
915
+ const sig = `${this.policy.kids}|${this.policy.speech}`;
916
+ if (!this.defaultChat || this.defaultChat.sig !== sig) this.defaultChat = { sig, rules: normalizeChat(null, { kids: this.policy.kids, speech: this.policy.speech }) };
917
+ return this.defaultChat.rules;
918
+ }
919
+
920
+ /** The window a page that just opened gets: the last `keep` messages of the last `keepMs`. */
921
+ recentChat(now = this.now()) {
922
+ return this.chatLog.filter((r) => now - r.at <= CHAT_LIMITS.keepMs).slice(-CHAT_LIMITS.keep);
923
+ }
924
+
925
+ /** A line or a reaction as every screen gets it (homie.rocks's room chat shapes: `line` and `react`). */
926
+ wireLine(r) {
927
+ 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 } : {}) };
928
+ if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
929
+ return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}) };
930
+ }
931
+
932
+ watchIdOf(conn) {
933
+ let id = this.watchIds.get(conn);
934
+ if (!id) { id = randomId(6); this.watchIds.set(conn, id); }
935
+ return id;
936
+ }
937
+
938
+ /**
939
+ * Who is speaking: a game socket's own client, or for a shell's watch socket the client of the same browser (or
940
+ * account) in this room: its seat, name and colour. A page with no client here (a homie.rocks room page, a watch
941
+ * page before its game connects) is a watcher with a handle. Signed in, a member, the owner: the Worker's word.
942
+ */
943
+ chatter(client, conn) {
944
+ let g = client;
945
+ if (!g && conn) {
946
+ const mine = this.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
947
+ g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
948
+ }
949
+ const via = String(g?.via ?? conn?.via ?? '');
950
+ const owner = via.split('~').includes('o');
951
+ const seat = g ? g.seat : null;
952
+ const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.watchIdOf(conn ?? g?.conn ?? {})}`);
953
+ const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || handle;
954
+ return {
955
+ key: g ? `c:${g.id}` : `w:${this.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
956
+ player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
957
+ owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
958
+ watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
959
+ };
960
+ }
961
+
962
+ /** A token bucket: true (and one token spent) when there is one. */
963
+ takeToken(map, key, rate, now) {
964
+ let b = map.get(key);
965
+ if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
966
+ b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
967
+ b.at = now;
968
+ if (b.tokens < 1) return false;
969
+ b.tokens -= 1;
970
+ return true;
971
+ }
972
+
973
+ /**
974
+ * One message from anyone in the room: `{ t: 'say', text }` (typed), `{ t: 'say', say: '<line id>' }` (a quick line)
975
+ * or `{ t: 'react', kind }` (an emoji), with an optional `n` (the sender's own id for it) and `bubble: false` (not
976
+ * over my character). Refused with `held` (or homie.rocks's `slow`) to the sender only; else published to everyone,
977
+ * after the studio's review for typed text when there is one.
978
+ */
979
+ onChat({ client = null, conn = null }, m) {
980
+ const now = this.now();
981
+ const rules = this.chatRules();
982
+ const who = this.chatter(client, conn);
983
+ const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
984
+ const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
985
+ const stats = this.stats;
986
+ const reply = (why, extra = {}) => {
987
+ stats.chatHeld = stats.chatHeld ?? {};
988
+ stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
989
+ const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
990
+ if (who.conn) this.sendText(who.conn, out);
991
+ return { ok: false, why };
992
+ };
993
+ if (who.agent) return reply('ai_seat');
994
+ if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
995
+ if (who.hub && !rules.hub) return reply('hub');
996
+ if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
997
+ const need = kind === 'text' ? rules.who : rules.react;
998
+ if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
999
+ if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
1000
+ const mute = this.muteOf({ token: who.token, browser: who.browser, player: who.player });
1001
+ if (mute) return reply('muted', { until: mute.until });
1002
+ const b = this.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
1003
+ this.chatBuckets.set(who.key, b);
1004
+ if (!this.takeToken(this.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
1005
+ if (who.ip && !this.takeToken(this.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
1006
+ 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 });
1007
+ let rec;
1008
+ if (kind === 'react') {
1009
+ const k = String(m.kind ?? m.react ?? '');
1010
+ const r = rules.emoji.find((x) => x.k === k);
1011
+ if (!r) return reply('unknown');
1012
+ // A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
1013
+ while (this.chatReacts.length && this.chatReacts[0] <= now - 1000) this.chatReacts.shift();
1014
+ if (this.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
1015
+ this.chatReacts.push(now);
1016
+ rec = { kind: 'react', react: r.k, glyph: r.e };
1017
+ } else if (kind === 'line') {
1018
+ const id = String(m.say ?? m.line ?? '');
1019
+ const l = rules.lines.find((x) => x.id === id);
1020
+ if (!l) return reply('unknown');
1021
+ rec = { kind: 'line', say: l.id, text: l.text };
1022
+ } else {
1023
+ const text = cleanText(m.text, rules.max);
1024
+ if (!text) return reply('empty');
1025
+ const f = floor(text, rules);
1026
+ if (!f.ok) return reply(f.why);
1027
+ if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
1028
+ b.lastText = text.toLowerCase();
1029
+ b.lastTextAt = now;
1030
+ rec = { kind: 'text', text };
1031
+ }
1032
+ if (kind !== 'react') b.lastSayAt = now;
1033
+ Object.assign(rec, {
1034
+ name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
1035
+ bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner,
1036
+ // Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
1037
+ from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
1038
+ });
1039
+ if (kind === 'text' && rules.ai && typeof this.review === 'function' && !who.owner) {
1040
+ if (this.chatPending >= CHAT_LIMITS.pending) return reply('busy');
1041
+ this.chatPending += 1;
1042
+ const t0 = now;
1043
+ Promise.resolve()
1044
+ .then(() => this.review(rec.text, { room: this.code, links: rules.links }))
1045
+ .then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
1046
+ .then((v) => {
1047
+ this.chatPending = Math.max(0, this.chatPending - 1);
1048
+ if (v && v.ok === false) {
1049
+ stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
1050
+ // What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
1051
+ stats.chatAiWhy = stats.chatAiWhy ?? {};
1052
+ const why = String(v.why ?? 'held').slice(0, 24);
1053
+ stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
1054
+ reply('ai');
1055
+ return;
1056
+ }
1057
+ if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
1058
+ rec.review = { by: v?.by ?? 'ai', ms: this.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
1059
+ // A mute that came while it was being reviewed still holds it.
1060
+ if (this.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
1061
+ this.publishChat(rec, who.conn, n);
1062
+ });
1063
+ return { ok: true, pending: true };
1064
+ }
1065
+ return this.publishChat(rec, who.conn, n);
1066
+ }
1067
+
1068
+ /** A message every socket and every watching shell gets now (the sender's own copy carries its `n`). */
1069
+ publishChat(rec, senderConn = null, n = null) {
1070
+ const now = this.now();
1071
+ rec.id = randomId(6);
1072
+ rec.at = now;
1073
+ this.chatLog.push(rec);
1074
+ while (this.chatLog.length > CHAT_LIMITS.keep || (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs)) this.chatLog.shift();
1075
+ const msg = this.wireLine(rec);
1076
+ const text = JSON.stringify(msg);
1077
+ const mine = n ? JSON.stringify({ ...msg, n }) : text;
1078
+ const hub = this.chatRules().hub;
1079
+ for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, o.conn === senderConn ? mine : text);
1080
+ for (const w of this.watchers) if (!w.hub || hub) this.sendText(w, w === senderConn ? mine : text);
1081
+ if (rec.kind === 'react') this.stats.chatReacts = (this.stats.chatReacts ?? 0) + 1;
1082
+ else this.stats.chatLines = (this.stats.chatLines ?? 0) + 1;
1083
+ return { ok: true, id: rec.id };
1084
+ }
1085
+
1086
+ /** Take messages down (the owner's `unsay`): from the window, and from every screen that shows them. */
1087
+ unsay(ids) {
1088
+ const gone = new Set(ids);
1089
+ const before = this.chatLog.length;
1090
+ this.chatLog = this.chatLog.filter((r) => !gone.has(r.id));
1091
+ const text = JSON.stringify({ t: 'unline', ids: [...gone] });
1092
+ for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, text);
1093
+ for (const w of this.watchers) this.sendText(w, text);
1094
+ return before - this.chatLog.length;
1095
+ }
1096
+
1097
+ /**
1098
+ * A mute or a kick for a chat line whose sender holds no socket in this room now (a watcher, a homie.rocks page): held
1099
+ * by that browser and account, as a kick holds a player's other tabs.
1100
+ */
1101
+ holdSender(line, minutes, now, { kick = false, message = '', purge = false } = {}) {
1102
+ const f = line.from ?? {};
1103
+ if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
1104
+ const until = now + minutes * 60_000;
1105
+ const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
1106
+ if (kick) this.bans = [...this.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
1107
+ else this.mutes = [...this.mutes, hold].slice(-CONTROL_LIMITS.mutes);
1108
+ this.tellBrowser(hold, kick ? { t: 'kicked', room: this.code, until, message } : { t: 'muted', room: this.code, until });
1109
+ const removed = purge ? this.unsay(this.linesOf(f)) : 0;
1110
+ this.tellWatchers();
1111
+ return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
1112
+ }
1113
+
1114
+ /** The lines of one sender (a line's `from`), for a mute or a kick that also takes their messages down. */
1115
+ linesOf(from) {
1116
+ if (!from) return [];
1117
+ return this.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);
1118
+ }
1119
+
869
1120
  /* ------------------------------------------------------------ watchers (section 16) */
870
1121
 
871
1122
  /**
@@ -1008,6 +1259,10 @@ export class NetRoom {
1008
1259
  }
1009
1260
  this.reapSeats(now);
1010
1261
  this.seatWaiting();
1262
+ // The chat window is minutes long; buckets of senders who went quiet are dropped.
1263
+ while (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs) this.chatLog.shift();
1264
+ if (this.chatBuckets.size > 512) this.chatBuckets.clear();
1265
+ if (this.chatAddress.size > 512) this.chatAddress.clear();
1011
1266
  if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) {
1012
1267
  for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
1013
1268
  this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
@@ -1015,6 +1270,8 @@ export class NetRoom {
1015
1270
  this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
1016
1271
  // The party's dial and vote are the room's: a new party starts from the server's level.
1017
1272
  this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
1273
+ // Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
1274
+ this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
1018
1275
  this.seatsDirty = false; this.persistDirty = false;
1019
1276
  try { this.store?.clear?.(); } catch { /* best effort */ }
1020
1277
  }
@@ -1333,13 +1590,17 @@ export class NetRoom {
1333
1590
  this.mutes = this.mutes.filter((m) => m.until > now);
1334
1591
  switch (op) {
1335
1592
  case 'kick': {
1336
- const target = this.findClient(a);
1593
+ // From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too).
1594
+ const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
1595
+ const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
1596
+ if (!target && line) return this.holdSender(line, minutes, now, { kick: true, message: oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.', purge: a.purge === true });
1337
1597
  if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
1598
+ const purged = a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
1338
1599
  const until = now + minutes * 60_000;
1339
1600
  // The message is the studio's words; `until` says when (the shell says it in the player's own time).
1340
1601
  const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
1341
1602
  // An AI is held out by its pass (its seat becomes a brainless AI seat again); a person by token, browser, account.
1342
- const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, pass: target.agent?.pass ?? null, name: target.name, seat: target.seat, until, at: now, message };
1603
+ const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, pass: target.agent?.pass ?? null, name: line?.name ?? target.name, seat: target.seat, until, at: now, message };
1343
1604
  this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
1344
1605
  // Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
1345
1606
  const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
@@ -1357,21 +1618,32 @@ export class NetRoom {
1357
1618
  this.tellBrowser(ban, { t: 'kicked', room: this.code, until, message });
1358
1619
  this.persist(now);
1359
1620
  this.tellWatchers();
1360
- return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until };
1621
+ return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
1361
1622
  }
1362
1623
  case 'mute': {
1363
- const target = this.findClient(a);
1624
+ const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
1625
+ const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
1626
+ if (!target && line && a.off !== true) return this.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
1364
1627
  if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
1365
1628
  const off = a.off === true;
1629
+ const purged = !off && a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
1366
1630
  const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
1367
1631
  this.mutes = this.mutes.filter((m) => !same(m));
1368
1632
  const until = off ? 0 : now + minutes * 60_000;
1369
- if (!off) this.mutes = [...this.mutes, { token: target.token, browser: target.browser, player: target.player, name: target.name, seat: target.seat, until, at: now }].slice(-CONTROL_LIMITS.mutes);
1633
+ if (!off) this.mutes = [...this.mutes, { token: target.token, browser: target.browser, player: target.player, name: line?.name ?? target.name, seat: target.seat, until, at: now }].slice(-CONTROL_LIMITS.mutes);
1370
1634
  const note = { t: 'mute', id: target.id, seat: target.seat, until };
1371
1635
  for (const o of this.live()) this.send(o, note);
1372
1636
  this.tellBrowser(target, { t: 'muted', room: this.code, until });
1373
1637
  this.tellWatchers();
1374
- return { ok: true, op, name: target.name, seat: target.seat, until };
1638
+ return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
1639
+ }
1640
+ case 'unsay': {
1641
+ // Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
1642
+ const ids = a.all === true ? this.chatLog.map((r) => r.id) : (Array.isArray(a.ids) ? a.ids : [a.id]).filter((x) => typeof x === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(x)).slice(0, 64);
1643
+ if (!ids.length) return { ok: false, error: 'no-line', message: 'which message?' };
1644
+ const removed = this.unsay(ids);
1645
+ this.stats.chatRemoved = (this.stats.chatRemoved ?? 0) + removed;
1646
+ return { ok: true, op, removed };
1375
1647
  }
1376
1648
  case 'announce': {
1377
1649
  const text = oneLine(a.text, CONTROL_LIMITS.announce);
@@ -1387,6 +1659,8 @@ export class NetRoom {
1387
1659
  const msg = JSON.stringify({ t: 'announce', ...this.announcement });
1388
1660
  for (const o of this.live()) this.sendText(o.conn, msg);
1389
1661
  for (const w of this.watchers) this.sendText(w, msg);
1662
+ // The room's chat shows it too, as the studio's own line (section 19).
1663
+ this.publishChat({ kind: 'studio', text, name: 'Studio', seat: null, colour: null, by: 'studio', bubble: false, acct: false, owner: true, from: null });
1390
1664
  this.tellWatchers();
1391
1665
  return { ok: true, op, id: this.announcement.id, people: this.live().length, until: this.announcement.until };
1392
1666
  }
@@ -1541,6 +1815,12 @@ export class NetRoom {
1541
1815
  }),
1542
1816
  bans: this.bans.filter((b) => b.until > now).map((b) => ({ name: b.name, seat: b.seat, until: b.until, at: b.at, address: Boolean(b.ip), browser: browserTag(b.browser) })),
1543
1817
  mutes: this.mutes.filter((m) => m.until > now).map((m) => ({ name: m.name, seat: m.seat, until: m.until })),
1818
+ // Room chat (section 19): the window as the owner sees it, with who sent each line (never an address).
1819
+ chat: {
1820
+ rules: this.chatRules(),
1821
+ pending: this.chatPending,
1822
+ lines: this.recentChat(now).map((r) => ({ ...this.wireLine(r), kind: r.kind, client: r.from?.client ?? null, player: r.from?.player ?? null, browser: browserTag(r.from?.browser), ...(r.review ? { review: r.review } : {}) })),
1823
+ },
1544
1824
  },
1545
1825
  };
1546
1826
  }
@@ -158,6 +158,8 @@ export function serverOf(raw, { from = 'd1' } = {}) {
158
158
  state: ['open', 'closed', 'archived'].includes(raw.state) ? raw.state : 'open',
159
159
  createdAt: Number(pick('created_at', 'createdAt')) || 0,
160
160
  updatedAt: Number(pick('updated_at', 'updatedAt')) || 0,
161
+ // A game.json seed's own chat rules (0.23.0): under the owner's for this server (worker/chat-store.mjs chatOf).
162
+ ...(from === 'game.json' && raw.chat && typeof raw.chat === 'object' && !Array.isArray(raw.chat) ? { chat: raw.chat } : {}),
161
163
  from,
162
164
  };
163
165
  }
@@ -206,7 +208,7 @@ export async function serversOf(env, meta, { fresh = false } = {}) {
206
208
  * together are at most one fewer than the room's seats (at least one seat is a person's); a kids server caps the
207
209
  * dial at 3 and keeps speech to quick lines. `server: null` is a named room (it takes the public server's rules).
208
210
  */
209
- export function policyOf(server, { seats = 8, named = false } = {}) {
211
+ export function policyOf(server, { seats = 8, named = false, chat = null } = {}) {
210
212
  const s = server ?? PUBLIC_SERVER;
211
213
  const room = Math.max(1, Math.min(seats, s.seats ?? seats));
212
214
  const guides = s.policy === 'beginner' ? Math.max(0, Math.min(s.guides, room - 1)) : 0;
@@ -219,6 +221,8 @@ export function policyOf(server, { seats = 8, named = false } = {}) {
219
221
  level: Math.min(levelMax, s.level), levelMax,
220
222
  speech: s.kids && s.speech === 'game' ? 'lines' : s.speech,
221
223
  kids: Boolean(s.kids), brain: s.brain,
224
+ // Room chat (0.23.0, worker/chat-store.mjs chatOf): the room's chat rules; an owner's change makes the policy newer.
225
+ ...(chat ? { chat, at: Math.max(Math.max(0, Number(s.updatedAt) || 0), Number(chat.at) || 0) } : {}),
222
226
  };
223
227
  }
224
228