@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/office.mjs CHANGED
@@ -42,6 +42,13 @@
42
42
  * passes (issued and revoked at once; the secret is shown once), a room's dial (room_level), and whether AI guides
43
43
  * may talk (the first time is an ASK: the owner's consent). A server change reaches its live rooms as a signed
44
44
  * `policy` control; AI leave after the round when a server becomes humans-only.
45
+ *
46
+ * ROOM CHAT (0.23.0, worker/chat.mjs and worker/chat-store.mjs; NETPLAY.md section 19): each game's chat rules (its
47
+ * game.json defaults, the owner's for the game, the owner's for each server), the last minutes of every live room's
48
+ * chat with Remove (a signed `unsay`), and Mute or Kick from a line (its sender, with their lines taken down), the
49
+ * reports players made (each the one message it is about) with Dismiss, and the review's day (Workers AI neurons).
50
+ * From an office key, a change that opens chat up (more allowed, fewer checks) is an ASK; one that tightens it, or
51
+ * taking a message down, happens at once.
45
52
  */
46
53
  import { SEAT_MAX, seatsOf } from './seats.mjs';
47
54
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
@@ -51,6 +58,8 @@ import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from '.
51
58
  import { esc, layout, notFoundPage } from './site.mjs';
52
59
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
53
60
  import { licenseOf } from './license.mjs';
61
+ import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
62
+ import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
54
63
 
55
64
  export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
56
65
 
@@ -456,6 +465,8 @@ async function roomRow(env, meta, room, max) {
456
465
  host: f.host ?? null, announce: f.announce ?? null,
457
466
  slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
458
467
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
468
+ // Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
469
+ chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
459
470
  // The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
460
471
  ...(f.brains && typeof f.brains === 'object' ? { brains: {
461
472
  brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
@@ -517,14 +528,23 @@ export async function officeView(env, cat, origin) {
517
528
  const row = settings.get(g.id);
518
529
  const max = seatsFor(g, settings);
519
530
  const rooms = (await Promise.all((await liveRooms(env, g.id)).map((r) => roomRow(env, g, r.room, max)))).filter(Boolean)
520
- .filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length);
531
+ .filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length || (r.chat && r.chat.lines.length));
521
532
  rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
522
533
  const servers = await serversOf(env, g, { fresh: true });
523
534
  const byServer = {};
524
535
  for (const r of rooms) { const b = (byServer[r.server] ??= { rooms: 0, players: 0, ai: 0 }); b.rooms += 1; b.players += r.players; b.ai += r.ai; }
525
536
  const members = await memberCountsOf(env, g.id);
537
+ const chatRows = await chatRowsOf(env, { fresh: true });
526
538
  return {
527
539
  id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
540
+ // Room chat (0.23.0): the game's rules as Quick play has them, where each came from, and each server's own.
541
+ chat: {
542
+ rules: publicChat(chatOf(g, servers.find((x) => x.id === 'public') ?? null, chatRows)),
543
+ from: chatRows.get(`${g.id}/`) ? 'office' : g.chat !== undefined ? 'game.json' : 'default',
544
+ office: chatRows.get(`${g.id}/`) ?? null,
545
+ servers: servers.filter((x) => x.id !== 'public' || chatRows.get(`${g.id}/public`)).map((sv) => ({ id: sv.id, name: sv.name, rules: publicChat(chatOf(g, sv, chatRows)), office: chatRows.get(`${g.id}/${sv.id}`) ?? null })),
546
+ reports: await reportsOf(env, { game: g.id }),
547
+ },
528
548
  // Servers (0.16.0): each with its policy and what is on it now; its build reads the dial only from netplay rev 6.
529
549
  servers: servers.map((sv) => ({ ...serverView(sv, { origin, game: g.id }), live: byServer[sv.id] ?? { rooms: 0, players: 0, ai: 0 }, members: members[sv.id] ?? 0 })),
530
550
  build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null, guides: Number(g.netplayRev) >= 7 },
@@ -545,6 +565,7 @@ export async function officeView(env, cat, origin) {
545
565
  fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
546
566
  agentsTalk: await talkConsented(env),
547
567
  brain: await brainDay(env),
568
+ chat: await chatDay(env, today()),
548
569
  };
549
570
  }
550
571
  async function memberCountsOf(env, game) {
@@ -579,8 +600,10 @@ function checkAction(cat, op, body) {
579
600
  if (!room) return bad('room is the room\'s code (pub-3, or a named room)');
580
601
  const id = typeof body.id === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.id) ? body.id : null;
581
602
  const seat = Number.isInteger(body.seat) && body.seat >= 0 && body.seat < SEAT_MAX ? body.seat : null;
582
- if (id === null && seat === null) return bad('name the player: id (from the office) or seat');
583
- return { ok: true, action: { op, game: meta.id, room, id, seat, minutes, ...(op === 'kick' ? { address: body.address === true, message: oneLine(body.message, 200) || null } : { off: body.off === true }), name: oneLine(body.name, 40) || null } };
603
+ // From a chat line (0.23.0): its sender, whoever they are now (a watcher too); `purge` takes their lines down.
604
+ const line = typeof body.line === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.line) ? body.line : null;
605
+ if (id === null && seat === null && line === null) return bad('name the player: id (from the office), seat, or line (a chat message of theirs)');
606
+ return { ok: true, action: { op, game: meta.id, room, id, seat, ...(line ? { line, purge: body.purge === true } : {}), minutes, ...(op === 'kick' ? { address: body.address === true, message: oneLine(body.message, 200) || null } : { off: body.off === true }), name: oneLine(body.name, 40) || null } };
584
607
  }
585
608
  case 'close': {
586
609
  if (!meta) return bad('game is one of this studio\'s game ids');
@@ -659,11 +682,56 @@ function checkAction(cat, op, body) {
659
682
  if (budget !== undefined && (!['workers-ai', 'owner-key'].includes(body.mode) || !(budget >= 0) || budget > (body.mode === 'owner-key' ? 100 : 1e7))) return bad('budget goes with workers-ai (neurons a day; the free allocation is 10,000 an account) or owner-key (dollars a day, at most 100)');
660
683
  return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
661
684
  }
685
+ case 'chat-rules': {
686
+ if (!meta) return bad('game is one of this studio\'s game ids');
687
+ const server = body.server === undefined || body.server === null || body.server === '' ? '' : String(body.server);
688
+ if (server && !(server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(server))) return bad('server is a server\'s id, or leave it out for the whole game');
689
+ if (body.reset === true) return { ok: true, action: { op, game: meta.id, server, reset: true } };
690
+ const { game: _g, server: _s, ...rest } = body;
691
+ const c = checkChatRules(rest);
692
+ if (!c.ok) return c;
693
+ if (!Object.keys(c.fields).length) return bad(`say what to change: mode (${CHAT_MODES.join(', ')}), who / react (${CHAT_WHO.join(', ')}), slow, max, links, swears, ai, bubbles, watchers, hub, block, allow, lines, emoji; or reset: true`);
694
+ return { ok: true, action: { op, game: meta.id, server, fields: c.fields } };
695
+ }
696
+ case 'chat-remove': {
697
+ if (!meta) return bad('game is one of this studio\'s game ids');
698
+ if (!room) return bad('room is the room\'s code');
699
+ const ids = (Array.isArray(body.ids) ? body.ids : body.id !== undefined ? [body.id] : []).filter((x) => typeof x === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(x)).slice(0, 64);
700
+ if (!ids.length && body.all !== true) return bad('id is the message\'s id (from the office), or all: true');
701
+ return { ok: true, action: { op, game: meta.id, room, ...(body.all === true ? { all: true } : { ids }) } };
702
+ }
703
+ case 'chat-report': {
704
+ if (!/^cr_[a-f0-9]{16}$/.test(String(body.id ?? ''))) return bad('id is the report\'s id');
705
+ return { ok: true, action: { op, id: body.id } };
706
+ }
707
+ case 'chat-budget': {
708
+ const n = Math.floor(Number(body.neurons));
709
+ if (!(n >= 0 && n <= 1e7)) return bad('neurons is the review\'s day for the whole studio (0 to 10,000,000; Workers AI gives an account 10,000 a day free)');
710
+ return { ok: true, action: { op, neurons: n } };
711
+ }
662
712
  default:
663
713
  return bad('unknown control');
664
714
  }
665
715
  }
666
716
 
717
+ /** Whether a chat change opens chat up (an office key only ASKS for these): more allowed, fewer checks, wider doors. */
718
+ export function chatOpensUp(before, f) {
719
+ const rank = { off: 0, emoji: 1, lines: 2, text: 3 };
720
+ const who = { members: 0, 'signed-in': 1, anyone: 2 };
721
+ if (f.mode !== undefined && rank[f.mode] > rank[before.mode]) return true;
722
+ for (const k of ['who', 'react']) if (f[k] !== undefined && who[f[k]] > who[before[k]]) return true;
723
+ if (f.slow !== undefined && f.slow < before.slow) return true;
724
+ if (f.max !== undefined && f.max > before.max) return true;
725
+ if (f.links === 'allow' && before.links !== 'allow') return true;
726
+ if (f.swears === 'allow' && before.swears !== 'allow') return true;
727
+ for (const k of ['watchers', 'hub']) if (f[k] === true && !before[k]) return true;
728
+ if (f.ai === false && before.ai) return true;
729
+ if (f.allow !== undefined && f.allow.some((w) => !(before.allow ?? []).includes(w))) return true;
730
+ if (f.block !== undefined && (before.block ?? []).some((w) => !f.block.includes(w))) return true;
731
+ if (f.lines !== undefined || f.emoji !== undefined) return true;
732
+ return false;
733
+ }
734
+
667
735
  /** The action in plain words, for the owner's confirm page and the AI's card. */
668
736
  export function describe(cat, a) {
669
737
  const g = (cat.games ?? []).find((x) => x.id === a.game);
@@ -705,6 +773,26 @@ export function describe(cat, a) {
705
773
  case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
706
774
  ? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
707
775
  : `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
776
+ case 'chat-rules': {
777
+ const where = a.server ? `${gname}'s server ${a.server}` : gname;
778
+ if (a.reset) return `Give ${where} its own chat rules back (the game's game.json, or Homie's defaults).`;
779
+ const f = a.fields;
780
+ const bits = [];
781
+ if (f.mode) bits.push({ off: 'turn chat off', emoji: 'keep chat to emoji', lines: 'keep chat to emoji and quick lines', text: 'let players type' }[f.mode]);
782
+ if (f.who) bits.push(`typing for ${f.who === 'anyone' ? 'anyone, guests too' : f.who === 'signed-in' ? 'signed-in players (a passkey)' : 'members only'}`);
783
+ if (f.react) bits.push(`emoji and quick lines for ${f.react === 'anyone' ? 'anyone' : f.react === 'signed-in' ? 'signed-in players' : 'members only'}`);
784
+ if (f.slow !== undefined) bits.push(f.slow ? `slow mode: one message every ${f.slow} s` : 'no slow mode');
785
+ if (f.max !== undefined) bits.push(`messages up to ${f.max} characters`);
786
+ if (f.links) bits.push(f.links === 'allow' ? 'allow links' : 'hold links');
787
+ if (f.swears) bits.push(f.swears === 'allow' ? 'allow swearing' : 'hold swearing');
788
+ if (f.ai !== undefined) bits.push(f.ai ? 'review typed messages with this studio\'s Workers AI' : 'stop the AI review (the word list still runs)');
789
+ const rest = Object.keys(f).filter((k) => !['mode', 'who', 'react', 'slow', 'max', 'links', 'swears', 'ai'].includes(k));
790
+ if (rest.length) bits.push(`change its ${rest.join(', ')}`);
791
+ return `Room chat in ${where}: ${bits.join('; ')}.`;
792
+ }
793
+ case 'chat-remove': return a.all ? `Take every message down in ${where}.` : `Take ${a.ids.length === 1 ? 'a message' : `${a.ids.length} messages`} down in ${where}.`;
794
+ case 'chat-report': return 'Dismiss a chat report (it is deleted).';
795
+ case 'chat-budget': return `Let room chat's review use up to ${a.neurons.toLocaleString('en-US')} Workers AI neurons a day for the whole studio (about ${a.neurons.toLocaleString('en-US')} typed messages; the free allocation is 10,000 an account, shared with the AI guides; past it on Workers Paid, $0.011 per 1,000).`;
708
796
  default: return 'A control.';
709
797
  }
710
798
  }
@@ -719,6 +807,15 @@ export async function needsAsk(env, cat, a) {
719
807
  const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
720
808
  return narrows(before, a.fields);
721
809
  }
810
+ // A bigger review budget can cost money on Workers Paid: the owner says yes. A smaller one happens at once.
811
+ if (a.op === 'chat-budget') return a.neurons > (await chatDay(env, today())).budget.neurons;
812
+ if (a.op === 'chat-rules') {
813
+ if (a.reset) return true;
814
+ const meta = (cat.games ?? []).find((g) => g.id === a.game);
815
+ const servers = meta ? await serversOf(env, meta, { fresh: true }) : [];
816
+ const before = chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? null, await chatRowsOf(env, { fresh: true }));
817
+ return chatOpensUp(before, a.fields);
818
+ }
722
819
  if (a.op === 'agents-brain') {
723
820
  if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
724
821
  // Raising the cap on the owner's own money is the owner's to confirm; Workers AI's free allocation is not money.
@@ -735,7 +832,7 @@ async function serverRooms(env, game, server) {
735
832
 
736
833
  /** A server's policy reaches every live room of it now (a signed `policy` control; section 17). */
737
834
  async function pushPolicy(env, meta, settings, server) {
738
- const pol = policyOf(server, { seats: seatsFor(meta, settings) });
835
+ const pol = policyOf(server, { seats: seatsFor(meta, settings), chat: chatOf(meta, server, await chatRowsOf(env, { fresh: true })) });
739
836
  const rooms = await serverRooms(env, meta.id, server.id);
740
837
  const res = await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'policy', { pol })));
741
838
  return { rooms: rooms.length, leaving: res.reduce((n, r) => n + (r.leaving ?? 0), 0) };
@@ -748,9 +845,10 @@ export async function perform(env, cat, a) {
748
845
  switch (a.op) {
749
846
  case 'kick':
750
847
  case 'mute': {
848
+ const line = a.line ? { line: a.line, purge: a.purge === true } : {};
751
849
  const r = await roomControl(env, meta, settings, a.room, a.op, a.op === 'kick'
752
- ? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...(a.message ? { message: a.message } : {}) }
753
- : { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off });
850
+ ? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...line, ...(a.message ? { message: a.message } : {}) }
851
+ : { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off, ...line });
754
852
  return r;
755
853
  }
756
854
  case 'close': return roomControl(env, meta, settings, a.room, 'close', a.reopen ? { reopen: true } : { minutes: a.minutes, ...(a.message ? { message: a.message } : {}) });
@@ -868,6 +966,32 @@ export async function perform(env, cat, a) {
868
966
  } catch (error) { return { ok: false, error: 'not-migrated', message: `Agent passes need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
869
967
  }
870
968
  case 'room-level': return roomControl(env, meta, settings, a.room, 'level', { level: a.level });
969
+ case 'chat-rules': {
970
+ if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for its office.' };
971
+ try {
972
+ if (a.reset) await clearChatRules(env, meta.id, a.server);
973
+ else await writeChatRules(env, meta.id, a.server, a.fields);
974
+ } catch (error) { return { ok: false, error: 'not-migrated', message: `Chat rules need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
975
+ // Every live room the change reaches hears its new rules now (a signed policy control, as a server change does).
976
+ const servers = await serversOf(env, meta, { fresh: true });
977
+ const reach = a.server ? servers.filter((x) => x.id === a.server) : servers;
978
+ let rooms = 0;
979
+ for (const sv of reach) rooms += (await pushPolicy(env, meta, settings, sv)).rooms;
980
+ const rows = await chatRowsOf(env, { fresh: true });
981
+ const rules = publicChat(chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? PUBLIC_SERVER, rows));
982
+ return { ok: true, op: a.op, game: meta.id, server: a.server || null, rules, rooms };
983
+ }
984
+ case 'chat-remove': {
985
+ const r = await roomControl(env, meta, settings, a.room, 'unsay', a.all ? { all: true } : { ids: a.ids });
986
+ return r;
987
+ }
988
+ case 'chat-report': {
989
+ try { return { op: a.op, ...(await dismissReport(env, a.id)) }; } catch (error) { return { ok: false, error: 'not-migrated', message: String(error?.message ?? error).slice(0, 120) }; }
990
+ }
991
+ case 'chat-budget': {
992
+ try { await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('chat_budget', ?1)").bind(JSON.stringify({ neurons: a.neurons })).run(); } catch (error) { return { ok: false, error: 'no-db', message: String(error?.message ?? error).slice(0, 120) }; }
993
+ return { ok: true, op: a.op, budget: { neurons: a.neurons }, note: `Room chat's review may use ${a.neurons.toLocaleString('en-US')} neurons a day from now on (rooms read it again within a minute).` };
994
+ }
871
995
  case 'agents-brain': {
872
996
  const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server);
873
997
  if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
@@ -1025,7 +1149,14 @@ async function api(request, env, url, cat) {
1025
1149
  return json({ ok: true, game, server, members });
1026
1150
  } catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
1027
1151
  }
1152
+ // Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
1153
+ if (path === '/_studio/api/chat' && request.method === 'GET') {
1154
+ const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
1155
+ const view = await officeView(env, { ...cat, games: metas }, url.origin);
1156
+ return json({ ok: true, day: view.chat, games: view.games.map((g) => ({ id: g.id, name: g.name, chat: g.chat, rooms: g.rooms.map((r) => ({ room: r.room, label: r.label, players: r.players, chat: r.chat })) })) });
1157
+ }
1028
1158
  const OPS = {
1159
+ '/_studio/api/chat/rules': 'chat-rules', '/_studio/api/chat/remove': 'chat-remove', '/_studio/api/chat/report': 'chat-report', '/_studio/api/chat/budget': 'chat-budget',
1029
1160
  '/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
1030
1161
  '/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
1031
1162
  '/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
package/worker/pages.mjs CHANGED
@@ -7,6 +7,7 @@ import { NET_PALETTE } from './room.mjs';
7
7
  import { SKILLS } from './agents.mjs';
8
8
  import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
9
9
  import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
10
+ import { CHAT_CSS, CHAT_JS, CHAT_OWNER_JS, chatBoot } from './chat-page.mjs';
10
11
 
11
12
  export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
12
13
 
@@ -84,12 +85,16 @@ export function sharePlaces(value) {
84
85
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
85
86
  * covers the middle of the screen or a thumb.
86
87
  */
87
- export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
88
+ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false } = {}) {
88
89
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
89
90
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
90
91
  const places = sharePlaces(g.screen?.share);
91
92
  // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
92
93
  // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
94
+ // A game with an art direction (games.json `ui`, from its style.json): the pills wear its paper and text, like its HUD.
95
+ const okHex = (v) => /^#[0-9a-f]{6}$/i.test(String(v ?? ''));
96
+ const ui = okHex(g.ui?.paper) && okHex(g.ui?.text) ? g.ui : null;
97
+ const pillUi = ui ? `\n.pill, .chip, .pill.dim { background: ${ui.paper}e6; color: ${ui.text}; border-color: ${ui.text}22; }\n.pill svg { color: ${ui.text}; }` : '';
93
98
  const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
94
99
  html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
95
100
  iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
@@ -155,7 +160,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
155
160
  .notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
156
161
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
157
162
  [hidden] { display: none !important; }
158
- ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
163
+ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
159
164
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
160
165
  // A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
161
166
  // The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
@@ -166,7 +171,9 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
166
171
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
167
172
  const joinCorner = corner('join', 'bottom-right');
168
173
  const first = places.desk;
169
- const joinCard = screen && joinUrl ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
174
+ // Local development: no join card at all on the big screen (no phone can open this computer's address); the room
175
+ // button's sheet says to deploy to share, for whoever looks for a link.
176
+ const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
170
177
  const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
171
178
  <div class="pills"><button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
172
179
  <button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button></div>
@@ -196,8 +203,10 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
196
203
  <div class="card" data-screen hidden></div>
197
204
  ${joinCard}${share}
198
205
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
206
+ <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
207
+ <script>${CHAT_JS}</script>
199
208
  <script>${SHELL_JS}</script>${g.saves && !screen ? `
200
- <script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
209
+ <script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
201
210
  }
202
211
 
203
212
  /*
@@ -267,6 +276,8 @@ const SHELL_JS = String.raw`(function () {
267
276
  var boot = window.__HOMIE_PLAY;
268
277
  var params = new URLSearchParams(location.search);
269
278
  var screenMode = params.get('screen') === '1' || boot.screen === true;
279
+ // Local development (this computer's own address): never hand a phone a link it cannot open.
280
+ var LOCAL = /^(localhost|.*\.localhost|127\..*|\[::1\]|0\.0\.0\.0)$/.test(location.hostname);
270
281
  var asked = params.get('hand');
271
282
  var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
272
283
  var want = screenMode ? 'screen' : 'play';
@@ -352,7 +363,7 @@ const SHELL_JS = String.raw`(function () {
352
363
  state.link = link;
353
364
  ui.querySelector('[data-room-code]').textContent = labelOf(room);
354
365
  ui.querySelector('[data-room-label]').textContent = labelOf(room);
355
- ui.querySelector('[data-room-link]').textContent = link.replace(/^https?:\/\//, '');
366
+ ui.querySelector('[data-room-link]').textContent = LOCAL ? 'Deploy to share: this preview address works on this computer only.' : link.replace(/^https?:\/\//, '');
356
367
  ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
357
368
  toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
358
369
  ui.querySelector('[data-invite]').addEventListener('click', function () {
@@ -391,6 +402,8 @@ const SHELL_JS = String.raw`(function () {
391
402
  if (params.get('debug') === '1') q.set('debug', '1');
392
403
  // Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
393
404
  if (state.quietAi) q.set('hush', '1');
405
+ // Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
406
+ try { if (localStorage.getItem('homie-chat-show') === '0') q.set('chat', '0'); if (localStorage.getItem('homie-chat-bubble') === '0') q.set('bub', '0'); } catch (e) {}
394
407
  // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
395
408
  ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
396
409
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
@@ -401,7 +414,7 @@ const SHELL_JS = String.raw`(function () {
401
414
  var m = ev.data;
402
415
  if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
403
416
  if (m.what === 'attached') state.attached = true;
404
- if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} }
417
+ if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
405
418
  if (m.what === 'stats') state.stats = m.stats;
406
419
  if (m.what === 'round') onRound(m.round);
407
420
  if (m.what === 'roster') state.roster = m.slots;
@@ -412,7 +425,7 @@ const SHELL_JS = String.raw`(function () {
412
425
  });
413
426
  watch(room);
414
427
  shareReady(room);
415
- if (screenMode && !document.querySelector('[data-join]')) {
428
+ if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
416
429
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
417
430
  var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
418
431
  screenCard.append(h2, div); screenCard.hidden = false;
@@ -447,6 +460,8 @@ const SHELL_JS = String.raw`(function () {
447
460
  try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
448
461
  catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
449
462
  state.watchSocket = ws;
463
+ // Room chat (section 19) speaks on this socket: lines, reactions and what the room keeps of the last minutes.
464
+ if (window.__homieChat) window.__homieChat.socket(ws, room);
450
465
  ws.onmessage = function (ev) {
451
466
  var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
452
467
  if (!m || typeof m !== 'object') return;
@@ -455,7 +470,9 @@ const SHELL_JS = String.raw`(function () {
455
470
  if (m.t === 'announce') { banner(m); return; }
456
471
  if (m.t === 'muted') { mutedNote(m); return; }
457
472
  if (m.t === 'vote') { onVote(m); return; }
473
+ if (window.__homieChat && window.__homieChat.receive(m)) return;
458
474
  if (m.t !== 'net') return;
475
+ if (window.__homieChat) window.__homieChat.facts(m);
459
476
  state.facts = m;
460
477
  if (typeof m.st === 'number') state.offset = m.st - Date.now();
461
478
  if (m.announce && m.announce.text) banner(m.announce, true);
@@ -826,11 +843,11 @@ const OWNER_JS = String.raw`(function () {
826
843
  * watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
827
844
  * is the play door's). Every name a player typed is set with textContent.
828
845
  */
829
- export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow' } = {}) {
846
+ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
830
847
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
831
848
  const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
832
849
  const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
833
- const css = `:root{--hot:${hot}}${WATCH_CSS}`;
850
+ const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}`;
834
851
  return layoutless(`Watch ${g.name}`, `
835
852
  <header class="wtop" data-top>
836
853
  <span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
@@ -847,6 +864,8 @@ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow
847
864
  </footer>
848
865
  <div class="wnote" data-note hidden></div>
849
866
  <script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
867
+ <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
868
+ <script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
850
869
  <script>${WATCH_JS}</script>`, css, frameAncestors(cat));
851
870
  }
852
871
 
@@ -1047,12 +1066,16 @@ const WATCH_JS = String.raw`(function () {
1047
1066
  try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
1048
1067
  catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
1049
1068
  state.socket = ws;
1069
+ // Room chat (section 19): a watcher reads the room's chat and sends what the room's rules let a watcher send.
1070
+ if (window.__homieChat) window.__homieChat.socket(ws, room);
1050
1071
  ws.onmessage = function (ev) {
1051
1072
  var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
1052
1073
  if (!m || typeof m !== 'object') return;
1053
1074
  if (m.t === 'kicked') { shut('kicked', m); return; }
1054
1075
  if (m.t === 'closed') { shut('closed', m); return; }
1076
+ if (window.__homieChat && window.__homieChat.receive(m)) return;
1055
1077
  if (m.t !== 'net') return;
1078
+ if (window.__homieChat) window.__homieChat.facts(m);
1056
1079
  state.facts = m;
1057
1080
  if (typeof m.st === 'number') state.offset = m.st - Date.now();
1058
1081
  if (m.round) state.round = m.round;
package/worker/qr.mjs CHANGED
@@ -489,6 +489,17 @@ export function encodeQr(text, { minLevel = 'M' } = {}) {
489
489
 
490
490
 
491
491
  /** The symbol as an SVG string: one path, dark modules on a light square with the quiet zone. */
492
+ /**
493
+ * An address only this computer can open (local development: localhost, 127.x, ::1, 0.0.0.0). A page never puts one in
494
+ * a QR code or tells a phone to open it: a phone cannot reach it. It says "deploy to share" instead.
495
+ */
496
+ export function isLocalOrigin(origin) {
497
+ let host = '';
498
+ try { host = new URL(String(origin)).hostname; } catch { return false; }
499
+ host = host.replace(/^\[|\]$/g, '');
500
+ return host === 'localhost' || host.endsWith('.localhost') || /^127\./.test(host) || host === '::1' || host === '0.0.0.0';
501
+ }
502
+
492
503
  export function qrSvg(text, { minLevel = 'M', quiet = 4, dark = '#000000', light = '#ffffff', title = '' } = {}) {
493
504
  const q = encodeQr(text, { minLevel });
494
505
  const total = q.size + quiet * 2;