@homie-rocks/studio 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +59 -0
  3. package/bin/homie-studio.mjs +68 -2
  4. package/chat/CHAT.md +101 -0
  5. package/chat/OWNERS.md +78 -0
  6. package/lib/build.mjs +20 -2
  7. package/lib/chat-cli.mjs +89 -0
  8. package/lib/cloudflare.mjs +5 -2
  9. package/lib/scaffold.mjs +59 -2
  10. package/lib/shop.mjs +270 -0
  11. package/lib/template-history.json +77 -0
  12. package/netplay/NETPLAY.md +194 -4
  13. package/netplay/netplay.ts +156 -3
  14. package/package.json +7 -1
  15. package/port/index.ts +3 -2
  16. package/port/view.ts +238 -0
  17. package/shop/SELLING.md +83 -0
  18. package/shop/SHOP.md +154 -0
  19. package/shop/shop.ts +130 -0
  20. package/site/SITE.md +22 -1
  21. package/starters/ember-vale/src/main.ts +22 -2
  22. package/starters/gem-rush/src/main.ts +21 -1
  23. package/starters/gem-rush-3d/src/main.ts +22 -1
  24. package/worker/account-page.mjs +14 -2
  25. package/worker/agents.mjs +3 -3
  26. package/worker/chat-page.mjs +556 -0
  27. package/worker/chat-store.mjs +161 -0
  28. package/worker/chat-words.mjs +46 -0
  29. package/worker/chat.mjs +419 -0
  30. package/worker/index.mjs +189 -7
  31. package/worker/office-page.mjs +120 -5
  32. package/worker/office.mjs +173 -6
  33. package/worker/pages.mjs +25 -8
  34. package/worker/players.mjs +8 -0
  35. package/worker/referrals.mjs +328 -0
  36. package/worker/room.mjs +291 -11
  37. package/worker/servers.mjs +5 -1
  38. package/worker/shop-page.mjs +532 -0
  39. package/worker/shop-rules.mjs +277 -0
  40. package/worker/shop-store.mjs +217 -0
  41. package/worker/shop.mjs +686 -0
  42. package/worker/site.mjs +4 -1
  43. package/worker/stats.mjs +4 -1
  44. package/worker/stripe.mjs +149 -0
  45. package/worker/version.mjs +1 -1
package/worker/office.mjs CHANGED
@@ -42,6 +42,17 @@
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.
52
+ *
53
+ * THE SHOP (0.24.0, worker/shop.mjs): /_studio/office/shop and /_studio/api/shop (sales, refunds, disputes, payouts as
54
+ * links to the studio's own Stripe, a CSV, the referral books and signed statements). A refund is the owner's one tap;
55
+ * from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
45
56
  */
46
57
  import { SEAT_MAX, seatsOf } from './seats.mjs';
47
58
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
@@ -51,6 +62,10 @@ import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from '.
51
62
  import { esc, layout, notFoundPage } from './site.mjs';
52
63
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
53
64
  import { licenseOf } from './license.mjs';
65
+ import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
66
+ import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
67
+ import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
68
+ import { orderById } from './shop-store.mjs';
54
69
 
55
70
  export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
56
71
 
@@ -456,6 +471,8 @@ async function roomRow(env, meta, room, max) {
456
471
  host: f.host ?? null, announce: f.announce ?? null,
457
472
  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
473
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
474
+ // Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
475
+ 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
476
  // The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
460
477
  ...(f.brains && typeof f.brains === 'object' ? { brains: {
461
478
  brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
@@ -517,14 +534,23 @@ export async function officeView(env, cat, origin) {
517
534
  const row = settings.get(g.id);
518
535
  const max = seatsFor(g, settings);
519
536
  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);
537
+ .filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length || (r.chat && r.chat.lines.length));
521
538
  rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
522
539
  const servers = await serversOf(env, g, { fresh: true });
523
540
  const byServer = {};
524
541
  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
542
  const members = await memberCountsOf(env, g.id);
543
+ const chatRows = await chatRowsOf(env, { fresh: true });
526
544
  return {
527
545
  id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
546
+ // Room chat (0.23.0): the game's rules as Quick play has them, where each came from, and each server's own.
547
+ chat: {
548
+ rules: publicChat(chatOf(g, servers.find((x) => x.id === 'public') ?? null, chatRows)),
549
+ from: chatRows.get(`${g.id}/`) ? 'office' : g.chat !== undefined ? 'game.json' : 'default',
550
+ office: chatRows.get(`${g.id}/`) ?? null,
551
+ 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 })),
552
+ reports: await reportsOf(env, { game: g.id }),
553
+ },
528
554
  // 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
555
  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
556
  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 +571,7 @@ export async function officeView(env, cat, origin) {
545
571
  fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
546
572
  agentsTalk: await talkConsented(env),
547
573
  brain: await brainDay(env),
574
+ chat: await chatDay(env, today()),
548
575
  };
549
576
  }
550
577
  async function memberCountsOf(env, game) {
@@ -579,8 +606,10 @@ function checkAction(cat, op, body) {
579
606
  if (!room) return bad('room is the room\'s code (pub-3, or a named room)');
580
607
  const id = typeof body.id === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.id) ? body.id : null;
581
608
  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 } };
609
+ // From a chat line (0.23.0): its sender, whoever they are now (a watcher too); `purge` takes their lines down.
610
+ const line = typeof body.line === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.line) ? body.line : null;
611
+ if (id === null && seat === null && line === null) return bad('name the player: id (from the office), seat, or line (a chat message of theirs)');
612
+ 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
613
  }
585
614
  case 'close': {
586
615
  if (!meta) return bad('game is one of this studio\'s game ids');
@@ -650,6 +679,8 @@ function checkAction(cat, op, body) {
650
679
  if (!(level >= 1 && level <= 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
651
680
  return { ok: true, action: { op, game: meta.id, room, level } };
652
681
  }
682
+ case 'refund': return checkRefund(body);
683
+ case 'shop-settle': return checkSettle(body);
653
684
  case 'agents-brain': {
654
685
  if (!meta) return bad('game is one of this studio\'s game ids');
655
686
  if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
@@ -659,11 +690,56 @@ function checkAction(cat, op, body) {
659
690
  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
691
  return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
661
692
  }
693
+ case 'chat-rules': {
694
+ if (!meta) return bad('game is one of this studio\'s game ids');
695
+ const server = body.server === undefined || body.server === null || body.server === '' ? '' : String(body.server);
696
+ 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');
697
+ if (body.reset === true) return { ok: true, action: { op, game: meta.id, server, reset: true } };
698
+ const { game: _g, server: _s, ...rest } = body;
699
+ const c = checkChatRules(rest);
700
+ if (!c.ok) return c;
701
+ 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`);
702
+ return { ok: true, action: { op, game: meta.id, server, fields: c.fields } };
703
+ }
704
+ case 'chat-remove': {
705
+ if (!meta) return bad('game is one of this studio\'s game ids');
706
+ if (!room) return bad('room is the room\'s code');
707
+ 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);
708
+ if (!ids.length && body.all !== true) return bad('id is the message\'s id (from the office), or all: true');
709
+ return { ok: true, action: { op, game: meta.id, room, ...(body.all === true ? { all: true } : { ids }) } };
710
+ }
711
+ case 'chat-report': {
712
+ if (!/^cr_[a-f0-9]{16}$/.test(String(body.id ?? ''))) return bad('id is the report\'s id');
713
+ return { ok: true, action: { op, id: body.id } };
714
+ }
715
+ case 'chat-budget': {
716
+ const n = Math.floor(Number(body.neurons));
717
+ 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)');
718
+ return { ok: true, action: { op, neurons: n } };
719
+ }
662
720
  default:
663
721
  return bad('unknown control');
664
722
  }
665
723
  }
666
724
 
725
+ /** Whether a chat change opens chat up (an office key only ASKS for these): more allowed, fewer checks, wider doors. */
726
+ export function chatOpensUp(before, f) {
727
+ const rank = { off: 0, emoji: 1, lines: 2, text: 3 };
728
+ const who = { members: 0, 'signed-in': 1, anyone: 2 };
729
+ if (f.mode !== undefined && rank[f.mode] > rank[before.mode]) return true;
730
+ for (const k of ['who', 'react']) if (f[k] !== undefined && who[f[k]] > who[before[k]]) return true;
731
+ if (f.slow !== undefined && f.slow < before.slow) return true;
732
+ if (f.max !== undefined && f.max > before.max) return true;
733
+ if (f.links === 'allow' && before.links !== 'allow') return true;
734
+ if (f.swears === 'allow' && before.swears !== 'allow') return true;
735
+ for (const k of ['watchers', 'hub']) if (f[k] === true && !before[k]) return true;
736
+ if (f.ai === false && before.ai) return true;
737
+ if (f.allow !== undefined && f.allow.some((w) => !(before.allow ?? []).includes(w))) return true;
738
+ if (f.block !== undefined && (before.block ?? []).some((w) => !f.block.includes(w))) return true;
739
+ if (f.lines !== undefined || f.emoji !== undefined) return true;
740
+ return false;
741
+ }
742
+
667
743
  /** The action in plain words, for the owner's confirm page and the AI's card. */
668
744
  export function describe(cat, a) {
669
745
  const g = (cat.games ?? []).find((x) => x.id === a.game);
@@ -702,9 +778,31 @@ export function describe(cat, a) {
702
778
  case 'member': return a.remove ? `Remove ${a.name ?? 'that player'} from ${gname}'s server ${a.server} (they can join again unless its door keeps them out).` : `Make ${a.name ?? 'that player'} a ${a.role} of ${gname}'s server ${a.server}.`;
703
779
  case 'pass': return a.action === 'revoke' ? `Revoke the agent pass ${a.id}: that AI leaves every room and cannot sit again.` : `Issue an agent pass for an AI called "${a.label} · AI".`;
704
780
  case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
781
+ case 'refund': return describeRefund(cat, a, a.item ? { item: a.item, amount: a.amount, currency: a.currency } : null);
782
+ case 'shop-settle': return `Mark what this studio owes ${a.via} for referrals as paid${a.ref ? ` (${a.ref})` : ''}: its owed lines are settled in the books.`;
705
783
  case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
706
784
  ? `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
785
  : `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
786
+ case 'chat-rules': {
787
+ const where = a.server ? `${gname}'s server ${a.server}` : gname;
788
+ if (a.reset) return `Give ${where} its own chat rules back (the game's game.json, or Homie's defaults).`;
789
+ const f = a.fields;
790
+ const bits = [];
791
+ 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]);
792
+ if (f.who) bits.push(`typing for ${f.who === 'anyone' ? 'anyone, guests too' : f.who === 'signed-in' ? 'signed-in players (a passkey)' : 'members only'}`);
793
+ if (f.react) bits.push(`emoji and quick lines for ${f.react === 'anyone' ? 'anyone' : f.react === 'signed-in' ? 'signed-in players' : 'members only'}`);
794
+ if (f.slow !== undefined) bits.push(f.slow ? `slow mode: one message every ${f.slow} s` : 'no slow mode');
795
+ if (f.max !== undefined) bits.push(`messages up to ${f.max} characters`);
796
+ if (f.links) bits.push(f.links === 'allow' ? 'allow links' : 'hold links');
797
+ if (f.swears) bits.push(f.swears === 'allow' ? 'allow swearing' : 'hold swearing');
798
+ 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)');
799
+ const rest = Object.keys(f).filter((k) => !['mode', 'who', 'react', 'slow', 'max', 'links', 'swears', 'ai'].includes(k));
800
+ if (rest.length) bits.push(`change its ${rest.join(', ')}`);
801
+ return `Room chat in ${where}: ${bits.join('; ')}.`;
802
+ }
803
+ 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}.`;
804
+ case 'chat-report': return 'Dismiss a chat report (it is deleted).';
805
+ 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
806
  default: return 'A control.';
709
807
  }
710
808
  }
@@ -712,6 +810,8 @@ export function describe(cat, a) {
712
810
  /** Whether an office key (the owner's AI) only ASKS for this (DESIGN D13): what takes something away, or consent. */
713
811
  export async function needsAsk(env, cat, a) {
714
812
  if (DESTRUCTIVE.has(a.op)) return true;
813
+ // Money: the owner's AI may only propose a refund or a settlement; the owner says yes.
814
+ if (a.op === 'refund' || a.op === 'shop-settle') return true;
715
815
  if (a.op === 'server-close') return !a.reopen;
716
816
  if (a.op === 'member') return a.remove === true;
717
817
  if (a.op === 'server-set') {
@@ -719,6 +819,15 @@ export async function needsAsk(env, cat, a) {
719
819
  const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
720
820
  return narrows(before, a.fields);
721
821
  }
822
+ // A bigger review budget can cost money on Workers Paid: the owner says yes. A smaller one happens at once.
823
+ if (a.op === 'chat-budget') return a.neurons > (await chatDay(env, today())).budget.neurons;
824
+ if (a.op === 'chat-rules') {
825
+ if (a.reset) return true;
826
+ const meta = (cat.games ?? []).find((g) => g.id === a.game);
827
+ const servers = meta ? await serversOf(env, meta, { fresh: true }) : [];
828
+ const before = chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? null, await chatRowsOf(env, { fresh: true }));
829
+ return chatOpensUp(before, a.fields);
830
+ }
722
831
  if (a.op === 'agents-brain') {
723
832
  if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
724
833
  // 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 +844,7 @@ async function serverRooms(env, game, server) {
735
844
 
736
845
  /** A server's policy reaches every live room of it now (a signed `policy` control; section 17). */
737
846
  async function pushPolicy(env, meta, settings, server) {
738
- const pol = policyOf(server, { seats: seatsFor(meta, settings) });
847
+ const pol = policyOf(server, { seats: seatsFor(meta, settings), chat: chatOf(meta, server, await chatRowsOf(env, { fresh: true })) });
739
848
  const rooms = await serverRooms(env, meta.id, server.id);
740
849
  const res = await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'policy', { pol })));
741
850
  return { rooms: rooms.length, leaving: res.reduce((n, r) => n + (r.leaving ?? 0), 0) };
@@ -748,9 +857,10 @@ export async function perform(env, cat, a) {
748
857
  switch (a.op) {
749
858
  case 'kick':
750
859
  case 'mute': {
860
+ const line = a.line ? { line: a.line, purge: a.purge === true } : {};
751
861
  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 });
862
+ ? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...line, ...(a.message ? { message: a.message } : {}) }
863
+ : { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off, ...line });
754
864
  return r;
755
865
  }
756
866
  case 'close': return roomControl(env, meta, settings, a.room, 'close', a.reopen ? { reopen: true } : { minutes: a.minutes, ...(a.message ? { message: a.message } : {}) });
@@ -868,6 +978,32 @@ export async function perform(env, cat, a) {
868
978
  } 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
979
  }
870
980
  case 'room-level': return roomControl(env, meta, settings, a.room, 'level', { level: a.level });
981
+ case 'chat-rules': {
982
+ if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for its office.' };
983
+ try {
984
+ if (a.reset) await clearChatRules(env, meta.id, a.server);
985
+ else await writeChatRules(env, meta.id, a.server, a.fields);
986
+ } 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)})` }; }
987
+ // Every live room the change reaches hears its new rules now (a signed policy control, as a server change does).
988
+ const servers = await serversOf(env, meta, { fresh: true });
989
+ const reach = a.server ? servers.filter((x) => x.id === a.server) : servers;
990
+ let rooms = 0;
991
+ for (const sv of reach) rooms += (await pushPolicy(env, meta, settings, sv)).rooms;
992
+ const rows = await chatRowsOf(env, { fresh: true });
993
+ const rules = publicChat(chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? PUBLIC_SERVER, rows));
994
+ return { ok: true, op: a.op, game: meta.id, server: a.server || null, rules, rooms };
995
+ }
996
+ case 'chat-remove': {
997
+ const r = await roomControl(env, meta, settings, a.room, 'unsay', a.all ? { all: true } : { ids: a.ids });
998
+ return r;
999
+ }
1000
+ case 'chat-report': {
1001
+ 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) }; }
1002
+ }
1003
+ case 'chat-budget': {
1004
+ 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) }; }
1005
+ 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).` };
1006
+ }
871
1007
  case 'agents-brain': {
872
1008
  const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server);
873
1009
  if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
@@ -896,6 +1032,8 @@ export async function perform(env, cat, a) {
896
1032
  if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
897
1033
  return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
898
1034
  }
1035
+ case 'refund': return performRefund(env, cat, a);
1036
+ case 'shop-settle': return settleReferrer(env, a);
899
1037
  default: return { ok: false, error: 'op' };
900
1038
  }
901
1039
  }
@@ -1025,10 +1163,27 @@ async function api(request, env, url, cat) {
1025
1163
  return json({ ok: true, game, server, members });
1026
1164
  } 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
1165
  }
1166
+ // Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
1167
+ if (path === '/_studio/api/chat' && request.method === 'GET') {
1168
+ const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
1169
+ const view = await officeView(env, { ...cat, games: metas }, url.origin);
1170
+ 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 })) })) });
1171
+ }
1172
+ // The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
1173
+ if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin));
1174
+ if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
1175
+ try {
1176
+ return new Response(await ordersCsv(env), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store, private', 'content-disposition': `attachment; filename="orders-${today()}.csv"` } });
1177
+ } catch { return json({ ok: false, error: 'not-migrated', message: 'The shop needs migration 0008_studio_shop.sql (npm run deploy).' }, 503); }
1178
+ }
1179
+ if (path === '/_studio/api/shop/statements' && request.method === 'GET') return json(await statementsOut(env, cat, url.origin, url.searchParams.get('period')));
1180
+ if (path === '/_studio/api/shop/statements/send' && request.method === 'POST') return json(await statementsSend(env, cat, url.origin, body.period));
1028
1181
  const OPS = {
1182
+ '/_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
1183
  '/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
1030
1184
  '/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
1031
1185
  '/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
1186
+ '/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
1032
1187
  };
1033
1188
  const op = OPS[path];
1034
1189
  if (!op || request.method !== 'POST') return json({ ok: false, error: 'not-found' }, 404);
@@ -1036,6 +1191,12 @@ async function api(request, env, url, cat) {
1036
1191
  if (!checked.ok) return json(checked, 400);
1037
1192
  const action = checked.action;
1038
1193
  if (op === 'server-create' || op === 'pass') action.origin = url.origin;
1194
+ if (op === 'refund') {
1195
+ // The ask and the confirm page say what the order is (its item and price), read from the books now.
1196
+ const o = await orderById(env, action.order);
1197
+ if (!o) return json({ ok: false, error: 'order', message: 'No such order (homie-studio shop orders lists them).' }, 404);
1198
+ Object.assign(action, { item: o.item, amount: Number(o.amount), currency: o.currency });
1199
+ }
1039
1200
  // An office key (the owner's AI) only ASKS for what takes something away; the owner confirms with one tap.
1040
1201
  if (who === 'office' && await needsAsk(env, cat, action)) {
1041
1202
  if (op === 'kick' || op === 'mute') {
@@ -1068,6 +1229,12 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
1068
1229
  const secure = url.protocol === 'https:' ? '; Secure' : '';
1069
1230
  return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
1070
1231
  }
1232
+ if (path === '/_studio/office/shop' || path === '/_studio/office/shop/') {
1233
+ const cat = await catalogueOf();
1234
+ if (request.method !== 'GET') return new Response('method', { status: 405 });
1235
+ if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
1236
+ return officeShopPage(cat);
1237
+ }
1071
1238
  const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
1072
1239
  if (confirm) {
1073
1240
  const cat = await catalogueOf();
package/worker/pages.mjs CHANGED
@@ -7,6 +7,8 @@ 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';
11
+ import { SHOP_SHELL_CSS, SHOP_SHELL_JS } from './shop-page.mjs';
10
12
 
11
13
  export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
12
14
 
@@ -84,7 +86,7 @@ export function sharePlaces(value) {
84
86
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
85
87
  * covers the middle of the screen or a thumb.
86
88
  */
87
- export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
89
+ 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, shop = null } = {}) {
88
90
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
89
91
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
90
92
  const places = sharePlaces(g.screen?.share);
@@ -159,14 +161,14 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
159
161
  .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); }
160
162
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
161
163
  [hidden] { display: none !important; }
162
- ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
164
+ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
163
165
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
164
166
  // A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
165
167
  // The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
166
168
  const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
167
169
  // game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
168
170
  const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
169
- const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE };
171
+ const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
170
172
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
171
173
  const joinCorner = corner('join', 'bottom-right');
172
174
  const first = places.desk;
@@ -189,7 +191,7 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
189
191
  <a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
190
192
  </div>
191
193
  <span class="link" data-room-link></span>
192
- <div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a><button type="button" data-copy-link>Copy link</button></div>${g.saves ? `
194
+ <div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a>${shop ? '<button type="button" data-shop-open>Shop</button>' : ''}<button type="button" data-copy-link>Copy link</button></div>${g.saves ? `
193
195
  <div class="who-row" data-who hidden><span data-who-name></span><button type="button" data-who-act></button></div>` : ''}
194
196
  </div>
195
197
  </div>
@@ -202,8 +204,11 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
202
204
  <div class="card" data-screen hidden></div>
203
205
  ${joinCard}${share}
204
206
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
207
+ <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
208
+ <script>${CHAT_JS}</script>
205
209
  <script>${SHELL_JS}</script>${g.saves && !screen ? `
206
- <script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
210
+ <script>${SAVES_SHELL_JS}</script>` : ''}${shop ? `
211
+ <script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
207
212
  }
208
213
 
209
214
  /*
@@ -399,6 +404,8 @@ const SHELL_JS = String.raw`(function () {
399
404
  if (params.get('debug') === '1') q.set('debug', '1');
400
405
  // Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
401
406
  if (state.quietAi) q.set('hush', '1');
407
+ // Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
408
+ 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) {}
402
409
  // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
403
410
  ['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); });
404
411
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
@@ -409,7 +416,7 @@ const SHELL_JS = String.raw`(function () {
409
416
  var m = ev.data;
410
417
  if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
411
418
  if (m.what === 'attached') state.attached = true;
412
- if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} }
419
+ 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(); }
413
420
  if (m.what === 'stats') state.stats = m.stats;
414
421
  if (m.what === 'round') onRound(m.round);
415
422
  if (m.what === 'roster') state.roster = m.slots;
@@ -455,6 +462,8 @@ const SHELL_JS = String.raw`(function () {
455
462
  try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
456
463
  catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
457
464
  state.watchSocket = ws;
465
+ // Room chat (section 19) speaks on this socket: lines, reactions and what the room keeps of the last minutes.
466
+ if (window.__homieChat) window.__homieChat.socket(ws, room);
458
467
  ws.onmessage = function (ev) {
459
468
  var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
460
469
  if (!m || typeof m !== 'object') return;
@@ -463,7 +472,9 @@ const SHELL_JS = String.raw`(function () {
463
472
  if (m.t === 'announce') { banner(m); return; }
464
473
  if (m.t === 'muted') { mutedNote(m); return; }
465
474
  if (m.t === 'vote') { onVote(m); return; }
475
+ if (window.__homieChat && window.__homieChat.receive(m)) return;
466
476
  if (m.t !== 'net') return;
477
+ if (window.__homieChat) window.__homieChat.facts(m);
467
478
  state.facts = m;
468
479
  if (typeof m.st === 'number') state.offset = m.st - Date.now();
469
480
  if (m.announce && m.announce.text) banner(m.announce, true);
@@ -834,11 +845,11 @@ const OWNER_JS = String.raw`(function () {
834
845
  * watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
835
846
  * is the play door's). Every name a player typed is set with textContent.
836
847
  */
837
- export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow' } = {}) {
848
+ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
838
849
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
839
850
  const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
840
851
  const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
841
- const css = `:root{--hot:${hot}}${WATCH_CSS}`;
852
+ const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}`;
842
853
  return layoutless(`Watch ${g.name}`, `
843
854
  <header class="wtop" data-top>
844
855
  <span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
@@ -855,6 +866,8 @@ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow
855
866
  </footer>
856
867
  <div class="wnote" data-note hidden></div>
857
868
  <script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
869
+ <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
870
+ <script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
858
871
  <script>${WATCH_JS}</script>`, css, frameAncestors(cat));
859
872
  }
860
873
 
@@ -1055,12 +1068,16 @@ const WATCH_JS = String.raw`(function () {
1055
1068
  try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
1056
1069
  catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
1057
1070
  state.socket = ws;
1071
+ // Room chat (section 19): a watcher reads the room's chat and sends what the room's rules let a watcher send.
1072
+ if (window.__homieChat) window.__homieChat.socket(ws, room);
1058
1073
  ws.onmessage = function (ev) {
1059
1074
  var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
1060
1075
  if (!m || typeof m !== 'object') return;
1061
1076
  if (m.t === 'kicked') { shut('kicked', m); return; }
1062
1077
  if (m.t === 'closed') { shut('closed', m); return; }
1078
+ if (window.__homieChat && window.__homieChat.receive(m)) return;
1063
1079
  if (m.t !== 'net') return;
1080
+ if (window.__homieChat) window.__homieChat.facts(m);
1064
1081
  state.facts = m;
1065
1082
  if (typeof m.st === 'number') state.offset = m.st - Date.now();
1066
1083
  if (m.round) state.round = m.round;
@@ -30,6 +30,7 @@ import { cookieValue } from './stats.mjs';
30
30
  import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
31
31
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
32
32
  import { ACCOUNT_JS, accountPage } from './account-page.mjs';
33
+ import { forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
33
34
 
34
35
  export const PLAYERS_MIGRATION_FILE = '0004_players.sql';
35
36
  export const PLAYERS_MIGRATION = `-- Players and cloud saves (@homie-rocks/studio 0.12.0): accounts on this studio only (saves/SAVES.md).
@@ -323,6 +324,8 @@ async function removePlayer(env, id) {
323
324
  ]);
324
325
  // Their servers' memberships too (0.16.0); a studio before migration 0006 has none to delete.
325
326
  try { await env.DB.prepare('DELETE FROM server_members WHERE player = ?1').bind(id).run(); } catch { /* not migrated */ }
327
+ // What they bought goes with them; the orders stay, without the player (0.24.0; none before migration 0008).
328
+ try { await env.DB.batch(forgetPlayerShop(env, id)); } catch { /* not migrated */ }
326
329
  await data.after();
327
330
  }
328
331
 
@@ -508,6 +511,8 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
508
511
  player: me,
509
512
  passkeys: keys.map((k) => ({ label: k.label, synced: Number(k.synced) === 1, createdAt: new Date(Number(k.created_at)).toISOString(), lastUsedAt: new Date(Number(k.used_at)).toISOString() })),
510
513
  games: await playerData(env, s.player.id),
514
+ // The shop (0.24.0): their orders, what they own and the age band they gave; never a card or an email.
515
+ ...(await shopDataOf(env, s.player.id).then((x) => (x ? { shop: x } : {}))),
511
516
  note: 'Everything this studio keeps about this player. A passkey\'s private key never left your device; this site keeps only its public key.',
512
517
  };
513
518
  return json(body, 200, { 'content-disposition': `attachment; filename="${s.player.id}.json"` });
@@ -770,6 +775,9 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
770
775
 
771
776
  if (head === 'delete' && parts.length === 1) {
772
777
  if (body.confirm !== 'delete') return fail(400, 'confirm', 'send { "confirm": "delete" } to delete this player and everything they kept');
778
+ // Things they bought go with the account: say so first (the shop, 0.24.0).
779
+ const owned = await livePurchases(env, s.player.id).catch(() => 0);
780
+ if (owned && body.purchases !== 'forfeit') return fail(409, 'purchases', `This account owns ${owned} thing${owned === 1 ? '' : 's'} bought here; deleting it gives ${owned === 1 ? 'it' : 'them'} up for good (an unused item can be refunded first, from this page). Send { "confirm": "delete", "purchases": "forfeit" } to delete anyway.`, { owned });
773
781
  await removePlayer(env, s.player.id);
774
782
  return withCookies(json({ ok: true, deleted: s.player.id }), [clearCookie(url)]);
775
783
  }