@homie-rocks/studio 0.49.0 → 0.50.1

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 +43 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +71 -1
  4. package/guides/app/SKILL.md +130 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/office/SKILL.md +99 -0
  21. package/guides/plan/SKILL.md +126 -0
  22. package/guides/plan/references/CODEX.md +105 -0
  23. package/guides/plan/references/INTERVIEW.md +86 -0
  24. package/guides/playtest/SKILL.md +205 -0
  25. package/guides/playtest/references/METHOD.md +124 -0
  26. package/guides/playtest/references/REVIEWER.md +78 -0
  27. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  28. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  29. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  30. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  31. package/guides/playtest/scripts/playtest.mjs +870 -0
  32. package/guides/servers/SKILL.md +111 -0
  33. package/guides/shop/SKILL.md +4 -0
  34. package/guides/sound/SKILL.md +156 -0
  35. package/guides/sound/references/GAME-SOUND.md +100 -0
  36. package/guides/sound/references/SCORE.md +131 -0
  37. package/guides/sound/references/TRAPS.md +93 -0
  38. package/guides/sound/scripts/lib/measure.mjs +252 -0
  39. package/guides/sound/scripts/lib/score.mjs +415 -0
  40. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  41. package/guides/sound/scripts/lib/synth.mjs +509 -0
  42. package/guides/sound/scripts/sound.mjs +520 -0
  43. package/guides/style/SKILL.md +105 -0
  44. package/lib/app-needs.mjs +63 -0
  45. package/lib/check.mjs +10 -4
  46. package/lib/codex.mjs +95 -18
  47. package/lib/decisions.mjs +9 -7
  48. package/lib/functions-build.mjs +10 -0
  49. package/lib/handoff.mjs +1 -1
  50. package/lib/mcp-tools.mjs +10 -9
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/scaffold.mjs +15 -7
  53. package/lib/studio.mjs +3 -3
  54. package/lib/template-history.json +84 -0
  55. package/lib/worker-config.mjs +1 -1
  56. package/package.json +1 -1
  57. package/shop/SHOP.md +4 -0
  58. package/shop/shop.ts +4 -4
  59. package/tools/TOOLS.md +21 -0
  60. package/tools/tools.ts +1 -1
  61. package/worker/build-id.mjs +1 -1
  62. package/worker/event-resources.mjs +116 -0
  63. package/worker/functions-schema.mjs +15 -0
  64. package/worker/functions.mjs +36 -22
  65. package/worker/index.mjs +23 -3
  66. package/worker/mcp-tools.mjs +14 -3
  67. package/worker/mcp.mjs +45 -8
  68. package/worker/shop-page.mjs +1 -1
  69. package/worker/shop.mjs +6 -2
  70. package/worker/version.mjs +1 -1
package/lib/studio.mjs CHANGED
@@ -90,7 +90,7 @@ export function listExperiences(root) {
90
90
  for (const row of rows) { if (ids.has(row.id)) throw new Error(`app/game id collision: ${row.id}`); ids.add(row.id); }
91
91
  return rows;
92
92
  }
93
- export const experienceDir = (root, id) => join(root, existsSync(join(root, 'apps', id, 'app.json')) ? 'apps' : 'games', id);
93
+ export const experienceDir = (root, id) => join(root, (existsSync(join(root, 'apps', id, 'app.json')) || existsSync(join(root, 'apps', id, 'CODEX.md'))) ? 'apps' : 'games', id);
94
94
  export const experienceFile = (root, id) => join(experienceDir(root, id), existsSync(join(root, 'apps', id, 'app.json')) ? 'app.json' : 'game.json');
95
95
  function listKind(root, kind) {
96
96
  const dir = join(root, `${kind}s`);
@@ -302,7 +302,7 @@ export function addNeeds(root, needs) {
302
302
 
303
303
  export async function newApp(root, id, { name, from = 'welcome' } = {}) {
304
304
  if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('invalid app id');
305
- if (listExperiences(root).some((g) => g.id === id) || existsSync(join(root, 'apps', id))) throw new Error(`app/game ${id} already exists`);
305
+ if (listExperiences(root).some((g) => g.id === id) || (existsSync(join(root, 'apps', id)) && readdirSync(join(root, 'apps', id)).some(f => !['CODEX.md', 'codex', 'style.json'].includes(f)))) throw new Error(`app/game ${id} already exists`);
306
306
  const dest = join(root, 'apps', id);
307
307
  if (!['welcome', 'giving', 'gathering', 'quiz', 'jobs'].includes(from)) throw new Error('app starter: welcome, giving, gathering, quiz or jobs');
308
308
  cpSync(join(PACKAGE_ROOT, 'app-starters', from), dest, { recursive: true });
@@ -312,5 +312,5 @@ export async function newApp(root, id, { name, from = 'welcome' } = {}) {
312
312
  writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
313
313
  const main = join(dest, meta.entry ?? 'src/main.ts');
314
314
  writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
315
- return { ok: true, command: 'app new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
315
+ return { ok: true, command: 'app new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs), next: ['This starter is plumbing: roles, records, room and a check. It is not the deliverable.', 'Read guides/plan/SKILL.md and guides/app/SKILL.md in the installed toolkit. Capture the person’s scene, people, offers and demonstrable needs in apps/' + id + '/CODEX.md. Build that scene with engines, style and shared parts; repair build failures without flattening it.', 'Install reported dependencies, build, and prove every Codex need on wall and phone before calling it done.'] };
316
316
  }
@@ -3153,6 +3153,90 @@
3153
3153
  }
3154
3154
  }
3155
3155
  }
3156
+ },
3157
+ "0.50.0": {
3158
+ "files": {
3159
+ "CLAUDE.md": "ddf2b2a0376202f6",
3160
+ "README.md": "684b3cca8001673c",
3161
+ "HANDOFF.md": "29337e2ace95b2e7",
3162
+ "games/README.md": "5ad32fe80c61f117",
3163
+ "music/README.md": "a3dc042dadd39df8",
3164
+ "videos/README.md": "16babc3437d5545b",
3165
+ "posts/README.md": "37841aae476bd540",
3166
+ "site/README.md": "1dbdf22c03a1e505",
3167
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3168
+ },
3169
+ "sections": {
3170
+ "AGENTS.md": {
3171
+ "(intro)": "394cbbe934e80722",
3172
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3173
+ "## Layout": "f3a280b8c96cd068",
3174
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3175
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3176
+ "## The site": "0cb8dd9a6f8a0096",
3177
+ "## Making games": "bb715272fd1a615b",
3178
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3179
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3180
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3181
+ "## Servers and AI seats": "1ccfd549d2369a60",
3182
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3183
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3184
+ "## Rules": "b2234d80f6cbe71d",
3185
+ "## Telling Homie": "80b46fdd9e578db8",
3186
+ "## Beta": "9d02638207a26e73"
3187
+ }
3188
+ },
3189
+ "named": {
3190
+ "Homie Arcade": {
3191
+ "sections": {
3192
+ "AGENTS.md": {
3193
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3194
+ }
3195
+ }
3196
+ }
3197
+ }
3198
+ },
3199
+ "0.50.1": {
3200
+ "files": {
3201
+ "CLAUDE.md": "ddf2b2a0376202f6",
3202
+ "README.md": "684b3cca8001673c",
3203
+ "HANDOFF.md": "29337e2ace95b2e7",
3204
+ "games/README.md": "5ad32fe80c61f117",
3205
+ "music/README.md": "a3dc042dadd39df8",
3206
+ "videos/README.md": "16babc3437d5545b",
3207
+ "posts/README.md": "37841aae476bd540",
3208
+ "site/README.md": "1dbdf22c03a1e505",
3209
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3210
+ },
3211
+ "sections": {
3212
+ "AGENTS.md": {
3213
+ "(intro)": "394cbbe934e80722",
3214
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3215
+ "## Layout": "f3a280b8c96cd068",
3216
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3217
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3218
+ "## The site": "0cb8dd9a6f8a0096",
3219
+ "## Making games": "bb715272fd1a615b",
3220
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3221
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3222
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3223
+ "## Servers and AI seats": "1ccfd549d2369a60",
3224
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3225
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3226
+ "## Rules": "b2234d80f6cbe71d",
3227
+ "## Telling Homie": "80b46fdd9e578db8",
3228
+ "## Beta": "9d02638207a26e73"
3229
+ }
3230
+ },
3231
+ "named": {
3232
+ "Homie Arcade": {
3233
+ "sections": {
3234
+ "AGENTS.md": {
3235
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3236
+ }
3237
+ }
3238
+ }
3239
+ }
3156
3240
  }
3157
3241
  }
3158
3242
  }
@@ -24,5 +24,5 @@ export function refreshWorkerConfig(root,overrides={}) {
24
24
  const tools=read(join(root,'site/src/tools/declarations.json'));
25
25
  const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
26
26
  const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
27
- updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,triggers:functions.length?{crons:[...new Set(['* * * * *',...functions.map(f=>f.schedule).filter(Boolean)])].sort()}:null,...overrides}));
27
+ updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:functions.length||Object.keys(studio.mcp?.events??{}).length?{crons:[...new Set(['* * * * *',...functions.map(f=>f.schedule).filter(Boolean)])].sort()}:null,...overrides}));
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.49.0",
3
+ "version": "0.50.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/shop/SHOP.md CHANGED
@@ -215,3 +215,7 @@ itemButton.onclick = () => shop.open('supporter');
215
215
  ```
216
216
 
217
217
  These calls open the same shop in games and apps. Inside a player card, purchases leave for the studio's shop; ordinary play/open pages start Stripe checkout. The studio's TV and kids policies still apply.
218
+
219
+ ## App payment references
220
+
221
+ Create the booking/ticket/gift intention first. `shop.buy('full-tune', 1, undefined, { reference: booking.id })` or `shop.checkout({ reference: booking.id })` sends the app's own reference through checkout. Existing boolean returns are unchanged: checkout opened is not paid. The verified durable `order.paid` event carries `data.order`, `data.reference` (null if omitted) and `data.app`. Reopened Payment Links retain the reference. A reference is buyer-supplied correlation, not authorization: verify the record, app, amount and currency, deduplicate by event/order ID, then fulfil it. Keep personal data out of references. Before Stripe is connected, local prices and unpaid requests can be demonstrated; actual payment and recurring collection remain unverified. Test events must be labelled simulations.
package/shop/shop.ts CHANGED
@@ -61,8 +61,8 @@ export interface Shop {
61
61
  add(item: string, quantity?: number, amount?: number): void;
62
62
  cart(): CartLine[];
63
63
  clear(): void;
64
- checkout(): Promise<boolean>;
65
- buy(item: string, quantity?: number, amount?: number): Promise<boolean>;
64
+ checkout(options?: { reference?: string }): Promise<boolean>;
65
+ buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
66
66
  /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
67
67
  used(key: string): Promise<boolean>;
68
68
  on(ev: 'change', fn: (owns: string[]) => void): () => void;
@@ -129,8 +129,8 @@ export function createShop(opts: ShopOptions = {}): Shop {
129
129
  add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
130
130
  cart: () => cart.map((line) => ({ ...line })),
131
131
  clear: () => { cart = []; },
132
- checkout: async () => { const ok = Boolean((await send({ op: 'checkout', lines: cart })).ok); if (ok) cart = []; return ok; },
133
- buy: async (item, quantity = 1, amount) => Boolean((await send({ op: 'checkout', lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
132
+ checkout: async (options = {}) => { const ok = Boolean((await send({ op: 'checkout', lines: cart, reference: options.reference })).ok); if (ok) cart = []; return ok; },
133
+ buy: async (item, quantity = 1, amount, options = {}) => Boolean((await send({ op: 'checkout', reference: options.reference, lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
134
134
  close: () => { void send({ op: 'close' }); },
135
135
  used: async (key) => (KEY.test(String(key)) && owns.includes(String(key)) ? Boolean((await send({ op: 'used', key })).ok) : false),
136
136
  on: ((ev: ShopEvent, fn: (x: unknown) => void) => {
package/tools/TOOLS.md CHANGED
@@ -151,3 +151,24 @@ page. App record collections retain their own role capabilities.
151
151
  MCP calls have no default rate limit. Set `mcp.callsPerMinute` only if the studio
152
152
  chooses a policy. Payment-provider requirements (such as a currency supported by
153
153
  the configured wallet or tax calculation in Checkout) still apply.
154
+
155
+ ## Booking notifications and staff access
156
+
157
+ For “tell my AI when a booking comes in”, follow the worked example in
158
+ `functions/FUNCTIONS.md`: declare `mcp.events.bookings` in `studio.json`, build,
159
+ then connect the owner's AI to `/mcp` with OAuth. Subscribe to
160
+ `studio://events/bookings` with `resources/subscribe` and keep the authenticated
161
+ SSE GET open. `notifications/resources/updated` carries the event in
162
+ `params._meta["studio/event"]`. Resume with the SSE `Last-Event-ID`; do not poll.
163
+ Nothing is enabled by default. Outbound webhooks are not included in 0.50.1.
164
+
165
+ The owner can call `app_role` with `{app, role, signIn, revoke}`. `signIn` is an
166
+ existing account's verified email or exact account name, not an account ID.
167
+ Ambiguous names require a verified email. Grant with `revoke: false`; revoke with
168
+ `revoke: true`. The person must first have signed in. Current grants govern tools,
169
+ resources and event delivery.
170
+
171
+ `app_records` also accepts `operation: "delete"`, with the record's current
172
+ `version`, where the selected role declares `delete:<collection>`. Record conflict
173
+ messages are returned intact so the AI can reload and retry; unexpected tool
174
+ errors remain generic to avoid exposing secrets.
package/tools/tools.ts CHANGED
@@ -3,7 +3,7 @@ export type Audience = 'public' | 'signed-in' | 'owner' | { app: string; role: s
3
3
  export interface ToolContext {
4
4
  readonly purchase?: { readonly id: string };
5
5
  readonly caller: { readonly id: string | null; readonly owner: boolean; readonly client: string | null; readonly roles: Readonly<Record<string, readonly string[]>> };
6
- records(args: { app: string; role?: string; collection: string; id?: string; operation?: 'read' | 'create' | 'update'; data?: Record<string, unknown>; version?: number }): Promise<unknown>;
6
+ records(args: { app: string; role?: string; collection: string; id?: string; operation?: 'read' | 'create' | 'update' | 'delete'; data?: Record<string, unknown>; version?: number }): Promise<unknown>;
7
7
  office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
8
8
  shop(): Promise<unknown>;
9
9
  rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.49.0","sha256":"26095bfdf585bf237d209c64a8ee5e2f99cbb901814dcf972d5872d753127413"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.50.1","sha256":"0ff5d7cf032cfcbfd6fa3a787c60055eadce37a46fd8f7fc09c33e073310d6ae"});
@@ -0,0 +1,116 @@
1
+ import {allowed,currentCaller} from './mcp-tools.mjs';
2
+ import {settingsOf,launchOf} from './office.mjs';
3
+
4
+ /** Explicit studio declarations only; these also retain their event types. */
5
+ export function eventFeeds(env) {
6
+ return JSON.parse(env.STUDIO_EVENT_FEEDS??'{}');
7
+ }
8
+ export function feedSubscriptions(env) {
9
+ return Object.entries(eventFeeds(env)).map(([name,feed])=>({name:'@events/'+name,event:feed.event}));
10
+ }
11
+ export const feedUri=name=>'studio://events/'+name;
12
+ export async function readableFeed(env,cat,caller,name) {
13
+ const feed=eventFeeds(env)[name];
14
+ if(!feed||!caller.id||!await allowed(feed.audience??'owner',caller,env))return false;
15
+ if(feed.event==='record.changed') {
16
+ const app=cat.games.find(g=>g.id===feed.app&&g.kind==='app');
17
+ if(!app||!app.records?.collections?.[feed.collection])return false;
18
+ if(caller.owner)return true;
19
+ if(launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return false;
20
+ return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||caller.roles?.[app.id]?.includes(role)));
21
+ }
22
+ // Non-record events have no app read capability; they remain owner-only.
23
+ return caller.owner===true;
24
+ }
25
+ export async function feedEvents(env,name,{after=0,through=Number.MAX_SAFE_INTEGER}={}) {
26
+ const feed=eventFeeds(env)[name];if(!feed)return [];
27
+ const rows=(await env.DB.prepare('SELECT seq,id,type,data,at FROM studio_events WHERE type=?1 AND seq>?2 AND seq<=?3 ORDER BY seq LIMIT 100').bind(feed.event,after,through).all()).results;
28
+ return rows.map(r=>({...r,data:JSON.parse(r.data)}));
29
+ }
30
+ export function matchesFeed(feed,event) {
31
+ return feed.event===event.type && (feed.event!=='record.changed'||(event.data.app===feed.app&&event.data.collection===feed.collection));
32
+ }
33
+ const key=(connection,name)=>'event-sub:'+connection+':'+name;
34
+ export async function subscribeFeed(env,cat,caller,uri,remove=false) {
35
+ const name=uri?.startsWith('studio://events/')?uri.slice(16):'';
36
+ if(!/^[a-z][a-z0-9-]*$/.test(name)||!await readableFeed(env,cat,caller,name))throw new Error('Event resource unavailable');
37
+ if(remove)await env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(key(caller.connection,name)).run();
38
+ else {
39
+ const seq=(await env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
40
+ await env.DB.prepare('INSERT OR IGNORE INTO mcp_kv(key,value,expires) VALUES(?1,?2,NULL)').bind(key(caller.connection,name),JSON.stringify({connection:caller.connection,name,after:seq})).run();
41
+ }
42
+ }
43
+ export function eventHub(env,connection) {return env.LOBBY.get(env.LOBBY.idFromName('@studio-events/'+connection));}
44
+ export async function pushEvents(env,cat,origin) {
45
+ if(!env.LOBBY||!Object.keys(eventFeeds(env)).length)return;
46
+ // Page subscriptions, without a studio-wide subscription or delivery cap.
47
+ let after='';const seen=new Set();
48
+ for(;;){
49
+ const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'event-sub:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
50
+ for(const row of rows){const {connection}=JSON.parse(row.value);if(seen.has(connection))continue;seen.add(connection);
51
+ const response=await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'wake',connection,cat,origin})});
52
+ if(!response.ok)throw new Error('Event stream wake failed');
53
+ }
54
+ if(rows.length<100)break;after=rows.at(-1).key;
55
+ }
56
+ }
57
+
58
+ /** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
59
+ export class EventStreams {
60
+ constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
61
+ close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream)this.stream=null;}
62
+ background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
63
+ async fetch(request) {
64
+ const input=await request.json();
65
+ if(input.op==='stream') {
66
+ // A reconnect replaces the previous GET, including a half-open socket.
67
+ if(this.stream)this.close(this.stream);
68
+ await this.tail;
69
+ const {connection,lastId}=input;
70
+ const subscriptions=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+connection+':%').all()).results;
71
+ let after=subscriptions.length?Math.min(...subscriptions.map(r=>JSON.parse(r.value).after)):(await this.env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq,name='';
72
+ if(lastId){const parts=lastId.split('/');if(parts.length!==3||parts[0]!==connection||!/^\d+$/.test(parts[1]))return new Response('Invalid Last-Event-ID',{status:400});after=Number(parts[1]);name=parts[2];}
73
+ const stream={...input,after,name,controller:null,cancelled:false,resume:null};
74
+ const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(new TextEncoder().encode(': connected\n\n'));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
75
+ this.stream=stream;
76
+ // Replay and later wakes share one ordered pump. Backpressure must not
77
+ // hold up the writer's request or a function handler.
78
+ this.background(input);
79
+ return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
80
+ }
81
+ this.background(input);return new Response(null,{status:204});
82
+ }
83
+ wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(()=>{});return work;}
84
+ async deliver(input) {
85
+ const stream=this.stream;if(!stream)return;
86
+ let caller;
87
+ try{caller=await currentCaller(this.env,{connection:input.connection});}
88
+ catch{this.close(stream);return;}
89
+ const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+caller.connection+':%').all()).results;
90
+ const subscriptions=[];
91
+ for(const row of rows){const sub=JSON.parse(row.value);if(await readableFeed(this.env,input.cat,caller,sub.name))subscriptions.push(sub);}
92
+ const through=(await this.env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
93
+ let cursor=Math.max(0,stream.after-1);
94
+ for(;;){
95
+ const events=(await this.env.DB.prepare('SELECT seq,id,type,data,at FROM studio_events WHERE seq>?1 AND seq<=?2 ORDER BY seq LIMIT 100').bind(cursor,through).all()).results;
96
+ for(const raw of events){const event={...raw,data:JSON.parse(raw.data)};
97
+ for(const sub of subscriptions.sort((a,b)=>a.name<b.name?-1:a.name>b.name?1:0)){
98
+ if(event.seq<=sub.after||event.seq<stream.after||event.seq===stream.after&&sub.name<=stream.name||!matchesFeed(eventFeeds(this.env)[sub.name],event))continue;
99
+ if(stream.cancelled)return;
100
+ if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
101
+ if(stream.cancelled)return;
102
+ // Recheck identity and grants for every event, including replay.
103
+ const fresh=await currentCaller(this.env,{connection:caller.connection}).catch(()=>null);
104
+ if(!fresh){this.close(stream);return;}
105
+ if(!await readableFeed(this.env,input.cat,fresh,sub.name))continue;
106
+ if(!await this.env.DB.prepare('SELECT key FROM mcp_kv WHERE key=?1').bind(key(caller.connection,sub.name)).first())continue;
107
+ const id=`${caller.connection}/${event.seq}/${sub.name}`;
108
+ const notification={jsonrpc:'2.0',method:'notifications/resources/updated',params:{uri:feedUri(sub.name),_meta:{'studio/event':event}}};
109
+ try{stream.controller.enqueue(new TextEncoder().encode(`id: ${id}\nevent: message\ndata: ${JSON.stringify(notification)}\n\n`));}catch{this.close(stream);return;}
110
+ stream.after=event.seq;stream.name=sub.name;
111
+ }
112
+ }
113
+ if(events.length<100)break;cursor=events.at(-1).seq;
114
+ }
115
+ }
116
+ }
@@ -69,3 +69,18 @@ ELSE 0 END BEGIN
69
69
  SELECT 'refund:' || json_extract(NEW.value,'$.id'),'payment.refunded',
70
70
  json_object('refund',json_extract(NEW.value,'$.id'),'payment',json_extract(NEW.value,'$.payment_intent'),'amount',json_extract(NEW.value,'$.amount'),'currency',json_extract(NEW.value,'$.currency')),json_extract(NEW.value,'$.at')*1000;
71
71
  END;`).join('\n');
72
+
73
+ /** Application correlation is saved before payment; only the paid transition emits it. */
74
+ export const FUNCTIONS_REFERENCE_MIGRATION_FILE = '0021_app_payment_reference.sql';
75
+ export const FUNCTIONS_REFERENCE_MIGRATION = `
76
+ DROP TRIGGER IF EXISTS shop_orders_function_paid;
77
+ CREATE TRIGGER shop_orders_function_paid AFTER UPDATE ON shop_orders
78
+ WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
79
+ AND OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL
80
+ AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE id=NEW.id) BEGIN
81
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
82
+ VALUES('shop_orders:' || NEW.id || ':paid','order.paid',
83
+ json_object('order',NEW.id,'source','shop_orders','amount',NEW.amount,'currency',NEW.currency,'mode',NEW.mode,
84
+ 'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id)),NEW.paid_at);
85
+ END;
86
+ `;
@@ -1,11 +1,13 @@
1
+ import {feedSubscriptions,pushEvents} from './event-resources.mjs';
1
2
  import { services } from './mcp-tools.mjs';
2
3
  import { isOwner, sameOrigin } from './office.mjs';
3
4
  import { esc } from './site.mjs';
4
5
  let load = async () => [];
5
6
  export function useFunctions(loader) { load = loader; }
6
- export async function hasFunctionType(type) { return (await load()).some(fn=>fn.event===type); }
7
+ export async function hasFunctionType(type,env={}) { return [...await load(),...feedSubscriptions(env)].some(fn=>fn.event===type); }
7
8
  export async function syncFunctions(env, definitions = null, now = Date.now()) {
8
9
  definitions ??= await load();
10
+ definitions=[...definitions.filter(fn=>!fn.name.startsWith('@events/')),...feedSubscriptions(env)];
9
11
  if (!env.DB || !definitions.length) return definitions;
10
12
  // Deploy owns the production registry; an old in-flight Worker must not overwrite it.
11
13
  if(await env.DB.prepare("SELECT 1 FROM meta WHERE key='function-registry-deployed'").first())return definitions;
@@ -39,29 +41,41 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
39
41
  definitions = await syncFunctions(env,definitions,now());
40
42
  if(!definitions.length)return;
41
43
  if(typeof cat==='function')cat=await cat();
42
- for (const fn of definitions) {
43
- const subscription=await env.DB.prepare('SELECT cursor,type FROM function_subscriptions WHERE name=?1').bind(fn.name).first();
44
- if(!subscription || subscription.type!==fn.event)continue;
45
- const page=(await env.DB.prepare('SELECT seq,id,data FROM studio_events WHERE type=?1 AND seq>?2 ORDER BY seq LIMIT 100').bind(fn.event,subscription.cursor).all()).results;
46
- if(page.length)await env.DB.batch([
47
- ...page.filter(e=>!fn.schedule||JSON.parse(e.data).function===fn.name).map(e=>env.DB.prepare('INSERT OR IGNORE INTO function_deliveries(event,function) SELECT id,?2 FROM studio_events WHERE id=?1').bind(e.id,fn.name)),
48
- env.DB.prepare('UPDATE function_subscriptions SET cursor=MAX(cursor,?2) WHERE name=?1').bind(fn.name,page.at(-1).seq),
49
- ]);
50
- const rows=(await env.DB.prepare(`SELECT d.*,e.type,e.data,e.at FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND d.state IN ('pending','failed','running') AND d.due<=?2 ORDER BY d.due LIMIT 100`).bind(fn.name,now()).all()).results;
51
- for (const row of rows) {
52
- const lease=crypto.randomUUID();
53
- const claimed=await env.DB.prepare(`UPDATE function_deliveries SET state='running',lease=?3,due=?4,attempts=attempts+1 WHERE event=?1 AND function=?2 AND state!='complete' AND due<=?5`).bind(row.event,fn.name,lease,now()+leaseMs,now()).run();
54
- if (!claimed.meta.changes) continue;
55
- try {
56
- const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:JSON.parse(row.data),attempt:row.attempts+1});
57
- await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null},'function:'+fn.name,fn));
58
- await env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()).run();
59
- } catch {
60
- // Handler errors may contain credentials. The office identifies the function/event; Worker logs are the studio's debugging surface.
61
- await env.DB.prepare(`UPDATE function_deliveries SET state='failed',error='Function failed; inspect its code and retry',due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()+leaseMs).run();
44
+ // An AI notification must not wait behind a slow function handler.
45
+ const notify=()=>pushEvents(env,cat,origin).catch(error=>console.error('Event notification wake failed',error));
46
+ const notifications=notify();
47
+ let more;
48
+ do {
49
+ more=false;
50
+ for (const fn of definitions) {
51
+ if(!fn.handler)continue;
52
+ const subscription=await env.DB.prepare('SELECT cursor,type FROM function_subscriptions WHERE name=?1').bind(fn.name).first();
53
+ if(!subscription || subscription.type!==fn.event)continue;
54
+ const page=(await env.DB.prepare('SELECT seq,id,data FROM studio_events WHERE type=?1 AND seq>?2 ORDER BY seq LIMIT 100').bind(fn.event,subscription.cursor).all()).results;
55
+ if(page.length)more=true;
56
+ if(page.length)await env.DB.batch([
57
+ ...page.filter(e=>!fn.schedule||JSON.parse(e.data).function===fn.name).map(e=>env.DB.prepare('INSERT OR IGNORE INTO function_deliveries(event,function) SELECT id,?2 FROM studio_events WHERE id=?1').bind(e.id,fn.name)),
58
+ env.DB.prepare('UPDATE function_subscriptions SET cursor=MAX(cursor,?2) WHERE name=?1').bind(fn.name,page.at(-1).seq),
59
+ ]);
60
+ const rows=(await env.DB.prepare(`SELECT d.*,e.type,e.data,e.at FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND d.state IN ('pending','failed','running') AND d.due<=?2 ORDER BY d.due LIMIT 100`).bind(fn.name,now()).all()).results;
61
+ if(rows.length===100)more=true;
62
+ for (const row of rows) {
63
+ const lease=crypto.randomUUID();
64
+ const claimed=await env.DB.prepare(`UPDATE function_deliveries SET state='running',lease=?3,due=?4,attempts=attempts+1 WHERE event=?1 AND function=?2 AND state!='complete' AND due<=?5`).bind(row.event,fn.name,lease,now()+leaseMs,now()).run();
65
+ if (!claimed.meta.changes) continue;
66
+ try {
67
+ const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:JSON.parse(row.data),attempt:row.attempts+1});
68
+ await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null},'function:'+fn.name,fn));
69
+ await env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()).run();
70
+ } catch {
71
+ // Handler errors may contain credentials. The office identifies the function/event; Worker logs are the studio's debugging surface.
72
+ await env.DB.prepare(`UPDATE function_deliveries SET state='failed',error='Function failed; inspect its code and retry',due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()+leaseMs).run();
73
+ }
62
74
  }
63
75
  }
64
- }
76
+ } while(more);
77
+ await notifications;
78
+ await notify();
65
79
  await pruneEvents(env);
66
80
  }
67
81
  export async function scheduleFunctions(event,env,cat,origin) {
package/worker/index.mjs CHANGED
@@ -621,18 +621,31 @@ export default {
621
621
  async fetch(request, env, ctx) {
622
622
  const url = new URL(request.url);
623
623
  await (await import('./functions.mjs')).syncFunctions(env);
624
+ const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
625
+ // Some routes reconcile shop state in waitUntil after returning their page.
626
+ // Keep dispatch behind those writes too, preserving the runtime context's
627
+ // methods and OAuth verification symbols through the proxy.
628
+ const requestCtx=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
629
+ if(key==='waitUntil')return work=>ctx.waitUntil(Promise.resolve(work).finally(dispatch));
630
+ const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
631
+ }}):ctx;
624
632
  // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
625
633
  // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
626
634
  const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
627
635
  if (appRecords && request.method === 'OPTIONS') return appCors(request, new Response(null, { status: 204, headers: { 'access-control-allow-methods': 'GET, POST, PUT, DELETE', 'access-control-allow-headers': 'content-type' } }));
628
636
  const appLobby = isAppOrigin(request) && /^\/[^/]+\/api\/lobby\/?$/.test(url.pathname);
629
637
  let res;
630
- try { res = finish(await route(request, env, ctx), url.pathname); } catch (error) {
638
+ try { res = finish(await route(request, env, requestCtx), url.pathname); } catch (error) {
631
639
  if (!appLobby) throw error;
632
640
  try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
633
641
  res = json({ ok: false, error: 'failed' }, 500);
642
+ } finally {
643
+ // A route can commit a write and then fail. Its outbox still needs delivery.
644
+ if(env.DB) {
645
+ const delivery=dispatch();
646
+ if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
647
+ }
634
648
  }
635
- if(ctx?.waitUntil && ['POST','PUT','DELETE'].includes(request.method)) ctx.waitUntil((async()=>{const {runFunctions}=await import('./functions.mjs');await runFunctions(env,()=>catalogue(env,url.origin),url.origin);})());
636
649
  if (appLobby || appRecords) return appCors(request, res);
637
650
  // A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
638
651
  // that pays referrals. The page is answered first; a cookie is added to it only then.
@@ -1840,7 +1853,7 @@ export class Table {
1840
1853
  queueFunctionEvent(type,data) {
1841
1854
  if(!this.env.DB || this.env.HOMIE_PREVIEW==='1' || !this.ctx.storage.sql) return;
1842
1855
  this.ctx.waitUntil((async()=>{
1843
- if(!await (await import('./functions.mjs')).hasFunctionType(type))return;
1856
+ if(!await (await import('./functions.mjs')).hasFunctionType(type,this.env))return;
1844
1857
  const sql=this.ctx.storage.sql;
1845
1858
  sql.exec('CREATE TABLE IF NOT EXISTS function_outbox(id TEXT PRIMARY KEY,type TEXT,data TEXT,at INTEGER)');
1846
1859
  sql.exec('INSERT INTO function_outbox VALUES(?,?,?,?)',crypto.randomUUID(),type,JSON.stringify(data),Date.now());
@@ -1858,6 +1871,8 @@ export class Table {
1858
1871
  try {await emitEvent(this.env,row.type,JSON.parse(row.data),{id:row.id,at:row.at});sql.exec('DELETE FROM function_outbox WHERE id=?',row.id);}
1859
1872
  catch {this.armRoom(Date.now()+60000);await this.alarmWrite;return;}
1860
1873
  }
1874
+ const cat=await catalogue(this.env,'https://studio.invalid');
1875
+ await (await import('./functions.mjs')).runFunctions(this.env,cat,cat.studio?.url??'https://studio.invalid');
1861
1876
  }
1862
1877
 
1863
1878
  /**
@@ -2565,6 +2580,11 @@ export class Lobby {
2565
2580
 
2566
2581
  /** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
2567
2582
  async fetch(request) {
2583
+ if(new URL(request.url).pathname==='/__studio_events') {
2584
+ const {EventStreams}=await import('./event-resources.mjs');
2585
+ this.eventStreams??=new EventStreams(this.env,this.ctx);
2586
+ return this.eventStreams.fetch(request);
2587
+ }
2568
2588
  try { return await this.answer(request); } catch (error) {
2569
2589
  let path = '';
2570
2590
  try { path = new URL(request.url).pathname; } catch { /* not an address */ }
@@ -38,10 +38,10 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
38
38
  if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw new Error('unknown app, collection or record');
39
39
  if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
40
40
  const url = new URL(`/${app}/api/app/records/${collection}${id ? '/'+id : ''}`,origin);if(role)url.searchParams.set('role',role);
41
- const method = {read:'GET',create:'POST',update:'PUT'}[operation];if (!method) throw new Error('unsupported operation');
41
+ const method = {read:'GET',create:'POST',update:'PUT',delete:'DELETE'}[operation];if (!method) throw new Error('unsupported operation');
42
42
  const request = withToolIdentity(new Request(url,{method,headers:{origin,'content-type':'application/json'},...(method==='GET'?{}:{body:JSON.stringify({data,version})})}),caller);
43
43
  const response = await appRecordsRoute(request,env,meta,url,url.pathname.slice(app.length+2));
44
- const result = await response.json(); if(!response.ok) throw new Error(result.error);return result;
44
+ const result = await response.json(); if(!response.ok) {const error=new Error(result.error);error.name='StudioOperationError';throw error;}return result;
45
45
  };
46
46
  const office = async (path, body, query={}) => {
47
47
  const delegated=definition.delegateOffice && await allowed(definition.audience,caller,env);
@@ -91,6 +91,17 @@ export function builtins(env,cat,origin) {
91
91
  tool('studio_info','The public studio, apps, games and posts.','public',object(),async()=>{const pub=publicCatalogue(cat,await settingsOf(env),env);return {studio:pub.studio?.name,games:pub.games.map(g=>({id:g.id,name:g.name,kind:g.kind??'game'})),posts:pub.posts};}),
92
92
  tool('studio_office','Live rooms and the players as shown in the office.','owner',object(),(_,ctx)=>ctx.office('/_studio/api/office')),
93
93
  tool('studio_shop','Orders, refunds and the shop state.','owner',object(),(_,ctx)=>ctx.shop()),
94
+ tool('app_role','Grant or revoke a staff role by verified email or exact sign-in name.','owner',object({app:str,role:str,signIn:{type:'string',maxLength:254},revoke:{type:'boolean'}},['app','role','signIn']),async({app,role,signIn,revoke},ctx)=>{
95
+ if(!ctx.caller.owner)throw new Error('Owner only');
96
+ const meta=cat.games.find(g=>g.id===app&&g.kind==='app');
97
+ if(!meta?.roles?.[role]?.signIn)throw new Error('Name a sign-in role');
98
+ const people=signIn.includes('@')
99
+ ? (await env.DB.prepare('SELECT p.id FROM players p JOIN player_emails e ON e.player=p.id WHERE e.email=?1 AND e.verified_at IS NOT NULL AND p.guest=0').bind(signIn.trim().toLowerCase()).all()).results
100
+ : (await env.DB.prepare('SELECT id FROM players WHERE name=?1 AND guest=0').bind(signIn).all()).results;
101
+ if(people.length!==1){const error=new Error(people.length?'Sign-in is ambiguous; use a verified email':'No signed-in account matches; ask the person to sign in first');error.name='StudioOperationError';throw error;}
102
+ await env.DB.prepare(revoke?'DELETE FROM app_roles WHERE app=?1 AND role=?2 AND player=?3':'INSERT OR IGNORE INTO app_roles(app,role,player) VALUES(?1,?2,?3)').bind(app,role,people[0].id).run();
103
+ return {ok:true,app,role,revoked:revoke===true};
104
+ }),
94
105
  tool('studio_players','The studio accounts as shown in the office.','owner',object({query:str}),({query})=>players.list(env,{q:query})),
95
106
  tool('studio_stats','Studio visits and plays.','owner',object(),()=>readStats(env,cat,{range:rangeOf(new URLSearchParams())})),
96
107
  tool('studio_posts','Published posts.','public',object(),()=>({posts:cat.posts??[]})),
@@ -102,7 +113,7 @@ export function builtins(env,cat,origin) {
102
113
  const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
103
114
  return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
104
115
  }),
105
- tool('app_records','List, read, create or update app records with the app role and record version.','public',object({app:str,role:str,collection:str,id:str,operation:{enum:['read','create','update']},data:{type:'object'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
116
+ tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:str,id:str,operation:{enum:['read','create','update','delete']},data:{type:'object'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
106
117
  ];
107
118
  for(const [name,path,description] of [
108
119
  ['room_announce','announce','Announce to a room.'],['room_kick','kick','Ask the owner to remove a player.'],['room_mute','mute','Ask the owner to mute a player.'],['room_close','close','Ask the owner to close a room.'],['studio_game','game','Ask the owner to change game access or room size.'],['shop_refund','shop/refund','Ask the owner to refund an order.'],