@homie-rocks/studio 0.45.3 → 0.46.2

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.
@@ -0,0 +1,496 @@
1
+ /** Load clients live on worker threads; every snapshot is decoded, no per-frame diagnostic re-encoding. */
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { performance, monitorEventLoopDelay } from 'node:perf_hooks';
5
+ import WebSocket from 'ws';
6
+ import { snapshotDecoder } from '../rules/interest.mjs';
7
+
8
+ export class Histogram {
9
+ bins = new Uint32Array(60001);
10
+ count = 0;
11
+ max = 0;
12
+ add(ms) {
13
+ if (!Number.isFinite(ms) || ms < 0) return;
14
+ this.bins[Math.min(60000, Math.round(ms))]++;
15
+ this.count++;
16
+ this.max = Math.max(this.max, ms);
17
+ }
18
+ merge(h) {
19
+ for (let i = 0; i < this.bins.length; i++) this.bins[i] += h.bins[i];
20
+ this.count += h.count;
21
+ this.max = Math.max(this.max, h.max);
22
+ }
23
+ json() {
24
+ const p = (q) => {
25
+ let n = 0;
26
+ for (let i = 0; i < this.bins.length; i++) {
27
+ n += this.bins[i];
28
+ if (n >= Math.ceil(this.count * q)) return this.count ? i : null;
29
+ }
30
+ return null;
31
+ };
32
+ return {
33
+ count: this.count,
34
+ p50: p(0.5),
35
+ p95: p(0.95),
36
+ p99: p(0.99),
37
+ max: this.max
38
+ };
39
+ }
40
+ }
41
+ export function createLoad({
42
+ origin,
43
+ game,
44
+ room,
45
+ index = 0,
46
+ workers = 1,
47
+ lead = 3,
48
+ compress = true,
49
+ onProblem = () => {}
50
+ }) {
51
+ const clients = [],
52
+ errors = new Map(),
53
+ first = new Histogram(),
54
+ welcome = new Histogram(),
55
+ ack = new Histogram(),
56
+ gaps = new Histogram(),
57
+ // From the tick a player held when it stamped an input to the tick it
58
+ // reached the rules, ms: what the system took, whatever the lead.
59
+ loops = new Histogram();
60
+ // Snapshots that said how early this player's input was, and how many said late.
61
+ let lateAcks = 0,
62
+ onTime = 0;
63
+ const loop = monitorEventLoopDelay({ resolution: 10 });
64
+ loop.enable();
65
+ let measuring = false,
66
+ rampCancelled = false,
67
+ stopping = false,
68
+ epoch = null,
69
+ totalUp = 0,
70
+ totalDown = 0,
71
+ up = 0,
72
+ down = 0,
73
+ sent = 0,
74
+ inputFrames = 0,
75
+ received = 0,
76
+ skipped = 0,
77
+ expired = 0,
78
+ disconnects = 0,
79
+ peakLag = 0,
80
+ badLag = 0,
81
+ stopReason = null;
82
+ let timer,
83
+ expected = performance.now();
84
+ const problem = (type, value) => {
85
+ const key = `${type}: ${String(value).slice(0, 300)}`;
86
+ if (errors.has(key)) errors.set(key, errors.get(key) + 1);
87
+ else if (errors.size < 100) {
88
+ errors.set(key, 1);
89
+ onProblem(key);
90
+ }
91
+ };
92
+ function send(c, m) {
93
+ if (c.ws?.readyState !== 1) return;
94
+ if (c.ws.bufferedAmount > 262144) {
95
+ stopReason ??= 'driver send backlog';
96
+ return;
97
+ }
98
+ const text = JSON.stringify(m),
99
+ bytes = Buffer.byteLength(text);
100
+ c.ws.send(text);
101
+ totalUp += bytes;
102
+ if (measuring) {
103
+ up += bytes;
104
+ sent++;
105
+ if (m.t === 'in') inputFrames++;
106
+ c.up += bytes;
107
+ }
108
+ }
109
+ function snapshot(c, raw) {
110
+ c.rawSnapshots++;
111
+ c.rawTick = raw.k;
112
+ const snap = c.decoder.decode(raw);
113
+ if (!snap) {
114
+ c.decodeMiss++;
115
+ return;
116
+ }
117
+ const own = snap.d[1].find((e) => e[9] === c.seat && e[10] !== 1);
118
+ if (!own) {
119
+ c.ownMissing++;
120
+ return;
121
+ }
122
+ const now = performance.now();
123
+ epoch ??= snap.e;
124
+ if (epoch !== snap.e) stopReason ??= 'room restarted';
125
+ if (c.first === null) {
126
+ c.first = now;
127
+ first.add(now - c.began);
128
+ }
129
+ if (c.epoch !== snap.e) {
130
+ c.pending.clear();
131
+ c.lastSent = 0;
132
+ c.epoch = snap.e;
133
+ }
134
+ const gap = c.at ? now - c.at : 0;
135
+ c.maxStateGapMs = Math.max(c.maxStateGapMs, gap);
136
+ c.k = snap.k;
137
+ c.at = now;
138
+ c.own = own;
139
+ c.r = own[2];
140
+ c.snapshots++;
141
+ if (measuring) {
142
+ c.firstTick ??= snap.k;
143
+ c.firstAt ??= now;
144
+ c.lastTick = snap.k;
145
+ c.lastAt = now;
146
+ c.frames++;
147
+ gaps.add(gap);
148
+ }
149
+ const control = snap.c?.find((row) => row[0] === c.seat);
150
+ // The room says how early this player's inputs arrive, in sixteenths of a
151
+ // tick (the smallest since its last snapshot). The toolkit's client steers
152
+ // its clock by that, to be half a tick early but for its jitter; this probe
153
+ // moves its whole-tick lead the same way: every two seconds, one more when
154
+ // the earliest twentieth of its last forty reports was under half a tick
155
+ // early, one fewer when it was over a tick and a half.
156
+ if (control && control[3] !== -128 && control[3] !== undefined) {
157
+ const early = control[3] / 16;
158
+ if (measuring) { if (early <= 0) lateAcks++; onTime++; loops.add((c.lead - early) * 50); }
159
+ (c.early ??= []).push(early); if (c.early.length > 40) c.early.shift();
160
+ if (c.early.length >= 20 && now - (c.leadAt ?? 0) > 2000) {
161
+ const low = c.early.slice().sort((a, b) => a - b)[Math.floor(c.early.length / 20)];
162
+ if (low < 0.5 && c.lead < 12) c.lead++;
163
+ else if (low > 1.5 && c.lead > 1) c.lead--;
164
+ c.leadAt = now; c.early.length = 0;
165
+ }
166
+ }
167
+ if (control)
168
+ for (const [tick, at] of c.pending) {
169
+ if (tick > control[2]) break;
170
+ c.pending.delete(tick);
171
+ if (measuring) {
172
+ if (tick === control[2]) ack.add(now - at);
173
+ else skipped++;
174
+ }
175
+ }
176
+ }
177
+ async function connect(i, reused) {
178
+ const c = {
179
+ i,
180
+ key: reused?.key ?? randomBytes(18).toString('base64url'),
181
+ seat: null,
182
+ token: reused?.token ?? null,
183
+ priorSeat: reused?.seat ?? null,
184
+ first: null,
185
+ lead,
186
+ began: performance.now(),
187
+ decoder: snapshotDecoder(),
188
+ pending: new Map(),
189
+ epoch: null,
190
+ k: 0,
191
+ r: 0,
192
+ at: 0,
193
+ lastSent: 0,
194
+ firstTick: null,
195
+ firstAt: null,
196
+ lastTick: null,
197
+ lastAt: null,
198
+ frames: 0,
199
+ up: 0,
200
+ down: 0,
201
+ snapshots: 0,
202
+ rawSnapshots: 0,
203
+ decodeMiss: 0,
204
+ ownMissing: 0,
205
+ maxStateGapMs: 0,
206
+ own: null,
207
+ nextPing: performance.now() + (i % 20) * 100,
208
+ nextInput: performance.now() + (i % 10) * 10
209
+ };
210
+ clients.push(c);
211
+ try {
212
+ const response = await fetch(
213
+ `${origin}/${game}/__game/?room=${encodeURIComponent(room)}&device=desk&want=play&b=${c.key}`,
214
+ { signal: AbortSignal.timeout(15000) }
215
+ );
216
+ if (!response.ok) throw Error(`bootstrap HTTP ${response.status}`);
217
+ const match = (await response.text()).match(
218
+ /window\.HOMIE_NET=(\{[^<]+\})<\/script>/
219
+ );
220
+ if (!match) throw Error('No HOMIE_NET bootstrap');
221
+ const cfg = JSON.parse(match[1]);
222
+ if (cfg.room !== undefined && cfg.room !== room)
223
+ throw Error('bootstrap changed the requested room');
224
+ c.ws = new WebSocket(cfg.url, {
225
+ origin: 'null',
226
+ handshakeTimeout: 15000,
227
+ // A browser always offers compression; a driver that refuses it asks
228
+ // one uplink for several times the bytes real players would cost it.
229
+ perMessageDeflate: compress
230
+ });
231
+ c.ws.on('open', () => {
232
+ c.helloAt = performance.now();
233
+ c.compressed = /permessage-deflate/.test(c.ws.extensions ?? '');
234
+ send(c, {
235
+ t: 'hello',
236
+ rules: true,
237
+ v: 1,
238
+ rev: 12,
239
+ token: c.token,
240
+ name: `Load ${i}`,
241
+ device: 'desk',
242
+ want: 'play',
243
+ canHost: false,
244
+ // As the toolkit's client (0.46.2): this decoder coasts a body it was not sent.
245
+ coast: 1,
246
+ game,
247
+ max: 1000,
248
+ ...(cfg.ver ? { ver: cfg.ver } : {})
249
+ });
250
+ });
251
+ c.ws.on('message', (data) => {
252
+ totalDown += data.length;
253
+ if (measuring) {
254
+ down += data.length;
255
+ received++;
256
+ c.down += data.length;
257
+ }
258
+ let m;
259
+ try {
260
+ m = JSON.parse(data.toString());
261
+ } catch {
262
+ problem('decode', i);
263
+ return;
264
+ }
265
+ if (m.t === 'welcome') {
266
+ c.token = m.token;
267
+ c.seat = m.seat;
268
+ welcome.add(performance.now() - c.began);
269
+ if (m.full || c.seat === null) problem('unseated', i);
270
+ if (m.snap) snapshot(c, m.snap);
271
+ } else if (m.t === 'snap') snapshot(c, m);
272
+ else if (m.t === 'seat') c.seat = m.seat;
273
+ else if (m.t === 'err' || m.t === 'error') {
274
+ problem(m.code ?? m.t, m.message);
275
+ if (
276
+ ['room-over', 'host-fault', 'host-failed', 'closed'].includes(
277
+ m.code
278
+ )
279
+ )
280
+ stopReason ??= `server ${m.code}`;
281
+ }
282
+ });
283
+ c.ws.on('error', (e) => problem('socket', e.message));
284
+ c.ws.on('unexpected-response', (_req, res) => {
285
+ problem('upgrade', res.statusCode);
286
+ res.resume();
287
+ c.ws.terminate();
288
+ });
289
+ c.ws.on('close', (code, reason) => {
290
+ c.closeCode = code;
291
+ c.closeReason = String(reason);
292
+ c.closedAt = performance.now();
293
+ if (!stopping && !c.retired) {
294
+ disconnects++;
295
+ problem('close', `${code} ${reason}`);
296
+ }
297
+ });
298
+ } catch (e) {
299
+ problem('connect', e.message);
300
+ }
301
+ }
302
+ // Stagger sends into 10 ms slices. No catch-up burst after a held timer.
303
+ timer = setInterval(() => {
304
+ const now = performance.now(),
305
+ lag = Math.max(0, now - expected - 10);
306
+ expected = now;
307
+ peakLag = Math.max(peakLag, lag);
308
+ badLag = lag > 2000 ? badLag + 1 : 0;
309
+ if (badLag >= 3) stopReason ??= 'driver event loop stalled';
310
+ for (const c of clients) {
311
+ if (c.retired || c.ws?.readyState !== 1) continue;
312
+ if (now >= c.nextPing) {
313
+ send(c, { t: 'ping', c: Date.now(), hid: false });
314
+ c.nextPing = now + 2000;
315
+ }
316
+ if (c.own && now - c.at > 5000 && !c.staleReported) {
317
+ c.staleReported = true;
318
+ problem('stale snapshot', 'own state absent for more than 5 seconds');
319
+ }
320
+ if (!c.own || now - c.at > 2000 || now < c.nextInput) continue;
321
+ c.nextInput += 100;
322
+ if (c.nextInput <= now) c.nextInput = now + 100;
323
+ const k = Math.max(
324
+ c.lastSent + 1,
325
+ c.k + Math.min(10, Math.floor((now - c.at) / 50)) + c.lead
326
+ ),
327
+ y = c.own[3][1],
328
+ east = Boolean(c.own[7][1]);
329
+ send(c, {
330
+ t: 'in',
331
+ e: c.epoch,
332
+ k,
333
+ s: [
334
+ [
335
+ 0,
336
+ east ? 127 : -127,
337
+ y > 55
338
+ ? -35
339
+ : y < -55
340
+ ? 35
341
+ : Math.round(Math.sin(now / 3100 + c.i) * 35)
342
+ ]
343
+ ],
344
+ r: c.r,
345
+ // The newest tick this socket holds, as the toolkit's own client says it.
346
+ ...(c.rawTick !== undefined ? { g: c.rawTick } : {})
347
+ });
348
+ c.pending.set(k, now);
349
+ c.lastSent = k;
350
+ for (const [tick, at] of c.pending) {
351
+ if (now - at <= 5000) break;
352
+ c.pending.delete(tick);
353
+ if (measuring) expired++;
354
+ }
355
+ }
356
+ }, 10);
357
+ const summary = () => ({
358
+ worker: index,
359
+ attempted: clients.length,
360
+ connected: clients.filter((c) => c.ws?.readyState === 1).length,
361
+ joined: clients.filter((c) => !c.retired && c.first !== null).length,
362
+ fresh: clients.filter(
363
+ (c) =>
364
+ !c.retired &&
365
+ c.ws?.readyState === 1 &&
366
+ c.own &&
367
+ performance.now() - c.at < 2000
368
+ ).length,
369
+ stopReason,
370
+ epoch,
371
+ inputFrames,
372
+ disconnects,
373
+ loopP99Ms: loop.percentile(99) / 1e6,
374
+ peakTimerLagMs: peakLag,
375
+ upBytes: up,
376
+ downBytes: down,
377
+ upFrames: sent,
378
+ downFrames: received,
379
+ // Bytes off the wire (after TLS, before inflation) for sockets still open.
380
+ wireDownBytes: clients.reduce(
381
+ (a, c) =>
382
+ a + Math.max(0, (c.ws?._socket?.bytesRead ?? 0) - (c.wire0 ?? 0)),
383
+ 0
384
+ ),
385
+ compressed: clients.filter((c) => c.compressed).length
386
+ });
387
+ return {
388
+ connect,
389
+ summary,
390
+ cancelRamp() {
391
+ rampCancelled = true;
392
+ },
393
+ async ramp(n, rampMs) {
394
+ await new Promise((r) => setTimeout(r, index * rampMs));
395
+ const pending = new Set();
396
+ for (let i = index; i < n && !stopping && !rampCancelled; i += workers) {
397
+ while (pending.size >= Math.max(1, Math.floor(8 / workers)))
398
+ await Promise.race(pending);
399
+ if (rampCancelled || stopping) break;
400
+ const task = connect(i).finally(() => pending.delete(task));
401
+ pending.add(task);
402
+ await new Promise((r) => setTimeout(r, rampMs * workers));
403
+ }
404
+ await Promise.all(pending);
405
+ },
406
+ start() {
407
+ measuring = true;
408
+ for (const c of clients) {
409
+ c.wire0 = c.ws?._socket?.bytesRead ?? 0;
410
+ c.pending.clear();
411
+ c.firstTick = null;
412
+ c.firstAt = null;
413
+ c.frames = 0;
414
+ }
415
+ },
416
+ async churn(count) {
417
+ const leaving = clients.filter(
418
+ (c) =>
419
+ c.i < count &&
420
+ !c.retired &&
421
+ c.first !== null &&
422
+ c.ws?.readyState === 1
423
+ );
424
+ for (const c of leaving) {
425
+ c.retired = true;
426
+ c.ws.close(1000, 'churn');
427
+ }
428
+ await new Promise((r) => setTimeout(r, 1000));
429
+ await Promise.all(leaving.map((c) => connect(c.i, c)));
430
+ return leaving.length;
431
+ },
432
+ async finish() {
433
+ const end = summary();
434
+ measuring = false;
435
+ stopping = true;
436
+ clearInterval(timer);
437
+ loop.disable();
438
+ for (const c of clients) c.ws?.close(1000, 'load step complete');
439
+ await new Promise((r) => setTimeout(r, 250));
440
+ for (const c of clients) if (c.ws?.readyState !== 3) c.ws?.terminate();
441
+ return {
442
+ ...end,
443
+ errors: [...errors].map(([text, count]) => ({ text, count })),
444
+ histograms: { first, welcome, ack, gaps, loops },
445
+ inputReports: onTime,
446
+ inputReportsLate: lateAcks,
447
+ leadTicks: clients.filter((c) => !c.retired).map((c) => c.lead),
448
+ ackSkipped: skipped,
449
+ ackExpired: expired,
450
+ totalUp,
451
+ totalDown,
452
+ players: clients.map((c) => ({
453
+ i: c.i,
454
+ seat: c.seat,
455
+ epoch: c.epoch,
456
+ priorSeat: c.priorSeat,
457
+ sameSeat: c.priorSeat === null ? null : c.seat === c.priorSeat,
458
+ retired: Boolean(c.retired),
459
+ firstStateMs: c.first === null ? null : c.first - c.began,
460
+ helloAfterMs: c.helloAt ? c.helloAt - c.began : null,
461
+ closeCode: c.closeCode,
462
+ closeReason: c.closeReason,
463
+ maxStateGapMs: c.maxStateGapMs,
464
+ rawSnapshots: c.rawSnapshots,
465
+ decodeMiss: c.decodeMiss,
466
+ ownMissing: c.ownMissing,
467
+ frames: c.frames,
468
+ tickHz:
469
+ c.lastAt > c.firstAt
470
+ ? ((c.lastTick - c.firstTick) * 1000) / (c.lastAt - c.firstAt)
471
+ : null,
472
+ deliveredHz:
473
+ c.lastAt > c.firstAt
474
+ ? ((c.frames - 1) * 1000) / (c.lastAt - c.firstAt)
475
+ : null,
476
+ upBytes: c.up,
477
+ downBytes: c.down
478
+ }))
479
+ };
480
+ }
481
+ };
482
+ }
483
+ if (parentPort) {
484
+ const load = createLoad({
485
+ ...workerData,
486
+ onProblem: (text) => parentPort.postMessage({ event: 'error', text })
487
+ });
488
+ parentPort.on('message', async ({ id, op, args = [] }) => {
489
+ try {
490
+ const value = await load[op](...args);
491
+ parentPort.postMessage({ id, value });
492
+ } catch (e) {
493
+ parentPort.postMessage({ id, error: e.stack });
494
+ }
495
+ });
496
+ }
@@ -80,7 +80,7 @@ type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
80
80
  entities: ${entities}; move?: Moves;
81
81
  room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number;
82
82
  start?: (world: RW) => void;
83
- join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => ${joins};
83
+ join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: (${joins}) | null }) => ${joins};
84
84
  on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
85
85
  };
86
86
  asks?: ${record(Object.entries(d.asks).map(([n, a]) => [n, `{ state: Fields; questions: ${value(a.questions)}; floor: (state: ReadonlyState<${fields(a.state)}>) => ${picks(a.questions)} }`]))};
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
1
+ import { FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
2
2
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
3
3
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
4
4
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
@@ -105,7 +105,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
105
105
  previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
106
106
  };
107
107
  return `// This studio's site on its own Cloudflare account (written by homie-studio). Everything here runs on
108
- // Cloudflare's Workers Free plan. \`npm run deploy\` (on this computer, or in Workers Builds) puts it live;
108
+ // the studio's Cloudflare Workers account. \`npm run deploy\` (on this computer, or in Workers Builds) puts it live;
109
109
  // \`npx wrangler preview\` puts a branch on its own Preview URL with its own rooms. There is no R2 binding
110
110
  // until \`homie-studio storage add\` gives the studio storage for large media.
111
111
  ${JSON.stringify(config, null, 2)}
@@ -118,7 +118,7 @@ export function agentsMd({ name, slug }) {
118
118
  This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
119
119
  Its games, music, videos and posts live here; its website and public game rooms
120
120
  run on the studio's **own Cloudflare account** (one Worker, one D1 database and
121
- the Table/Lobby Durable Objects, all on Cloudflare's free Workers plan), built
121
+ the Table/Lobby Durable Objects, on the studio's Cloudflare account), built
122
122
  from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
123
123
  directory lists its games; homie.rocks does not host them.
124
124
 
@@ -217,7 +217,7 @@ studio's pinned copy, never a registry lookup of the bare name.
217
217
  - \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
218
218
  costs, and changes nothing. Tell the person before the first deploy.
219
219
  - \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
220
- SQLite-backed Durable Objects, all on the free Workers plan (no payment method needed).
220
+ SQLite-backed Durable Objects on the studio’s Cloudflare account; its subscription is not inferred.
221
221
  If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
222
222
  their browser. It never overwrites a Worker or database this studio did not create.
223
223
  - **Workers Builds** (Cloudflare's own CI, set up by the "Deploy to Cloudflare" button or in the dashboard): on
@@ -553,9 +553,9 @@ chat's card follows the work from then on) and, for a studio still being set up,
553
553
  \`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
554
554
  in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
555
555
  the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
556
- the same calls until the person's own \`proceed <code>\`, once the person has trusted them, and in Grok Build once
556
+ unrequested deploys and the other held calls until the person's own \`proceed <code>\`, once the person has trusted them, and in Grok Build once
557
557
  the plugin is trusted. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
558
- holds are on, and while they are off, ask before each of those yourself. When a call is refused, say what you
558
+ holds are on. A deploy the person requested in their own words is already approved for that task; do not ask again or demand a typed code. Stop an unrequested deploy and ask once. When a call is refused, say what you
559
559
  meant to do and why; do not retry it unless the person asks.
560
560
 
561
561
  ## Telling Homie
@@ -802,6 +802,7 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
802
802
  [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
803
803
  [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
804
804
  [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
805
+ [`site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`]: FUNCTIONS_META_MIGRATION,
805
806
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
806
807
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
807
808
  '.claude/skills/.gitkeep': '',
@@ -973,6 +974,11 @@ function ensurePartsStateMigration(root) {
973
974
  return `site/migrations/${PURCHASE_STATE_FILE}`;
974
975
  }
975
976
 
977
+ function ensureFunctionMeta(root) {
978
+ const file=join(root,'site','migrations',FUNCTIONS_META_MIGRATION_FILE);
979
+ if(existsSync(file))return null;
980
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_META_MIGRATION);return `site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`;
981
+ }
976
982
  function ensureFunctionCursors(root) {
977
983
  const file=join(root,'site','migrations',FUNCTIONS_CURSOR_MIGRATION_FILE);
978
984
  if(existsSync(file))return null;
@@ -989,12 +995,12 @@ function ensureMcpMigration(root) {
989
995
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
990
996
  }
991
997
  export function ensureMigrations(root) {
992
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root)].filter(Boolean);
998
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root)].filter(Boolean);
993
999
  }
994
1000
 
995
1001
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
996
1002
  export function migrationWord(file) {
997
- return /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
1003
+ return /function_meta/.test(file) ? 'refund events alongside plain-text studio settings' : /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
998
1004
  }
999
1005
 
1000
1006
  /**
@@ -2691,6 +2691,90 @@
2691
2691
  }
2692
2692
  }
2693
2693
  }
2694
+ },
2695
+ "0.46.0": {
2696
+ "files": {
2697
+ "CLAUDE.md": "ddf2b2a0376202f6",
2698
+ "README.md": "684b3cca8001673c",
2699
+ "HANDOFF.md": "29337e2ace95b2e7",
2700
+ "games/README.md": "5ad32fe80c61f117",
2701
+ "music/README.md": "a3dc042dadd39df8",
2702
+ "videos/README.md": "16babc3437d5545b",
2703
+ "posts/README.md": "37841aae476bd540",
2704
+ "site/README.md": "732894273a32dc3f",
2705
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2706
+ },
2707
+ "sections": {
2708
+ "AGENTS.md": {
2709
+ "(intro)": "214aa46eb3a895d5",
2710
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2711
+ "## Layout": "f3a280b8c96cd068",
2712
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2713
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2714
+ "## The site": "0cb8dd9a6f8a0096",
2715
+ "## Making games": "bb715272fd1a615b",
2716
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2717
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2718
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2719
+ "## Servers and AI seats": "1ccfd549d2369a60",
2720
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2721
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2722
+ "## Rules": "c48971c1437414c2",
2723
+ "## Telling Homie": "80b46fdd9e578db8",
2724
+ "## Beta": "9d02638207a26e73"
2725
+ }
2726
+ },
2727
+ "named": {
2728
+ "Homie Arcade": {
2729
+ "sections": {
2730
+ "AGENTS.md": {
2731
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2732
+ }
2733
+ }
2734
+ }
2735
+ }
2736
+ },
2737
+ "0.46.2": {
2738
+ "files": {
2739
+ "CLAUDE.md": "ddf2b2a0376202f6",
2740
+ "README.md": "684b3cca8001673c",
2741
+ "HANDOFF.md": "29337e2ace95b2e7",
2742
+ "games/README.md": "5ad32fe80c61f117",
2743
+ "music/README.md": "a3dc042dadd39df8",
2744
+ "videos/README.md": "16babc3437d5545b",
2745
+ "posts/README.md": "37841aae476bd540",
2746
+ "site/README.md": "732894273a32dc3f",
2747
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2748
+ },
2749
+ "sections": {
2750
+ "AGENTS.md": {
2751
+ "(intro)": "394cbbe934e80722",
2752
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2753
+ "## Layout": "f3a280b8c96cd068",
2754
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2755
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
2756
+ "## The site": "0cb8dd9a6f8a0096",
2757
+ "## Making games": "bb715272fd1a615b",
2758
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2759
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2760
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2761
+ "## Servers and AI seats": "1ccfd549d2369a60",
2762
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2763
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2764
+ "## Rules": "27881d11d3ff3803",
2765
+ "## Telling Homie": "80b46fdd9e578db8",
2766
+ "## Beta": "9d02638207a26e73"
2767
+ }
2768
+ },
2769
+ "named": {
2770
+ "Homie Arcade": {
2771
+ "sections": {
2772
+ "AGENTS.md": {
2773
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
2774
+ }
2775
+ }
2776
+ }
2777
+ }
2694
2778
  }
2695
2779
  }
2696
2780
  }
@@ -2106,7 +2106,9 @@ from outside the studio run on its server.
2106
2106
  The Table writes the whole room to its SQLite `save` table in one synchronous transaction:
2107
2107
  entities, motion, shared state, rounds, seats and their tokens, pending events, timers and
2108
2108
  inputs, RNG state, tick, epoch, state hash and build hash. Saves larger than 2 MB use several
2109
- rows in that transaction. The default interval is `room.durability.movementSeconds: 1`;
2109
+ rows in that transaction. The default interval is `room.durability.movementSeconds: 1`
2110
+ (30 for a `lifetime: "persistent"` world, which writes its `durable` fields to a journal the moment they change and
2111
+ saves the rest on this interval);
2110
2112
  round end, `world.finish()` and pause save immediately. Both elapsed ticks and elapsed wall
2111
2113
  time bound the interval, including ticks that catch up after a delay. Cloudflare's output
2112
2114
  gate holds outgoing frames until preceding writes are confirmed.