@homie-rocks/studio 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +92 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +81 -2
  7. package/chat/CHAT.md +101 -0
  8. package/chat/OWNERS.md +78 -0
  9. package/lib/art-cli.mjs +296 -0
  10. package/lib/art-tools.mjs +331 -0
  11. package/lib/asset-check.mjs +189 -0
  12. package/lib/asset-import.mjs +136 -0
  13. package/lib/asset-manifest.mjs +318 -0
  14. package/lib/build.mjs +32 -2
  15. package/lib/chat-cli.mjs +89 -0
  16. package/lib/cloudflare.mjs +5 -2
  17. package/lib/codex.mjs +43 -1
  18. package/lib/colour.mjs +160 -0
  19. package/lib/decisions.mjs +800 -0
  20. package/lib/directory.mjs +9 -1
  21. package/lib/library.mjs +169 -0
  22. package/lib/mcp-tools.mjs +22 -4
  23. package/lib/mcp.mjs +6 -3
  24. package/lib/optimise.mjs +380 -0
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +39 -2
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +76 -0
  33. package/mcp/ui/card.css +15 -0
  34. package/mcp/ui/cast.js +62 -0
  35. package/mcp/ui/decision.js +61 -0
  36. package/mcp/ui/lineup.js +68 -0
  37. package/mcp/ui/rights.js +30 -0
  38. package/mcp/ui/style.js +154 -0
  39. package/netplay/NETPLAY.md +191 -3
  40. package/netplay/netplay.ts +150 -3
  41. package/package.json +15 -2
  42. package/port/index.ts +3 -2
  43. package/port/view.ts +238 -0
  44. package/site/SITE.md +11 -1
  45. package/starters/ember-vale/src/main.ts +22 -2
  46. package/starters/gem-rush/src/main.ts +21 -1
  47. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  48. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  49. package/starters/gem-rush-3d/credits.json +31 -0
  50. package/starters/gem-rush-3d/game.json +11 -0
  51. package/starters/gem-rush-3d/index.html +20 -0
  52. package/starters/gem-rush-3d/lab.json +19 -0
  53. package/starters/gem-rush-3d/src/main.ts +2107 -0
  54. package/starters/gem-rush-3d/style.json +66 -0
  55. package/starters/gem-rush-3d/tunables.json +13 -0
  56. package/worker/agents.mjs +3 -3
  57. package/worker/chat-page.mjs +556 -0
  58. package/worker/chat-store.mjs +161 -0
  59. package/worker/chat-words.mjs +46 -0
  60. package/worker/chat.mjs +419 -0
  61. package/worker/index.mjs +153 -8
  62. package/worker/office-page.mjs +115 -0
  63. package/worker/office.mjs +137 -6
  64. package/worker/pages.mjs +32 -9
  65. package/worker/qr.mjs +11 -0
  66. package/worker/room.mjs +288 -10
  67. package/worker/servers.mjs +5 -1
  68. package/worker/site.mjs +9 -4
  69. package/worker/stats.mjs +4 -1
  70. package/worker/version.mjs +1 -1
package/worker/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,6 +310,9 @@ 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.
@@ -312,16 +340,21 @@ export class NetRoom {
312
340
  // A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
313
341
  const held = this.heldNotice(conn);
314
342
  if (held) this.sendText(conn, JSON.stringify(held));
343
+ // Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
344
+ // studio holds out of the room, or a room it closed).
345
+ 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
346
  const rate = [];
316
347
  return {
317
348
  onClose: () => this.watchers.delete(conn),
318
349
  onMessage: (text) => {
319
350
  const now = this.now();
320
351
  while (rate.length && rate[0] <= now - 1000) rate.shift();
321
- if (rate.length >= 4 || String(text).length > 256) return;
352
+ if (rate.length >= 10 || String(text).length > LIMITS.say) return;
322
353
  rate.push(now);
323
354
  let m = null;
324
355
  try { m = JSON.parse(String(text)); } catch { return; }
356
+ // Room chat from the shell (the play page, the big screen's page, the watch page, a homie.rocks room page).
357
+ if (m && (m.t === 'say' || m.t === 'react')) { this.onChat({ conn }, m); return; }
325
358
  if (!m || m.t !== 'vote' || !conn.browser) return;
326
359
  const c = this.live().find((o) => o.browser === conn.browser && o.seat !== null && !o.agent && !o.watch);
327
360
  if (c) this.onVote(c, m, conn);
@@ -358,7 +391,7 @@ export class NetRoom {
358
391
  levelNow() { return Math.max(1, Math.min(this.policy.levelMax, this.level ?? this.policy.level)); }
359
392
  policyOut() {
360
393
  const level = this.levelNow();
361
- return { ...this.policy, skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
394
+ return { ...this.policy, chat: publicChat(this.chatRules()), skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
362
395
  }
363
396
  /** Send to every client in the room (a lite agent too: it hears join/leave, roster, round, policy and votes). */
364
397
  broadcast(msg) {
@@ -550,6 +583,12 @@ export class NetRoom {
550
583
  this.onVote(c, m);
551
584
  return;
552
585
  }
586
+ case 'say':
587
+ case 'react': {
588
+ // Room chat (section 19): the game's own chat UI (`net.say`, `net.react`), checked like the shell's.
589
+ this.onChat({ client: c }, m);
590
+ return;
591
+ }
553
592
  case 'ping': {
554
593
  const wasHidden = c.hidden;
555
594
  c.hidden = m.hid === true;
@@ -866,6 +905,216 @@ export class NetRoom {
866
905
  for (const o of this.live()) if (o !== c && this.lite(o) && o.id !== this.hostId) this.send(o, out);
867
906
  }
868
907
 
908
+ /* ------------------------------------------------------------ room chat (revision 8, section 19) */
909
+
910
+ /** The room's chat rules: the Worker's (in the policy), else the defaults for this server's speech and kids. */
911
+ chatRules() {
912
+ if (this.policy.chat) return this.policy.chat;
913
+ const sig = `${this.policy.kids}|${this.policy.speech}`;
914
+ if (!this.defaultChat || this.defaultChat.sig !== sig) this.defaultChat = { sig, rules: normalizeChat(null, { kids: this.policy.kids, speech: this.policy.speech }) };
915
+ return this.defaultChat.rules;
916
+ }
917
+
918
+ /** The window a page that just opened gets: the last `keep` messages of the last `keepMs`. */
919
+ recentChat(now = this.now()) {
920
+ return this.chatLog.filter((r) => now - r.at <= CHAT_LIMITS.keepMs).slice(-CHAT_LIMITS.keep);
921
+ }
922
+
923
+ /** A line or a reaction as every screen gets it (homie.rocks's room chat shapes: `line` and `react`). */
924
+ wireLine(r) {
925
+ 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 } : {}) };
926
+ if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
927
+ return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}) };
928
+ }
929
+
930
+ watchIdOf(conn) {
931
+ let id = this.watchIds.get(conn);
932
+ if (!id) { id = randomId(6); this.watchIds.set(conn, id); }
933
+ return id;
934
+ }
935
+
936
+ /**
937
+ * Who is speaking: a game socket's own client, or for a shell's watch socket the client of the same browser (or
938
+ * account) in this room: its seat, name and colour. A page with no client here (a homie.rocks room page, a watch
939
+ * page before its game connects) is a watcher with a handle. Signed in, a member, the owner: the Worker's word.
940
+ */
941
+ chatter(client, conn) {
942
+ let g = client;
943
+ if (!g && conn) {
944
+ const mine = this.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
945
+ g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
946
+ }
947
+ const via = String(g?.via ?? conn?.via ?? '');
948
+ const owner = via.split('~').includes('o');
949
+ const seat = g ? g.seat : null;
950
+ const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.watchIdOf(conn ?? g?.conn ?? {})}`);
951
+ const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || handle;
952
+ return {
953
+ key: g ? `c:${g.id}` : `w:${this.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
954
+ player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
955
+ owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
956
+ watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
957
+ };
958
+ }
959
+
960
+ /** A token bucket: true (and one token spent) when there is one. */
961
+ takeToken(map, key, rate, now) {
962
+ let b = map.get(key);
963
+ if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
964
+ b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
965
+ b.at = now;
966
+ if (b.tokens < 1) return false;
967
+ b.tokens -= 1;
968
+ return true;
969
+ }
970
+
971
+ /**
972
+ * One message from anyone in the room: `{ t: 'say', text }` (typed), `{ t: 'say', say: '<line id>' }` (a quick line)
973
+ * or `{ t: 'react', kind }` (an emoji), with an optional `n` (the sender's own id for it) and `bubble: false` (not
974
+ * over my character). Refused with `held` (or homie.rocks's `slow`) to the sender only; else published to everyone,
975
+ * after the studio's review for typed text when there is one.
976
+ */
977
+ onChat({ client = null, conn = null }, m) {
978
+ const now = this.now();
979
+ const rules = this.chatRules();
980
+ const who = this.chatter(client, conn);
981
+ const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
982
+ const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
983
+ const stats = this.stats;
984
+ const reply = (why, extra = {}) => {
985
+ stats.chatHeld = stats.chatHeld ?? {};
986
+ stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
987
+ const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
988
+ if (who.conn) this.sendText(who.conn, out);
989
+ return { ok: false, why };
990
+ };
991
+ if (who.agent) return reply('ai_seat');
992
+ if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
993
+ if (who.hub && !rules.hub) return reply('hub');
994
+ if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
995
+ const need = kind === 'text' ? rules.who : rules.react;
996
+ if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
997
+ if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
998
+ const mute = this.muteOf({ token: who.token, browser: who.browser, player: who.player });
999
+ if (mute) return reply('muted', { until: mute.until });
1000
+ const b = this.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
1001
+ this.chatBuckets.set(who.key, b);
1002
+ if (!this.takeToken(this.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
1003
+ if (who.ip && !this.takeToken(this.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
1004
+ 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 });
1005
+ let rec;
1006
+ if (kind === 'react') {
1007
+ const k = String(m.kind ?? m.react ?? '');
1008
+ const r = rules.emoji.find((x) => x.k === k);
1009
+ if (!r) return reply('unknown');
1010
+ // A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
1011
+ while (this.chatReacts.length && this.chatReacts[0] <= now - 1000) this.chatReacts.shift();
1012
+ if (this.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
1013
+ this.chatReacts.push(now);
1014
+ rec = { kind: 'react', react: r.k, glyph: r.e };
1015
+ } else if (kind === 'line') {
1016
+ const id = String(m.say ?? m.line ?? '');
1017
+ const l = rules.lines.find((x) => x.id === id);
1018
+ if (!l) return reply('unknown');
1019
+ rec = { kind: 'line', say: l.id, text: l.text };
1020
+ } else {
1021
+ const text = cleanText(m.text, rules.max);
1022
+ if (!text) return reply('empty');
1023
+ const f = floor(text, rules);
1024
+ if (!f.ok) return reply(f.why);
1025
+ if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
1026
+ b.lastText = text.toLowerCase();
1027
+ b.lastTextAt = now;
1028
+ rec = { kind: 'text', text };
1029
+ }
1030
+ if (kind !== 'react') b.lastSayAt = now;
1031
+ Object.assign(rec, {
1032
+ name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
1033
+ bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner,
1034
+ // Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
1035
+ from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
1036
+ });
1037
+ if (kind === 'text' && rules.ai && typeof this.review === 'function' && !who.owner) {
1038
+ if (this.chatPending >= CHAT_LIMITS.pending) return reply('busy');
1039
+ this.chatPending += 1;
1040
+ const t0 = now;
1041
+ Promise.resolve()
1042
+ .then(() => this.review(rec.text, { room: this.code, links: rules.links }))
1043
+ .then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
1044
+ .then((v) => {
1045
+ this.chatPending = Math.max(0, this.chatPending - 1);
1046
+ if (v && v.ok === false) {
1047
+ stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
1048
+ // What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
1049
+ stats.chatAiWhy = stats.chatAiWhy ?? {};
1050
+ const why = String(v.why ?? 'held').slice(0, 24);
1051
+ stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
1052
+ reply('ai');
1053
+ return;
1054
+ }
1055
+ if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
1056
+ rec.review = { by: v?.by ?? 'ai', ms: this.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
1057
+ // A mute that came while it was being reviewed still holds it.
1058
+ if (this.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
1059
+ this.publishChat(rec, who.conn, n);
1060
+ });
1061
+ return { ok: true, pending: true };
1062
+ }
1063
+ return this.publishChat(rec, who.conn, n);
1064
+ }
1065
+
1066
+ /** A message every socket and every watching shell gets now (the sender's own copy carries its `n`). */
1067
+ publishChat(rec, senderConn = null, n = null) {
1068
+ const now = this.now();
1069
+ rec.id = randomId(6);
1070
+ rec.at = now;
1071
+ this.chatLog.push(rec);
1072
+ while (this.chatLog.length > CHAT_LIMITS.keep || (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs)) this.chatLog.shift();
1073
+ const msg = this.wireLine(rec);
1074
+ const text = JSON.stringify(msg);
1075
+ const mine = n ? JSON.stringify({ ...msg, n }) : text;
1076
+ const hub = this.chatRules().hub;
1077
+ for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, o.conn === senderConn ? mine : text);
1078
+ for (const w of this.watchers) if (!w.hub || hub) this.sendText(w, w === senderConn ? mine : text);
1079
+ if (rec.kind === 'react') this.stats.chatReacts = (this.stats.chatReacts ?? 0) + 1;
1080
+ else this.stats.chatLines = (this.stats.chatLines ?? 0) + 1;
1081
+ return { ok: true, id: rec.id };
1082
+ }
1083
+
1084
+ /** Take messages down (the owner's `unsay`): from the window, and from every screen that shows them. */
1085
+ unsay(ids) {
1086
+ const gone = new Set(ids);
1087
+ const before = this.chatLog.length;
1088
+ this.chatLog = this.chatLog.filter((r) => !gone.has(r.id));
1089
+ const text = JSON.stringify({ t: 'unline', ids: [...gone] });
1090
+ for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, text);
1091
+ for (const w of this.watchers) this.sendText(w, text);
1092
+ return before - this.chatLog.length;
1093
+ }
1094
+
1095
+ /**
1096
+ * 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
1097
+ * by that browser and account, as a kick holds a player's other tabs.
1098
+ */
1099
+ holdSender(line, minutes, now, { kick = false, message = '', purge = false } = {}) {
1100
+ const f = line.from ?? {};
1101
+ if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
1102
+ const until = now + minutes * 60_000;
1103
+ const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
1104
+ if (kick) this.bans = [...this.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
1105
+ else this.mutes = [...this.mutes, hold].slice(-CONTROL_LIMITS.mutes);
1106
+ this.tellBrowser(hold, kick ? { t: 'kicked', room: this.code, until, message } : { t: 'muted', room: this.code, until });
1107
+ const removed = purge ? this.unsay(this.linesOf(f)) : 0;
1108
+ this.tellWatchers();
1109
+ return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
1110
+ }
1111
+
1112
+ /** The lines of one sender (a line's `from`), for a mute or a kick that also takes their messages down. */
1113
+ linesOf(from) {
1114
+ if (!from) return [];
1115
+ 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);
1116
+ }
1117
+
869
1118
  /* ------------------------------------------------------------ watchers (section 16) */
870
1119
 
871
1120
  /**
@@ -1008,6 +1257,10 @@ export class NetRoom {
1008
1257
  }
1009
1258
  this.reapSeats(now);
1010
1259
  this.seatWaiting();
1260
+ // The chat window is minutes long; buckets of senders who went quiet are dropped.
1261
+ while (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs) this.chatLog.shift();
1262
+ if (this.chatBuckets.size > 512) this.chatBuckets.clear();
1263
+ if (this.chatAddress.size > 512) this.chatAddress.clear();
1011
1264
  if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) {
1012
1265
  for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
1013
1266
  this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
@@ -1015,6 +1268,8 @@ export class NetRoom {
1015
1268
  this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
1016
1269
  // The party's dial and vote are the room's: a new party starts from the server's level.
1017
1270
  this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
1271
+ // Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
1272
+ this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
1018
1273
  this.seatsDirty = false; this.persistDirty = false;
1019
1274
  try { this.store?.clear?.(); } catch { /* best effort */ }
1020
1275
  }
@@ -1333,13 +1588,17 @@ export class NetRoom {
1333
1588
  this.mutes = this.mutes.filter((m) => m.until > now);
1334
1589
  switch (op) {
1335
1590
  case 'kick': {
1336
- const target = this.findClient(a);
1591
+ // From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too).
1592
+ const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
1593
+ const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
1594
+ 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
1595
  if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
1596
+ const purged = a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
1338
1597
  const until = now + minutes * 60_000;
1339
1598
  // The message is the studio's words; `until` says when (the shell says it in the player's own time).
1340
1599
  const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
1341
1600
  // 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 };
1601
+ 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
1602
  this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
1344
1603
  // Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
1345
1604
  const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
@@ -1357,21 +1616,32 @@ export class NetRoom {
1357
1616
  this.tellBrowser(ban, { t: 'kicked', room: this.code, until, message });
1358
1617
  this.persist(now);
1359
1618
  this.tellWatchers();
1360
- return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until };
1619
+ return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
1361
1620
  }
1362
1621
  case 'mute': {
1363
- const target = this.findClient(a);
1622
+ const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
1623
+ const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
1624
+ if (!target && line && a.off !== true) return this.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
1364
1625
  if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
1365
1626
  const off = a.off === true;
1627
+ const purged = !off && a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
1366
1628
  const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
1367
1629
  this.mutes = this.mutes.filter((m) => !same(m));
1368
1630
  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);
1631
+ 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
1632
  const note = { t: 'mute', id: target.id, seat: target.seat, until };
1371
1633
  for (const o of this.live()) this.send(o, note);
1372
1634
  this.tellBrowser(target, { t: 'muted', room: this.code, until });
1373
1635
  this.tellWatchers();
1374
- return { ok: true, op, name: target.name, seat: target.seat, until };
1636
+ return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
1637
+ }
1638
+ case 'unsay': {
1639
+ // Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
1640
+ 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);
1641
+ if (!ids.length) return { ok: false, error: 'no-line', message: 'which message?' };
1642
+ const removed = this.unsay(ids);
1643
+ this.stats.chatRemoved = (this.stats.chatRemoved ?? 0) + removed;
1644
+ return { ok: true, op, removed };
1375
1645
  }
1376
1646
  case 'announce': {
1377
1647
  const text = oneLine(a.text, CONTROL_LIMITS.announce);
@@ -1387,6 +1657,8 @@ export class NetRoom {
1387
1657
  const msg = JSON.stringify({ t: 'announce', ...this.announcement });
1388
1658
  for (const o of this.live()) this.sendText(o.conn, msg);
1389
1659
  for (const w of this.watchers) this.sendText(w, msg);
1660
+ // The room's chat shows it too, as the studio's own line (section 19).
1661
+ this.publishChat({ kind: 'studio', text, name: 'Studio', seat: null, colour: null, by: 'studio', bubble: false, acct: false, owner: true, from: null });
1390
1662
  this.tellWatchers();
1391
1663
  return { ok: true, op, id: this.announcement.id, people: this.live().length, until: this.announcement.until };
1392
1664
  }
@@ -1541,6 +1813,12 @@ export class NetRoom {
1541
1813
  }),
1542
1814
  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
1815
  mutes: this.mutes.filter((m) => m.until > now).map((m) => ({ name: m.name, seat: m.seat, until: m.until })),
1816
+ // Room chat (section 19): the window as the owner sees it, with who sent each line (never an address).
1817
+ chat: {
1818
+ rules: this.chatRules(),
1819
+ pending: this.chatPending,
1820
+ 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 } : {}) })),
1821
+ },
1544
1822
  },
1545
1823
  };
1546
1824
  }
@@ -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
 
package/worker/site.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * What a studio puts in site/ wins: a whole page (site/pages), a partial (site/partials), its tokens
10
10
  * (site/theme.json) and CSS (site/theme.css). The build gathers them into games.json and site/dist/_site/.
11
11
  */
12
- import { qrSvg } from './qr.mjs';
12
+ import { isLocalOrigin, qrSvg } from './qr.mjs';
13
13
  import { STUDIO_VERSION_TAG } from './version.mjs';
14
14
  import { licenseLabel, licenseOf, remixRow } from './license.mjs';
15
15
  import { POLICY_WORDS, policyWords } from './servers.mjs';
@@ -690,7 +690,7 @@ export function watchOf(g) {
690
690
  * A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). A server's room
691
691
  * (0.16.0) names its server and policy, and how many AI are in it (always marked AI).
692
692
  */
693
- export function roomView(g, r, max, srv = null) {
693
+ export function roomView(g, r, max, srv = null, { chat = false } = {}) {
694
694
  const room = r.name ?? r.room;
695
695
  const m = /^(?:pub|s-[a-z0-9-]+)-(\d+)$/.exec(String(room ?? ''));
696
696
  const n = m ? Number(m[1]) : null;
@@ -701,6 +701,9 @@ export function roomView(g, r, max, srv = null) {
701
701
  server: srv && srv.id !== 'public' ? { id: srv.id, name: srv.name, badge: policyWords(srv).badge } : null,
702
702
  policy: srv?.policy ?? 'open',
703
703
  ai: Number(r.ai ?? r.agents ?? 0) || 0,
704
+ // Room chat (0.23.0, NETPLAY.md section 19): its chat is on and homie.rocks's page for it may show it (and open the
705
+ // room's watch socket for its lines and emoji).
706
+ ...(chat ? { chat: true } : {}),
704
707
  };
705
708
  }
706
709
 
@@ -1080,7 +1083,9 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1080
1083
  const host = origin.replace(/^https?:\/\//, '');
1081
1084
  const playUrl = `${origin}/${g.id}/play`;
1082
1085
  let qr = '';
1083
- try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
1086
+ // Local development: no code a phone cannot open; the landing says to deploy instead of naming this computer's address.
1087
+ const local = isLocalOrigin(origin);
1088
+ if (!local) try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
1084
1089
  const tvOn = L.tv !== false;
1085
1090
  const max = g.players?.max ?? 8;
1086
1091
  const words = L.words ?? {};
@@ -1111,7 +1116,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1111
1116
  <p class="lead">Nothing to download and nothing to install: ${esc(g.name)} runs in the browser you already have, and everyone who presses Play meets in the same public room.</p>
1112
1117
  <ol class="ways">
1113
1118
  <li class="way"><span class="n">${icon('phone')}</span><div><h3>On your phone</h3><p>${esc(L.controls?.phone ?? 'Touch controls appear under your thumbs; the middle of the screen stays clear.')}</p>
1114
- <div class="scan desk-only">${qr ? `<span class="qr" role="img" aria-label="A code for your phone’s camera: ${esc(host)}/${esc(g.id)}/play">${qr}</span>` : ''}<p>Point your phone’s camera at the code, or open <span class="addr">${esc(host)}/${esc(g.id)}/play</span>.</p></div>
1119
+ <div class="scan desk-only">${qr ? `<span class="qr" role="img" aria-label="A code for your phone’s camera: ${esc(host)}/${esc(g.id)}/play">${qr}</span>` : ''}<p>${local ? 'Deploy to share: once the studio is online, a code here takes a phone straight to the game.' : `Point your phone’s camera at the code, or open <span class="addr">${esc(host)}/${esc(g.id)}/play</span>.`}</p></div>
1115
1120
  <p class="phone-only" style="margin-top:8px"><strong>You are on it:</strong> press Play above.</p></div></li>
1116
1121
  <li class="way"><span class="n">${icon('computer')}</span><div><h3>On your computer</h3><p>${esc(L.controls?.computer ?? 'Keys and mouse, in any modern browser; the game shows you which keys.')}</p></div></li>
1117
1122
  ${tvOn ? `<li class="way"><span class="n">${icon('tv')}</span><div><h3>On the TV</h3><ol><li>Open <span class="addr">${esc(host)}/${esc(g.id)}/tv</span> in the TV’s browser, or on a laptop plugged into it.</li><li>Scan the code it shows with your phone’s camera.</li><li>Play on your phone. Everyone on the couch joins the same way, up to ${esc(max)} ${esc(many)}, and the TV shows the whole room.</li></ol>${L.controls?.tv ? `<p style="margin-top:8px">${esc(L.controls.tv)}</p>` : ''}</div></li>` : ''}
package/worker/stats.mjs CHANGED
@@ -59,7 +59,10 @@ CREATE TABLE IF NOT EXISTS stats_keys (
59
59
  ) WITHOUT ROWID;
60
60
  `;
61
61
 
62
- export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons', 'brain-microdollars']);
62
+ export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons', 'brain-microdollars',
63
+ // Room chat (0.23.0): lines and reactions sent, messages held (by why: words, link, ai…), the review's calls and neurons.
64
+ // Counts only: never a message, never who.
65
+ 'chat-lines', 'chat-reacts', 'chat-held', 'chat-reviews', 'chat-neurons']);
63
66
  const MAX_METRICS = new Set(['peak', 'peak-room']);
64
67
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
65
68
  const DAY = /^\d{4}-\d{2}-\d{2}$/;
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.21.0';
2
+ export const STUDIO_VERSION_TAG = '0.23.0';