@homie-rocks/studio 0.55.0 → 0.56.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 (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/bin/homie-studio.mjs +1 -1
  4. package/docs/custom-routes.md +72 -0
  5. package/guides/app/SKILL.md +7 -0
  6. package/guides/playtest/scripts/playtest.mjs +7 -5
  7. package/lib/check.mjs +8 -1
  8. package/lib/cloudflare.mjs +49 -22
  9. package/lib/dev.mjs +6 -8
  10. package/lib/local-database-start.mjs +6 -0
  11. package/lib/mcp-tools.mjs +3 -3
  12. package/lib/port-check.mjs +3 -2
  13. package/lib/scaffold.mjs +2 -0
  14. package/lib/template-history.json +46 -1
  15. package/lib/worker-build.mjs +4 -1
  16. package/lib/worker-config.mjs +7 -0
  17. package/netplay/NETPLAY.md +5 -1
  18. package/netplay/netplay.ts +24 -9
  19. package/package.json +1 -1
  20. package/rules/core.ts +9 -0
  21. package/rules/host.ts +40 -11
  22. package/rules/view.ts +1 -0
  23. package/tools/TOOLS.md +6 -1
  24. package/worker/background-queue.mjs +1 -0
  25. package/worker/build-id.mjs +1 -1
  26. package/worker/custom-routes.mjs +16 -0
  27. package/worker/gate.mjs +94 -23
  28. package/worker/idle-chat.mjs +56 -0
  29. package/worker/index.mjs +2 -0
  30. package/worker/lazy-objects.mjs +3 -0
  31. package/worker/lobby-peak.mjs +44 -0
  32. package/worker/lobby-shards.mjs +43 -0
  33. package/worker/lobby-store.mjs +35 -0
  34. package/worker/lobby-summary.mjs +28 -0
  35. package/worker/lobby.mjs +86 -18
  36. package/worker/lounge-store.mjs +2 -2
  37. package/worker/match-router.mjs +74 -0
  38. package/worker/mcp-room.mjs +2 -1
  39. package/worker/mcp-tools.mjs +4 -2
  40. package/worker/office.mjs +2 -1
  41. package/worker/operational-schema.mjs +2 -0
  42. package/worker/pages.mjs +1 -1
  43. package/worker/relay-channel.mjs +119 -0
  44. package/worker/request-db.mjs +4 -3
  45. package/worker/room-chat.mjs +2 -1
  46. package/worker/room-sessions.mjs +65 -0
  47. package/worker/room.mjs +16 -9
  48. package/worker/route-auth.mjs +22 -0
  49. package/worker/routes.mjs +7 -4
  50. package/worker/runtime-support.mjs +5 -3
  51. package/worker/session-codec.mjs +24 -0
  52. package/worker/stats.mjs +2 -1
  53. package/worker/table.mjs +277 -41
  54. package/worker/version.mjs +1 -1
  55. package/worker/watch-sockets.mjs +75 -0
@@ -170,7 +170,7 @@ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'of
170
170
  export interface LinkChange {
171
171
  state: LinkState;
172
172
  prev: LinkState;
173
- /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
173
+ /** `subscribe` is the current state delivered immediately on subscription (prev equals state). Otherwise, why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
174
174
  why: string;
175
175
  /** This browser is running the rules right now (alone, or as the room's host): two browsers can both say so while one is cut off. */
176
176
  hosting: boolean;
@@ -749,7 +749,7 @@ export interface NetHandlers<S, A, C> {
749
749
  unchat: (e: { ids: string[] }) => void;
750
750
  /** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
751
751
  held: (h: ChatHeld) => void;
752
- /** Revision 9: where this browser stands with its room changed (online, reconnecting, alone, closed). */
752
+ /** Current link on subscription (why: subscribe), then changes (online, reconnecting, alone, closed). */
753
753
  link: (e: LinkChange) => void;
754
754
  /** Revision 9: a newer build of the game is live; this tab should reload (section 23). */
755
755
  stale: (e: StaleNotice) => void;
@@ -797,7 +797,7 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
797
797
  * THE LINK (revision 9, NETPLAY.md section 22). `offline` is true when this browser is NOT in a room: there is no
798
798
  * shell at all, or the room never answered and it plays alone (`link === 'alone'`). `connected` is true only while
799
799
  * the socket is up and welcomed. Between the two (`!offline && !connected`) the browser was in a room and is
800
- * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` when it changes.
800
+ * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` immediately reports the current state, then each change.
801
801
  */
802
802
  readonly link: LinkState;
803
803
  /** Times the helper has scheduled another knock at the relay (the same number as `stats().reconnects`). 0: never interrupted. */
@@ -1713,6 +1713,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1713
1713
  const peers = new Map<string, Peer>();
1714
1714
  let heldPeers: Peer[] = [];
1715
1715
  let roundInfo: RoundInfo | null = null;
1716
+ let idlePing = false, lastClockPing = -Infinity;
1716
1717
  let slots: Slot[] | null = null;
1717
1718
  let lastCkpt: Checkpoint<C> | null = null;
1718
1719
 
@@ -2352,12 +2353,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2352
2353
 
2353
2354
  // ---------------------------------------------------------------- wire in
2354
2355
  function onMessage(data: unknown): void {
2355
- lastMsgAt = wall();
2356
+ if (data === 'homie-pong' && idlePing) { lastMsgAt = wall(); return; }
2356
2357
  const text = typeof data === 'string' ? data : String(data);
2357
2358
  countBytes(bytesIn, text.length);
2358
2359
  let m: Record<string, unknown>;
2359
2360
  try { m = JSON.parse(text) as Record<string, unknown>; } catch { return; }
2360
2361
  if (!m || typeof m['t'] !== 'string') return;
2362
+ lastMsgAt = wall();
2361
2363
  switch (m['t']) {
2362
2364
  case 'welcome': return onWelcome(m);
2363
2365
  case 'snap': return onSnap(m, text.length);
@@ -2583,6 +2585,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2583
2585
  }
2584
2586
 
2585
2587
  function onWelcome(m: Record<string, unknown>): void {
2588
+ idlePing = m.idlePing === true; lastClockPing = -Infinity;
2586
2589
  post?.({ what: 'build', ver: version });
2587
2590
  clearTimeout(connectTimer);
2588
2591
  const next = m['role'] as Role;
@@ -2810,7 +2813,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2810
2813
  const st = Number(m['st']);
2811
2814
  const sent = pingsOut.get(c);
2812
2815
  if (sent === undefined || !Number.isFinite(st)) return;
2813
- pingsOut.delete(c);
2816
+ for (const key of pingsOut.keys()) if (key <= c) pingsOut.delete(key);
2814
2817
  const recv = Date.now();
2815
2818
  const r = recv - sent;
2816
2819
  const off = st + r / 2 - recv;
@@ -2869,7 +2872,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2869
2872
  }
2870
2873
 
2871
2874
  function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
2872
- function ping(): void {
2875
+ function ping(force = false): void {
2873
2876
  // This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
2874
2877
  const beat = wall();
2875
2878
  const pingHeld = beat - lastPingTick > 2000 + 1500;
@@ -2885,12 +2888,22 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2885
2888
  // the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
2886
2889
  // could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
2887
2890
  // Dropping a healthy socket there turned every long hitch into a reconnect.
2888
- if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) {
2891
+ const oldestPing = pingsOut.values().next().value;
2892
+ if (ws && oldestPing !== undefined && Date.now() - oldestPing >= 30_000 && !pingHeld) { lost(ws, 'stale'); return; }
2893
+ const silence = idlePing && (roundInfo?.phase !== 'live' || wall() - lastClockPing > staleMs) ? Math.max(staleMs, 60_000) : staleMs;
2894
+ if (ws && lastMsgAt && wall() - lastMsgAt > silence) {
2889
2895
  if (!pingHeld) { lost(ws, 'stale'); return; }
2890
2896
  // Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
2891
2897
  // still dropped, two beats from now.
2892
2898
  lastMsgAt = wall() - staleMs + 2500;
2893
2899
  }
2900
+ // An idle hibernatable room can answer this at the edge. Keep a regular
2901
+ // clock/visibility sample on visibility changes, and every heartbeat during play.
2902
+ if (idlePing && roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt) && !force) {
2903
+ try { ws?.send('homie-ping'); } catch { if(ws)lost(ws,'send'); }
2904
+ return;
2905
+ }
2906
+ lastClockPing = wall();
2894
2907
  const c = Date.now();
2895
2908
  pingsOut.set(c, c);
2896
2909
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
@@ -3026,6 +3039,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3026
3039
  ws = sock; socketStartedAt = wall();
3027
3040
  sock.onopen = () => {
3028
3041
  lastMsgAt = wall();
3042
+ pingsOut.clear();
3029
3043
  raw({
3030
3044
  t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
3031
3045
  ...(rulesGame ? { coast: 1 } : {}),
@@ -3101,8 +3115,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3101
3115
  if (hidden()) {
3102
3116
  // A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
3103
3117
  if (role === 'host' && peers.size > 1) yieldHost();
3104
- ping();
3105
- } else ping();
3118
+ ping(true);
3119
+ } else ping(true);
3106
3120
  });
3107
3121
  g.addEventListener?.('online', () => { if (!closed && !connected && rulesGame) { if (ws) lost(ws, 'online'); if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = null; attempt = 0; open(); } });
3108
3122
  g.addEventListener?.('pagehide', () => { if (role === 'host') { sendCheckpoint(); rulesSender?.leave(); } raw({ t: 'bye' }); });
@@ -3353,6 +3367,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3353
3367
  on(kind, fn) {
3354
3368
  (handlers[kind] as Set<unknown>).add(fn);
3355
3369
  if (kind === 'view') markFollows();
3370
+ if (kind === 'link') try { (fn as NetHandlers<S, A, C>['link'])({state:link,prev:link,why:'subscribe',hosting:roleKnown && role === 'host',downMs:connected || !downSince ? 0 : Math.round(wall() - downSince)}); } catch { /* A failed UI listener must still be removable. */ }
3356
3371
  return () => { (handlers[kind] as Set<unknown>).delete(fn); };
3357
3372
  },
3358
3373
  now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
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/rules/core.ts CHANGED
@@ -173,6 +173,8 @@ export interface Core {
173
173
  shared(): unknown[];
174
174
  /** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
175
175
  round(): Extract<CoreOut, { t: 'round' }>;
176
+ /** End a settled wall-clock break without simulating its idle ticks. */
177
+ resumeRound(): boolean;
176
178
  bodies(): Body[];
177
179
  /** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
178
180
  listeners(): { seat: number; id: string; pos: Vec3 }[];
@@ -1537,6 +1539,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1537
1539
  }
1538
1540
  return {
1539
1541
  round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1542
+ resumeRound() {
1543
+ if (round.phase !== 'over' || overAt) return false;
1544
+ // Preserve queued events and their simulation-time deadlines. Nothing
1545
+ // moves during the wall-clock break; the next step delivers them.
1546
+ if (playing) startRound(); else restartAt = tick;
1547
+ return true;
1548
+ },
1540
1549
  get tick() { return tick; },
1541
1550
  reviseMovement,
1542
1551
  get epoch() { return epoch; },
package/rules/host.ts CHANGED
@@ -87,6 +87,8 @@ export interface HostOptions {
87
87
  restoreEpoch?: number;
88
88
  startDelayMs?: number;
89
89
  onTick?(): void;
90
+ /** Suspend settled round breaks; the owner persists and wakes this deadline. */
91
+ onRoundIdle?(until: number): void;
90
92
  /** Only the shared server isolate can attribute timer lateness to another tick. */
91
93
  enforceOverrun?: boolean;
92
94
  log?(line: Record<string, unknown>): void;
@@ -111,6 +113,8 @@ export interface Host {
111
113
  readonly epoch: number;
112
114
  readonly running: boolean;
113
115
  readonly paused: boolean;
116
+ readonly roundIdleUntil: number | null;
117
+ wakeRound(): void;
114
118
  readonly people: number;
115
119
  readonly viewRadiusM: number | null;
116
120
  readonly privateDelivery: boolean;
@@ -199,13 +203,14 @@ export function createHost(o: HostOptions): Host {
199
203
  // A log that throws takes nothing down with it.
200
204
  const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
201
205
  const random = o.random ?? Math.random;
202
- const saved = o.restore ? fromBytes(o.restore) as { v: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
206
+ const saved = o.restore ? fromBytes(o.restore) as { v: number; roundIdleUntil?: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
203
207
  if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
204
208
  if (saved) {
205
209
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
206
210
  const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
207
- check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
211
+ check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil'].includes(k)));
208
212
  check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
213
+ if (saved.roundIdleUntil !== undefined) check(Number.isFinite(saved.roundIdleUntil) && saved.roundIdleUntil >= 0 && saved.core.round[1] === 0 && !saved.core.overAt);
209
214
  if (saved.characters !== undefined) {
210
215
  check(c.settings.characters && Array.isArray(saved.characters));
211
216
  check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
@@ -396,6 +401,7 @@ export function createHost(o: HostOptions): Host {
396
401
  core.goal(b.seat, g?.state === 'active' ? { goal: g.goal, args: g.args, from: g.from, at: Math.round(g.at / period), asked: g.asked === true } : null);
397
402
  }
398
403
  }
404
+ let roundIdleUntil: number | null = o.onRoundIdle && Number.isFinite(saved?.roundIdleUntil) ? saved!.roundIdleUntil! : null;
399
405
  let running = false;
400
406
  let paused = Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
401
407
  let ended = false;
@@ -594,7 +600,7 @@ export function createHost(o: HostOptions): Host {
594
600
  core.seatLeave(held.seat);
595
601
  tick(o.clock.now());
596
602
  } else core.seatAway(m.seat as number, true);
597
- if (running && people() === 0) pause();
603
+ if ((running || roundIdleUntil !== null) && people() === 0) pause();
598
604
  return;
599
605
  }
600
606
  case 'free': {
@@ -832,7 +838,17 @@ export function createHost(o: HostOptions): Host {
832
838
  }
833
839
  timing.record('tick', tickAt);
834
840
  if (ended) return;
835
- if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
841
+ if (!faulted && core.stats.ticksCut === cut) {
842
+ failingSince = null;
843
+ const round = core.round();
844
+ if (o.onRoundIdle && running && round.phase === 'over' && round.results && round.endsAt > core.tick) {
845
+ roundIdleUntil = now + (round.endsAt - core.tick) * period;
846
+ running = false; disarm();
847
+ try { o.store?.save(save()); o.onRoundIdle(roundIdleUntil); }
848
+ catch (error) { fault('round-idle', error); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); }
849
+ }
850
+ return;
851
+ }
836
852
  if (failingSince === null) failingSince = now;
837
853
  if (now - failingSince < FAIL_MS) return;
838
854
  if (faulted) end('fault', { error: stats.lastFault, faults: stats.faults });
@@ -913,7 +929,7 @@ export function createHost(o: HostOptions): Host {
913
929
  const self = { blame: (): void => { blamed = true; } };
914
930
 
915
931
  function start(): void {
916
- if (running || ended) return;
932
+ if (running || ended || roundIdleUntil !== null) return;
917
933
  running = true; paused = false;
918
934
  try {
919
935
  base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
@@ -926,26 +942,37 @@ export function createHost(o: HostOptions): Host {
926
942
  } catch (error) { fault('start', error); } finally { keepTime(); }
927
943
  }
928
944
  function pause(): void {
929
- if (!running) return;
945
+ if (!running) {
946
+ if (roundIdleUntil !== null && !paused) { paused = true; checkpoint(true); o.onPause?.(); }
947
+ return;
948
+ }
930
949
  running = false; paused = true;
931
950
  // A pending timer would keep the object in memory and billed: the pause clears it.
932
951
  disarm();
933
952
  log({ ev: 'host-pause', game: o.game, tick: core.tick });
934
953
  try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
935
954
  }
936
- function resume(): void {
937
- if (running || ended || !paused) return;
955
+ function resume(announceRound = true): void {
956
+ if (running || ended || !paused || roundIdleUntil !== null) return;
938
957
  running = true; paused = false;
939
958
  try {
940
959
  // The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
941
960
  base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
942
961
  overSince = null; blamed = false; failingSince = null; lastStart = base.ms;
943
- handle([core.round()], base.ms);
962
+ if (announceRound) handle([core.round()], base.ms);
944
963
  for (const q of queues.values()) q.lastFrameTick = core.tick;
945
964
  log({ ev: 'host-resume', game: o.game, tick: core.tick });
946
965
  o.onResume?.();
947
966
  } catch (error) { fault('resume', error); } finally { keepTime(); }
948
967
  }
968
+ function wakeRound(): void {
969
+ if (roundIdleUntil === null || o.clock.now() < roundIdleUntil || ended) return;
970
+ if (!core.resumeRound()) throw new Error('Round is not settled');
971
+ roundIdleUntil = null;
972
+ handle(core.drain(), o.clock.now());
973
+ if (people() > 0) { paused = true; resume(false); }
974
+ else { paused = true; checkpoint(true); }
975
+ }
949
976
  /** What a relay keeps for whoever joins next, said again: the round with its results, the roster, the shared state, the capabilities. */
950
977
  function announce(): void {
951
978
  if (ended) return;
@@ -975,7 +1002,7 @@ export function createHost(o: HostOptions): Host {
975
1002
 
976
1003
  function save(): Uint8Array {
977
1004
  const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
978
- return toBytes({ v: 1, ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
1005
+ return toBytes({ v: 1, ...(roundIdleUntil !== null ? { roundIdleUntil } : {}), ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
979
1006
  }
980
1007
  function checkpoint(force = false): void {
981
1008
  if (persistent) {
@@ -1022,7 +1049,7 @@ export function createHost(o: HostOptions): Host {
1022
1049
  held.character = core.character(held.seat) ?? held.character; held.returning = true;
1023
1050
  present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
1024
1051
  if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
1025
- if (running && people() === 0) pause();
1052
+ if ((running || roundIdleUntil !== null) && people() === 0) pause();
1026
1053
  },
1027
1054
  characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
1028
1055
  get viewSettings() { return c.settings.view; },
@@ -1032,6 +1059,8 @@ export function createHost(o: HostOptions): Host {
1032
1059
  get epoch() { return epoch; },
1033
1060
  get running() { return running; },
1034
1061
  get paused() { return paused; },
1062
+ get roundIdleUntil() { return roundIdleUntil; },
1063
+ wakeRound,
1035
1064
  get people() { return people(); },
1036
1065
  frame, start, pause, resume, announce,
1037
1066
  // An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
package/rules/view.ts CHANGED
@@ -941,6 +941,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
941
941
  net.on('snapshot', onSnapshot);
942
942
  net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
943
943
  net.on('link', (e) => {
944
+ if (e.why === 'subscribe') return;
944
945
  if (e.state === 'online') { returningUntil = clock() + 3000; base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
945
946
  });
946
947
  net.on('round', (r) => { if (r.results) { results = r.results; if (round) { round = { ...round, results }; emit('round', roundNow()); } } });
package/tools/TOOLS.md CHANGED
@@ -53,7 +53,12 @@ Workers AI requires the studio's own binding and owner authority, or an explicit
53
53
  never exposes the environment or raw credentials. A named secret capability in
54
54
  `studio.json` (`mcp.secrets.NAME: { origin, header, prefix, tools }`) lets an owner tool
55
55
  make an HTTPS request to one declared origin; `tools` can explicitly delegate this capability to named staff tools; redirects and credential echoes are
56
- refused. The studio’s AI can generate an integration secret and place it in Cloudflare and the sender through their authorized APIs. Never ask the person to find or paste a key, and never put a secret in source files.
56
+ refused. Use `origin: 'self'` for the studio's own API: the capability resolves to
57
+ the current Worker request origin (or the configured studio origin for scheduled
58
+ functions), so a workers.dev address need not be known at build time. Calls may
59
+ use a relative URL such as `ctx.fetchWithSecret('API_TOKEN', '/api/sync')`.
60
+ HTTPS is still required; credentials in URLs and cross-origin targets are refused.
61
+ The studio’s AI can generate an integration secret and place it in Cloudflare and the sender through their authorized APIs. Never ask the person to find or paste a key, and never put a secret in source files.
57
62
 
58
63
  Studio handlers and imported parts are trusted Worker code, not sandboxed plugins.
59
64
  Review shared code before adding it: JavaScript wrappers cannot confine malicious
@@ -22,6 +22,7 @@ export async function consumeBackground(batch, env) {
22
22
  }
23
23
  for(const message of batch.messages)if(!changes.includes(message)) {
24
24
  try {
25
+ if(message.body?.v===1&&message.body.type==='lobby.snapshot'){await (await import('./lobby-summary.mjs')).storeLobbySummary(env,message.body);message.ack();continue;}
25
26
  if(message.body?.v!==1||message.body.type!=='stripe.event')throw new Error('Unknown background message type');
26
27
  await (await import('./stripe-intake.mjs')).reconcileStripeEvent(env,message.body.id,await catalogue(env));
27
28
  message.ack();
@@ -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.55.0","sha256":"597d65592bde7e9519b8184bc2db2502a88ef37cfc31dd6ba3b1c9a4ece30132"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.56.0","sha256":"9b000afecb22ce5781d6499a02f44ffcc255c61907e9f7d469dd63d0bd9f35d1"});
@@ -0,0 +1,16 @@
1
+ /** Studio configuration only: register once at module evaluation, never per request. */
2
+ let handler = null;
3
+ export function useRoutes(routes) {
4
+ if (routes !== null && typeof routes !== 'function') throw new TypeError('useRoutes expects a function or null');
5
+ handler = routes;
6
+ }
7
+ export async function customRoute(request, env, ctx) {
8
+ const response = await handler?.(request, env, ctx);
9
+ if (response == null) return null;
10
+ if (!(response instanceof Response)) throw new TypeError('A custom route must return a Response, null, or undefined');
11
+ if (response.status === 101 || response.webSocket) return response;
12
+ const headers = new Headers(response.headers);
13
+ headers.set('x-content-type-options', 'nosniff');
14
+ if (!headers.has('referrer-policy')) headers.set('referrer-policy', 'strict-origin-when-cross-origin');
15
+ return new Response(response.body, {status:response.status,statusText:response.statusText,headers});
16
+ }