@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
@@ -43,7 +43,7 @@
43
43
  * The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
44
44
  * every lab call is a no-op.
45
45
  */
46
- import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
46
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
47
47
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
48
48
  import vocabulary from '../agents.json';
49
49
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
@@ -712,8 +712,14 @@ const INSET = { top: 124, bottom: 100 }; // the hero panel and its pills; STRIKE
712
712
  let cam: Fit | null = null;
713
713
  let lastDraw = 0;
714
714
  // Names keep clear of the guides' bubbles and of the ask panel (a page element over the canvas).
715
- const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); return p && !p.hidden ? [...bubbleBoxes, p.getBoundingClientRect()] : bubbleBoxes; } });
715
+ const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); const all = [...bubbleBoxes, ...chatBubbles.boxes()]; return p && !p.hidden ? [...all, p.getBoundingClientRect()] : all; } });
716
716
  let bubbleBoxes: { left: number; top: number; right: number; bottom: number }[] = [];
717
+ // Room chat (NETPLAY.md section 19): what a hero's player says in the room's chat shows over the hero for a few seconds,
718
+ // clear of the guides' own bubbles. The studio taking a message down takes its bubble too.
719
+ const chatBubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: vw, h: vh }), avoid: () => bubbleBoxes });
720
+ net.on('say', (e) => chatBubbles.say(e.seat, e.text, { id: e.id, kind: e.kind }));
721
+ net.on('unchat', (e) => { for (const id of e.ids) chatBubbles.remove(id); });
722
+ let shownChat: BubbleOut[] = [];
717
723
  let shownLabels: LabelOut[] = [];
718
724
  function aim(): Fit {
719
725
  const v = lookOnly ? room.viewBody() : null;
@@ -772,6 +778,7 @@ function draw(t: number): void {
772
778
  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
773
779
  const names: LabelIn[] = [];
774
780
  const said: { x: number; y: number; head: number; text: string }[] = [];
781
+ const heads = new Map<number, { x: number; y: number; seat: number; self: boolean }>();
775
782
  const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
776
783
  const meAt = (() => { const v = room.view().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
777
784
  for (const b of room.view()) {
@@ -801,6 +808,7 @@ function draw(t: number): void {
801
808
  const body = { left: sx(x - 24), top: sy(y - R - 12), right: sx(x + 24), bottom: sy(y + R) };
802
809
  if (body.right < 0 || body.left > vw || body.bottom < 0 || body.top > vh) continue; // off screen: no name at the edge
803
810
  if (self) { const w = ctx.measureText(text).width; mine.push(body, { left: sx(x) - w / 2 - 6, top: body.top - 4 - fs * 1.2, right: sx(x) + w / 2 + 6, bottom: body.top }); }
811
+ if (!b.bot && b.seat !== null && b.seat !== undefined) heads.set(b.slot, { x: sx(x), y: body.top - 3, seat: b.seat, self });
804
812
  names.push({ key: b.slot, text, x: sx(x), y: body.top - 3, w: ctx.measureText(text).width, h: fs * 1.2, below: body.bottom + 4 + fs * 1.2, body, self, rank: (b.bot ? (isGuide ? 1 : 2) : 0) * 10_000 + near });
805
813
  const line = bubbles.get(b.slot);
806
814
  // The bubble's tail stops just above the guide's own name, so the name keeps its spot while it speaks.
@@ -830,6 +838,16 @@ function draw(t: number): void {
830
838
  ctx.fillText(l.text, l.cx, l.cy);
831
839
  }
832
840
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
841
+ // Room chat's bubbles: over each speaking hero's name (your own placed first), clear of the guides' bubbles.
842
+ if (chatBubbles.size) {
843
+ const anchors: BubbleIn[] = [];
844
+ for (const [slot, h] of heads) {
845
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
846
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.self });
847
+ }
848
+ shownChat = chatBubbles.place(anchors);
849
+ paintBubbles(ctx, shownChat, { paper: '#f6efdc', ink: '#1a1710', edge: 'rgba(40,30,10,.35)' });
850
+ } else shownChat = [];
833
851
  ctx.setTransform(1, 0, 0, 1, 0, 0);
834
852
  hud(t);
835
853
  }
@@ -1116,6 +1134,8 @@ function frame(t: number): void {
1116
1134
 
1117
1135
  // Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
1118
1136
  net.expose({
1137
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1138
+ chatBubbles: () => shownChat.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
1119
1139
  scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
1120
1140
  // The guides, for the e2e probe: each guide's goal now, and the log of goals, asks and lines (seats and ids only).
1121
1141
  guides: () => ({
@@ -39,7 +39,7 @@
39
39
  import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
40
40
  // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
41
41
  // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
42
- import { createLabels, exposePort, fitView, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
42
+ import { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
43
43
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
44
44
  import { lab } from '@homie-rocks/studio/lab';
45
45
  import tuning from '../tunables.json';
@@ -768,6 +768,12 @@ resize();
768
768
 
769
769
  const cam = { x: W / 2, y: H / 2, scale: 0 };
770
770
  const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });
771
+ // Room chat (NETPLAY.md section 19): what a player says in the room's chat (the play page's Chat, or a quick line) shows
772
+ // over their body for a few seconds, when they want it there. The studio taking a message down takes its bubble too.
773
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }) });
774
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
775
+ net.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });
776
+ let shownBubbles: BubbleOut[] = [];
771
777
  let shownLabels: LabelOut[] = [];
772
778
  let lastDraw = 0;
773
779
  /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
@@ -868,6 +874,7 @@ function draw(t: number): void {
868
874
  const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
869
875
  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
870
876
  const tags: LabelIn[] = [];
877
+ const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
871
878
  const viewAt = list.find((a) => a.mine) ?? null;
872
879
  for (const a of list) {
873
880
  const colour = colourOf(a.slot, a.bot ? null : a.seat);
@@ -884,6 +891,7 @@ function draw(t: number): void {
884
891
  const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
885
892
  const sx = cw / 2 + (a.x - cam.x) * scale; const sy = ch / 2 + (a.y - cam.y) * scale; const r = (R_AV + (a.mine ? 7 : 0)) * scale;
886
893
  if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge
894
+ if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 5, seat: a.seat, mine: a.mine });
887
895
  // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
888
896
  tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });
889
897
  }
@@ -909,6 +917,16 @@ function draw(t: number): void {
909
917
  ctx.fillText(l.text, l.cx, l.cy);
910
918
  }
911
919
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
920
+ // Speech bubbles (room chat): over each speaker's name, the view's own player first.
921
+ if (bubbles.size) {
922
+ const anchors: BubbleIn[] = [];
923
+ for (const [slot, h] of heads) {
924
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
925
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.mine });
926
+ }
927
+ shownBubbles = bubbles.place(anchors);
928
+ paintBubbles(ctx, shownBubbles);
929
+ } else shownBubbles = [];
912
930
 
913
931
  hud(cw, ch, phone, list);
914
932
  if (stick.active) {
@@ -985,6 +1003,8 @@ function frame(t: number): void {
985
1003
  }
986
1004
 
987
1005
  net.expose({
1006
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1007
+ bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
988
1008
  self: () => (me.has ? { x: me.x, y: me.y } : null),
989
1009
  peer: (seat: number) => {
990
1010
  if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
@@ -45,7 +45,7 @@ import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies
45
45
  import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
46
46
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
47
47
  // labels that never pile up (port/view.ts).
48
- import { createLabels, exposePort, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
48
+ import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
49
49
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
50
50
  import { lab } from '@homie-rocks/studio/lab';
51
51
  import tuning from '../tunables.json';
@@ -1659,6 +1659,13 @@ resize();
1659
1659
  let zoneMark: { left: number; top: number; right: number; bottom: number } | null = null;
1660
1660
  const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }), avoid: () => (zoneMark ? [zoneMark] : []) });
1661
1661
  let shownLabels: LabelOut[] = [];
1662
+ // Room chat (NETPLAY.md section 19): what a player says in the room's chat shows over their animal for a few seconds,
1663
+ // on the HUD's own paper (one UI with the names, the clock and the board). The studio taking a message down takes it too.
1664
+ const BUBBLE_TYPE = `600 14px ${FONT_BODY}`;
1665
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_TYPE; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }), avoid: () => (zoneMark ? [zoneMark] : []) });
1666
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
1667
+ net.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });
1668
+ let shownBubbles: BubbleOut[] = [];
1662
1669
  let lastDraw = 0;
1663
1670
  /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
1664
1671
  function seatPos(seat: number): { x: number; y: number } | null {
@@ -1823,6 +1830,7 @@ function draw(t: number, dt: number): void {
1823
1830
  const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
1824
1831
  ctx.font = `700 ${fs}px ${FONT_BODY}`;
1825
1832
  const tags: LabelIn[] = [];
1833
+ const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
1826
1834
  const viewAt = list.find((a) => a.mine) ?? null;
1827
1835
  for (const a of list) {
1828
1836
  const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
@@ -1833,6 +1841,7 @@ function draw(t: number, dt: number): void {
1833
1841
  const r = Math.max(6, Math.hypot(side.x - mid.x, side.y - mid.y));
1834
1842
  if (mid.x + r < 0 || mid.x - r > cw || head.y > ch || mid.y + r < 0) continue; // off screen: no name at the edge
1835
1843
  const foot = onScreen(a.x, a.y, 0, cw, ch);
1844
+ if (a.seat !== null && !a.bot) heads.set(a.slot, { x: head.x, y: head.y - 3, seat: a.seat, mine: a.mine });
1836
1845
  // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
1837
1846
  tags.push({ key: a.slot, text, x: head.x, y: head.y - 3, w: ctx.measureText(text).width, h: fs * 1.2, below: foot.y + r * 0.6 + 4 + fs * 1.2, body: { left: mid.x - r, top: head.y, right: mid.x + r, bottom: foot.y + r * 0.4 }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) * 50 : 0) });
1838
1847
  }
@@ -1855,6 +1864,16 @@ function draw(t: number, dt: number): void {
1855
1864
  ctx.fillText(l.text, l.cx, l.cy);
1856
1865
  }
1857
1866
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
1867
+ // Speech bubbles (room chat): over each speaker's name pill, the view's own player first.
1868
+ if (bubbles.size) {
1869
+ const anchors: BubbleIn[] = [];
1870
+ for (const [slot, h] of heads) {
1871
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
1872
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 3 : h.y, self: h.mine });
1873
+ }
1874
+ shownBubbles = bubbles.place(anchors);
1875
+ paintBubbles(ctx, shownBubbles, { font: BUBBLE_TYPE, paper: PAPER, ink: TEXT, edge: mixHex(TEXT, PAPER, 0.7) });
1876
+ } else shownBubbles = [];
1858
1877
 
1859
1878
  hud(cw, ch, phone, list);
1860
1879
  if (stick.active) {
@@ -2028,6 +2047,8 @@ function frame(t: number): void {
2028
2047
  }
2029
2048
 
2030
2049
  net.expose({
2050
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
2051
+ bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
2031
2052
  self: () => (me.has ? { x: me.x, y: me.y } : null),
2032
2053
  peer: (seat: number) => {
2033
2054
  if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
@@ -160,6 +160,10 @@ export function accountPage(cat, { origin = '', next = '/account/', owner = null
160
160
  <label for="acct-email">Email</label><input id="acct-email" data-email type="email" autocomplete="email">
161
161
  <div class="row"><button class="ghost" type="button" data-email-save>Send a confirmation</button><button class="ghost" type="button" data-email-remove hidden>Remove it</button></div>
162
162
  </div>` : ''}
163
+ <div class="card shop-card" data-shop-card hidden>
164
+ <h2>Your purchases</h2>
165
+ <div data-shop-body></div>
166
+ </div>
163
167
  <div class="card" data-servers-card hidden>
164
168
  <h2>Your servers</h2>
165
169
  <p>The servers you belong to in this studio's games. Your home server is where Play takes you.</p>
@@ -193,8 +197,9 @@ ${back}
193
197
  </details>
194
198
  </section>
195
199
  <script type="application/json" id="account-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
196
- <script src="/_homie/account.js" defer></script>`;
197
- return layout(cat, { title: `Your account · ${studio}`, description: `Your player account on ${studio}.`, origin, path: '/account/', page: 'account', head: `<style>${ACCOUNT_CSS}</style><meta name="robots" content="noindex">`, main });
200
+ <script src="/_homie/account.js" defer></script>
201
+ <script src="/_homie/shop.js" defer></script>`;
202
+ return layout(cat, { title: `Your account · ${studio}`, description: `Your player account on ${studio}.`, origin, path: '/account/', page: 'account', head: `<style>${ACCOUNT_CSS}.shop-card .badge{display:inline-block;padding:3px 10px;border-radius:999px;background:var(--hot,#ffcf5a);color:var(--hot-ink,#0b0b10);font:800 12px/1.3 ui-sans-serif,system-ui,sans-serif;margin-right:6px}.shop-card ul{margin:8px 0 0;padding-left:18px}.shop-card li{margin:4px 0}</style><meta name="robots" content="noindex">`, main });
198
203
  }
199
204
 
200
205
  /** /_homie/account.js */
@@ -343,6 +348,13 @@ export const ACCOUNT_JS = `${PASSKEY_JS}
343
348
  var typed = prompt('This deletes your account and everything it kept on ' + boot.studio + ', in every game. It cannot be undone. Type DELETE to go ahead.');
344
349
  if (!typed || typed.trim().toUpperCase() !== 'DELETE') { say('Nothing was deleted.'); return; }
345
350
  P.api('POST', '/api/player/delete', { confirm: 'delete' }).then(function (r) {
351
+ if (!r.ok && r.error === 'purchases') {
352
+ var again = prompt(r.message + ' Type DELETE again to give them up and delete the account.');
353
+ if (!again || again.trim().toUpperCase() !== 'DELETE') return { ok: false, message: 'Nothing was deleted.' };
354
+ return P.api('POST', '/api/player/delete', { confirm: 'delete', purchases: 'forfeit' });
355
+ }
356
+ return r;
357
+ }).then(function (r) {
346
358
  if (!r.ok) throw new Error(r.message);
347
359
  me = null; P.remember(null); render();
348
360
  say('Your account and everything it kept are deleted. You can also remove the passkey from your device\\'s passwords.', 'good');
package/worker/agents.mjs CHANGED
@@ -74,10 +74,10 @@ export function encodeFacts(value) {
74
74
  for (const b of bytes) s += String.fromCharCode(b);
75
75
  return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
76
76
  }
77
- /** The reverse, or null for anything that is not one (at most 4 KB). */
78
- export function decodeFacts(text) {
77
+ /** The reverse, or null for anything that is not one (at most 4 KB, or `max`: a room's chat rules may be longer). */
78
+ export function decodeFacts(text, max = 4096) {
79
79
  const t = String(text ?? '');
80
- if (!t || t.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(t)) return null;
80
+ if (!t || t.length > max || !/^[A-Za-z0-9_-]+$/.test(t)) return null;
81
81
  try {
82
82
  const s = atob(t.replace(/-/g, '+').replace(/_/g, '/'));
83
83
  const bytes = Uint8Array.from(s, (c) => c.charCodeAt(0));