@homie-rocks/studio 0.54.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 (104) hide show
  1. package/CHANGELOG.md +66 -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/background-queue.mjs +31 -0
  8. package/lib/build.mjs +4 -1
  9. package/lib/check.mjs +8 -1
  10. package/lib/cloudflare.mjs +65 -23
  11. package/lib/dev.mjs +6 -8
  12. package/lib/local-database-start.mjs +6 -0
  13. package/lib/mcp-tools.mjs +3 -3
  14. package/lib/port-check.mjs +3 -2
  15. package/lib/read-replication.mjs +26 -0
  16. package/lib/scaffold.mjs +30 -10
  17. package/lib/shop.mjs +7 -0
  18. package/lib/static-assets.mjs +27 -0
  19. package/lib/template-history.json +87 -0
  20. package/lib/worker-build.mjs +16 -4
  21. package/lib/worker-config.mjs +10 -1
  22. package/netplay/NETPLAY.md +5 -1
  23. package/netplay/netplay.ts +24 -9
  24. package/package.json +1 -1
  25. package/rules/core.ts +9 -0
  26. package/rules/host.ts +40 -11
  27. package/rules/view.ts +1 -0
  28. package/tools/TOOLS.md +6 -1
  29. package/worker/analytics.mjs +84 -0
  30. package/worker/app-frame.mjs +1 -1
  31. package/worker/app-graphics.mjs +1 -1
  32. package/worker/app-streams.mjs +3 -3
  33. package/worker/asset-paths.mjs +2 -0
  34. package/worker/background-queue.mjs +31 -0
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/built-metadata.mjs +2 -0
  37. package/worker/catalogue.mjs +18 -0
  38. package/worker/chat-page.mjs +1 -1
  39. package/worker/custom-routes.mjs +16 -0
  40. package/worker/embed.mjs +1 -1
  41. package/worker/event-resources.mjs +23 -11
  42. package/worker/function-registry.mjs +4 -0
  43. package/worker/functions.mjs +7 -5
  44. package/worker/gate-routing.mjs +61 -0
  45. package/worker/gate.mjs +106 -91
  46. package/worker/hosted-registry.mjs +11 -0
  47. package/worker/hosted.mjs +3 -17
  48. package/worker/idle-chat.mjs +56 -0
  49. package/worker/index.mjs +56 -2785
  50. package/worker/lazy-objects.mjs +43 -0
  51. package/worker/lobby-limits.mjs +2 -0
  52. package/worker/lobby-peak.mjs +44 -0
  53. package/worker/lobby-shards.mjs +43 -0
  54. package/worker/lobby-store.mjs +35 -0
  55. package/worker/lobby-summary.mjs +28 -0
  56. package/worker/lobby.mjs +319 -0
  57. package/worker/lounge-store.mjs +2 -2
  58. package/worker/lounge.mjs +3 -3
  59. package/worker/match-router.mjs +74 -0
  60. package/worker/mcp-event-streams.mjs +3 -3
  61. package/worker/mcp-events.mjs +4 -4
  62. package/worker/mcp-room.mjs +2 -1
  63. package/worker/mcp-store.mjs +12 -1
  64. package/worker/mcp-tools.mjs +4 -2
  65. package/worker/mcp.mjs +5 -1
  66. package/worker/office.mjs +18 -21
  67. package/worker/operational-schema.mjs +16 -0
  68. package/worker/page-policy.mjs +29 -0
  69. package/worker/pages.mjs +9 -29
  70. package/worker/parts.mjs +2 -1
  71. package/worker/payment-recovery.mjs +33 -19
  72. package/worker/player-registry.mjs +9 -0
  73. package/worker/players.mjs +8 -7
  74. package/worker/purchase-machine.mjs +5 -3
  75. package/worker/reconcile-guard.mjs +22 -0
  76. package/worker/relay-channel.mjs +119 -0
  77. package/worker/request-db.mjs +36 -10
  78. package/worker/responses.mjs +18 -0
  79. package/worker/room-chat.mjs +217 -0
  80. package/worker/room-controls.mjs +296 -0
  81. package/worker/room-lifecycle.mjs +10 -0
  82. package/worker/room-protocol.mjs +197 -0
  83. package/worker/room-sessions.mjs +65 -0
  84. package/worker/room.mjs +54 -740
  85. package/worker/rooms-cache.mjs +19 -0
  86. package/worker/route-auth.mjs +22 -0
  87. package/worker/routes.mjs +670 -0
  88. package/worker/runtime-support.mjs +447 -0
  89. package/worker/seats.mjs +1 -1
  90. package/worker/selling/index.mjs +1 -0
  91. package/worker/selling/runtime.mjs +7 -5
  92. package/worker/service-run.mjs +1 -1
  93. package/worker/session-codec.mjs +24 -0
  94. package/worker/shop-links.mjs +1 -1
  95. package/worker/shop-page.mjs +1 -0
  96. package/worker/shop.mjs +27 -20
  97. package/worker/stats.mjs +9 -7
  98. package/worker/stream-objects.mjs +18 -0
  99. package/worker/stripe-intake.mjs +79 -0
  100. package/worker/stripe.mjs +3 -0
  101. package/worker/table.mjs +1448 -0
  102. package/worker/tool-registry.mjs +10 -0
  103. package/worker/version.mjs +1 -1
  104. package/worker/watch-sockets.mjs +75 -0
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
@@ -0,0 +1,84 @@
1
+ /** Page counters are sampled analytics, never billing or gameplay state.
2
+ * D1 retains historical totals. Each rollup adds only the delta from the last
3
+ * observed total, in the same transaction as its watermark, so retries are safe.
4
+ */
5
+ export const PAGE_METRICS = new Set(['visit', 'play', 'screen', 'watch']);
6
+ export function writePageMetric(env, row) {
7
+ if (!env.STUDIO_ANALYTICS || !env.STUDIO_ANALYTICS_DATASET || !PAGE_METRICS.has(row.metric)) return false;
8
+ const day = row.day ?? new Date().toISOString().slice(0, 10);
9
+ try { env.STUDIO_ANALYTICS.writeDataPoint({
10
+ indexes: [String(row.subject ?? '').slice(0, 80) || 'studio'],
11
+ blobs: [day, row.metric, String(row.subject ?? '').slice(0, 80), String(row.source ?? '').slice(0, 80)],
12
+ doubles: [Math.max(0, Math.floor(Number(row.n ?? 1) || 0))],
13
+ }); } catch (error) {
14
+ // Analytics is sampled telemetry. A binding failure must not fail a page or
15
+ // amplify the outage into a D1 write on every visit.
16
+ console.warn('Studio analytics point dropped:', error.message);
17
+ }
18
+ return true;
19
+ }
20
+ async function* analyticsPages(env,{now=Date.now(),pageSize=1000}={}) {
21
+ if (!env.ANALYTICS_SQL || !env.STUDIO_ANALYTICS_DATASET) return;
22
+ const dataset=String(env.STUDIO_ANALYTICS_DATASET);
23
+ if(!/^[a-z0-9_-]+$/.test(dataset))throw new Error('Invalid studio analytics dataset');
24
+ // Revisit each UTC day for late ingestion and sample corrections. Ordered
25
+ // pages avoid the aggregate 50k result ceiling, including a busy single day.
26
+ for(let ago=7;ago>=0;ago--) {
27
+ const day=new Date(now-ago*86400000).toISOString().slice(0,10);
28
+ for(let offset=0;;offset+=pageSize) {
29
+ const result=await env.ANALYTICS_SQL.query({
30
+ query:`SELECT blob1 AS day, blob2 AS metric, blob3 AS subject, blob4 AS source, SUM(_sample_interval * double1) AS n
31
+ FROM events.analyticsEngine."${dataset}" WHERE timestamp >= $start AND blob1 = $day
32
+ GROUP BY blob1, blob2, blob3, blob4 ORDER BY blob1, blob2, blob3, blob4 LIMIT ${pageSize} OFFSET $offset`,
33
+ params:{start:day+'T00:00:00Z',day,offset},
34
+ });
35
+ if(!Array.isArray(result.data))throw new Error('Incomplete studio analytics rollup');
36
+ yield result.data.filter(r=>/^\d{4}-\d{2}-\d{2}$/.test(r.day)&&PAGE_METRICS.has(r.metric)&&Number.isFinite(Number(r.n)));
37
+ if(result.data.length<pageSize)break;
38
+ }
39
+ }
40
+ }
41
+ export async function analyticsTotals(env,now=Date.now()) {
42
+ const rows=[];for await(const page of analyticsPages(env,{now}))rows.push(...page);return rows;
43
+ }
44
+ export async function analyticsStatus(env,now=Date.now()) {
45
+ const rows=(await env.DB.prepare("SELECT key,value FROM meta WHERE key IN ('analytics-rollup-error','analytics-rollup-success')").all()).results??[];
46
+ const values=Object.fromEntries(rows.map(r=>[r.key,r.value]));
47
+ const error=values['analytics-rollup-error'];
48
+ return {lastSuccess:Number(values['analytics-rollup-success'])||null,delayed:Boolean(error)||now-Number(values['analytics-rollup-success']??0)>7200000,error:error||null};
49
+ }
50
+ async function health(env,key,value) {
51
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE value!=excluded.value').bind(key,String(value)).run();
52
+ }
53
+ export async function rollupAnalytics(env,options={}) {
54
+ if(!env.DB||!env.ANALYTICS_SQL)return;
55
+ try {
56
+ for await(const rows of analyticsPages(env,options)) {
57
+ for(let offset=0;offset<rows.length;offset+=40) {
58
+ const statements=rows.slice(offset,offset+40).flatMap(r=>{
59
+ const args=[r.day,r.metric,r.subject,r.source,Math.round(Number(r.n))];
60
+ return [
61
+ env.DB.prepare(`INSERT INTO stats_daily(day,metric,subject,source,n)
62
+ SELECT ?1,?2,?3,?4,?5-COALESCE((SELECT n FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4),0)
63
+ WHERE NOT EXISTS(SELECT 1 FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4 AND n=?5)
64
+ ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=stats_daily.n+excluded.n`).bind(...args),
65
+ env.DB.prepare(`INSERT INTO stats_analytics_daily(day,metric,subject,source,n) VALUES(?1,?2,?3,?4,?5)
66
+ ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=excluded.n WHERE n!=excluded.n`).bind(...args),
67
+ ];
68
+ });
69
+ if(statements.length)await env.DB.batch(statements);
70
+ }
71
+ }
72
+ await health(env,'analytics-rollup-error','');
73
+ await health(env,'analytics-rollup-success',options.now??Date.now());
74
+ return {delayed:false};
75
+ } catch(error) {
76
+ console.error('Studio analytics counts delayed:',error.message);
77
+ await health(env,'analytics-rollup-error',String(error.message).slice(0,500));
78
+ return {delayed:true,error:error.message};
79
+ }
80
+ }
81
+
82
+ export const ANALYTICS_MIGRATION_FILE="0037_owner_analytics.sql";
83
+ export const ANALYTICS_MIGRATION=`CREATE TABLE IF NOT EXISTS stats_analytics_daily(day TEXT NOT NULL,metric TEXT NOT NULL,subject TEXT NOT NULL,source TEXT NOT NULL,n INTEGER NOT NULL,PRIMARY KEY(day,metric,subject,source)) WITHOUT ROWID;
84
+ `;
@@ -1,7 +1,7 @@
1
1
  /** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
2
2
  export const APP_STORAGE_JS=String.raw`(()=>{
3
3
  for(const name of ['localStorage','sessionStorage']) {
4
- try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
4
+ try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{/* A blocked storage probe falls through to the in-memory shim. */}
5
5
  const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
6
6
  const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
7
7
  Object.defineProperty(window,name,{value:storage,configurable:true});
@@ -1,6 +1,6 @@
1
1
  export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
2
2
  if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
3
- try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
3
+ try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{/* Context creation can throw when graphics are unavailable; show the fallback. */}
4
4
  const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
5
5
  p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
6
6
  const canvas=document.querySelector('canvas');
@@ -10,11 +10,11 @@ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';retu
10
10
  END;`;}).join('\n')}
11
11
  `;
12
12
  const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
13
- export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.idFromName('@app-records/'+app));
13
+ export const appHub=(env,app)=>{const ns=env.APP_RECORDS??env.LOBBY;return ns.get(ns.idFromName('@app-records/'+app));};
14
14
  const pushedRevisions=new WeakMap();
15
15
  export async function pushAppRecords(env,cat){
16
16
  const apps=(cat.games??[]).filter(g=>g.kind==='app');
17
- if(!env.LOBBY||!env.DB||!apps.length)return;
17
+ if(!(env.APP_RECORDS??env.LOBBY)||!env.DB||!apps.length)return;
18
18
  const rows=(await env.DB.prepare(`SELECT app,collection,revision FROM app_record_revisions
19
19
  UNION ALL SELECT a.app,'*',SUM(COALESCE(r.revision,0)) FROM app_tool_namespaces a LEFT JOIN tool_data_revisions r ON r.namespace=a.namespace GROUP BY a.app`).all()).results;
20
20
  const signatures=new Map();for(const row of rows){const app=row.app.split(':check:')[0];signatures.set(app,(signatures.get(app)??'')+JSON.stringify(row));}
@@ -32,7 +32,7 @@ export async function pushAppRecords(env,cat){
32
32
  export class AppRecordStreams {
33
33
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
34
34
  dispose(){for(const s of [...this.streams])this.close(s);}
35
- close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{}this.presence();}
35
+ close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.presence();}
36
36
  count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
37
37
  send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
38
38
  presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
@@ -0,0 +1,2 @@
1
+ export const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
2
+ export const PART_FILES = /^\/parts\/[a-z0-9][a-z0-9-]{0,47}\/\d{1,6}\.\d{1,6}\.\d{1,6}[^/]*\//;
@@ -0,0 +1,31 @@
1
+ import {catalogue} from './catalogue.mjs';
2
+ /** Durable rows remain the source of truth. Messages carry wakeups, never records. */
3
+ export async function enqueueChanges(env, origin) {
4
+ if(env.BACKGROUND) {
5
+ try {return await env.BACKGROUND.send({v:1,type:'studio.changed',origin});}
6
+ catch(error) {console.warn('Background queue unavailable; delivering inline:',error.message);}
7
+ }
8
+ // Older wrappers and local unit fixtures without the binding keep their behavior.
9
+ return (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
10
+ }
11
+ export async function consumeBackground(batch, env) {
12
+ const changes=batch.messages.filter(m=>m.body?.v===1&&m.body.type==='studio.changed');
13
+ if(changes.length) {
14
+ try {
15
+ const origin=changes[0].body.origin;
16
+ await (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
17
+ for(const message of changes)message.ack();
18
+ } catch(error) {
19
+ console.error('Background delivery failed',error);
20
+ for(const message of changes)message.retry({delaySeconds:30});
21
+ }
22
+ }
23
+ for(const message of batch.messages)if(!changes.includes(message)) {
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;}
26
+ if(message.body?.v!==1||message.body.type!=='stripe.event')throw new Error('Unknown background message type');
27
+ await (await import('./stripe-intake.mjs')).reconcileStripeEvent(env,message.body.id,await catalogue(env));
28
+ message.ack();
29
+ } catch(error) {console.error('Background queue delivery failed',error);message.retry({delaySeconds:error.retryAfterSeconds??30});}
30
+ }
31
+ }
@@ -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.54.0","sha256":"4f65918ccb0f52120d8d0fa6cf761f2243b4636fdf10ccef395479318d603370"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.56.0","sha256":"9b000afecb22ce5781d6499a02f44ffcc255c61907e9f7d469dd63d0bd9f35d1"});
@@ -0,0 +1,2 @@
1
+ // Replaced with this build’s immutable metadata by lib/worker-build.mjs.
2
+ export default null;
@@ -0,0 +1,18 @@
1
+ const cache = new WeakMap();
2
+ /** Cache completed immutable data only. Never share pending I/O across requests. */
3
+ export async function catalogue(env, origin = 'https://studio.invalid') {
4
+ const baked = (await import('./built-metadata.mjs')).default;
5
+ if (baked) return baked.catalogue;
6
+ const hit = cache.get(env.ASSETS);
7
+ if (hit?.origin === origin && env.HOMIE_PREVIEW !== '1' && env.HOMIE_DEV !== '1') return hit.value;
8
+ const response = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
9
+ if (!response.ok) return {studio: {name: env.STUDIO_NAME || 'Studio'}, games: []};
10
+ const tag=response.headers.get('etag');
11
+ if(tag&&hit?.origin===origin&&hit.tag===tag){await response.body?.cancel();return hit.value;}
12
+ const value = await response.json();
13
+ cache.set(env.ASSETS, {origin, value, tag});
14
+ return value;
15
+ }
16
+ export async function builtPosts() {
17
+ return (await import('./built-metadata.mjs')).default?.posts;
18
+ }
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { REACTIONS } from './chat.mjs';
23
23
  import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
24
- import { NET_PALETTE } from './room.mjs';
24
+ import { NET_PALETTE } from './room-protocol.mjs';
25
25
 
26
26
  /** Where room chat's strip of new lines may sit: a corner, or the middle of the top or bottom edge. */
27
27
  export const CHAT_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'];
@@ -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
+ }
package/worker/embed.mjs CHANGED
@@ -61,7 +61,7 @@ export function playerOrigins(value) {
61
61
  const origin = 'https://' + (wildcard ? '*.' : '') + u.host;
62
62
  if (!origins.includes(origin)) origins.push(origin);
63
63
  if (origins.length === 8) break;
64
- } catch {}
64
+ } catch { /* Invalid configured origins grant no framing permission. */ }
65
65
  }
66
66
  return origins;
67
67
  }
@@ -50,14 +50,14 @@ export async function subscribeFeed(env,cat,caller,uri,remove=false) {
50
50
  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();
51
51
  }
52
52
  }
53
- export function eventHub(env,connection) {return env.LOBBY.get(env.LOBBY.idFromName('@studio-events/'+connection));}
53
+ export function eventHub(env,connection) {const ns=env.EVENTS??env.LOBBY;return ns.get(ns.idFromName('@studio-events/'+connection));}
54
54
  export async function pushEvents(env,cat,origin) {
55
- if(!env.LOBBY||!Object.keys(eventFeeds(env)).length)return;
56
- // Page subscriptions, without a studio-wide subscription or delivery cap.
57
- await env.DB.prepare("DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-stream:%' AND expires<?1").bind(Date.now()).run();
55
+ if(!(env.EVENTS??env.LOBBY)||!Object.keys(eventFeeds(env)).length)return;
56
+ // Wake only active streams. Durable subscriptions without a connection replay
57
+ // their cursor when they reconnect; they never need an idle object wake.
58
58
  let after='';const seen=new Set();
59
59
  for(;;){
60
- const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key LIKE 'event-sub:%' OR key LIKE 'mcp-event-stream:%' OR key LIKE 'tools-stream:%') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
60
+ const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE ((key >= 'mcp-event-stream:' AND key < 'mcp-event-stream;') OR (key >= 'tools-stream:' AND key < 'tools-stream;')) AND key>?1 AND (expires IS NULL OR expires>?2) ORDER BY key LIMIT 100").bind(after,Math.floor(Date.now()/1000)).all()).results;
61
61
  for(const row of rows){const {connection}=JSON.parse(row.value);if(seen.has(connection))continue;seen.add(connection);
62
62
  const response=await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'wake',connection,cat,origin})});
63
63
  if(!response.ok)throw new Error('Event stream wake failed');
@@ -70,7 +70,7 @@ export async function pushEvents(env,cat,origin) {
70
70
  const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
71
71
  export class EventStreams {
72
72
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();this.extensions=new McpEventStreams(env,ctx);}
73
- close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
73
+ close(stream){stream.cancelled=true;clearTimeout(stream.reconnect);stream.resume?.();try{stream.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(error=>console.warn("MCP stream task failed:",error.message)));}}
74
74
  background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
75
75
  async fetch(request) {
76
76
  const input=await request.json();
@@ -82,7 +82,7 @@ export class EventStreams {
82
82
  if(this.stream)this.close(this.stream);
83
83
  await this.tail;
84
84
  const {connection,lastId}=input;
85
- const subscriptions=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+connection+':%').all()).results;
85
+ const subscriptions=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key >= ?1 AND key < ?2").bind('event-sub:'+connection+':','event-sub:'+connection+';').all()).results;
86
86
  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='';
87
87
  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];}
88
88
  const stream={...input,after,name,controller:null,cancelled:false,resume:null};
@@ -92,7 +92,9 @@ export class EventStreams {
92
92
  if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
93
93
  stream.toolSignature=await this.toolSignature(input.tools??[],caller);
94
94
  stream.eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
95
- await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
95
+ await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3) ON CONFLICT(key) DO UPDATE SET value=excluded.value,expires=excluded.expires').bind('tools-stream:'+connection,JSON.stringify({connection}),Math.floor(Date.now()/1000)+300).run();
96
+ stream.reconnect=setTimeout(()=>this.close(stream),240000);
97
+ stream.reconnect?.unref?.();
96
98
  // Replay and later wakes share one ordered pump. Backpressure must not
97
99
  // hold up the writer's request or a function handler.
98
100
  this.background(input);
@@ -100,7 +102,7 @@ export class EventStreams {
100
102
  }
101
103
  this.background(input);return new Response(null,{status:204});
102
104
  }
103
- wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(()=>{});return work;}
105
+ wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(error=>console.warn("MCP stream task failed:",error.message));return work;}
104
106
  async toolSignature(tools,caller) {const visible=[];for(const tool of tools)if(await allowed(tool.audience,caller,this.env))visible.push(tool);return JSON.stringify(visible);}
105
107
  async deliver(input) {
106
108
  const stream=this.stream;if(!stream)return;
@@ -113,7 +115,7 @@ export class EventStreams {
113
115
  const signature=await this.toolSignature(input.tools,caller);
114
116
  if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
115
117
  }
116
- const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+caller.connection+':%').all()).results;
118
+ const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key >= ?1 AND key < ?2").bind('event-sub:'+caller.connection+':','event-sub:'+caller.connection+';').all()).results;
117
119
  const subscriptions=[];
118
120
  for(const row of rows){const sub=JSON.parse(row.value);if(await readableFeed(this.env,input.cat,caller,sub.name))subscriptions.push(sub);}
119
121
  const through=(await this.env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
@@ -155,9 +157,19 @@ export async function pushToolList(env,cat,tools,origin) {
155
157
  await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
156
158
  let after='';
157
159
  for(;;){
158
- const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'tools-stream:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
160
+ const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key >= 'tools-stream:' AND key < 'tools-stream;') AND key>?1 AND (expires IS NULL OR expires>?2) ORDER BY key LIMIT 100").bind(after,Math.floor(Date.now()/1000)).all()).results;
159
161
  for(const row of rows){const connection=JSON.parse(row.value).connection;await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'tools',connection,cat,tools:publicShape,origin})});}
160
162
  if(rows.length<100)break;after=rows.at(-1).key;
161
163
  }
162
164
  toolCatalogues.set(binding,signature);
163
165
  }
166
+
167
+ /** Bounded expiry work belongs to cron, never to each mutation or page. */
168
+ export async function pruneMcpKeys(env) {
169
+ if(!env.DB)return;
170
+ try { await env.DB.prepare('DELETE FROM mcp_kv WHERE key IN (SELECT key FROM mcp_kv WHERE expires IS NOT NULL AND expires<?1 ORDER BY expires LIMIT 1000)').bind(Math.floor(Date.now()/1000)).run(); }
171
+ catch(error) {
172
+ // Older embedded shop workers have no MCP schema to prune.
173
+ if(!/no such table: mcp_kv/i.test(String(error?.message)))throw error;
174
+ }
175
+ }
@@ -0,0 +1,4 @@
1
+ // Studio declarations register synchronously; their implementation loads on demand.
2
+ let load=async()=>[];
3
+ export function useFunctions(loader) {load=loader;}
4
+ export const loadFunctions=()=>load();
@@ -6,8 +6,8 @@ import {feedSubscriptions,pushEvents} from './event-resources.mjs';
6
6
  import { services } from './mcp-tools.mjs';
7
7
  import { isOwner, sameOrigin } from './office.mjs';
8
8
  import { esc } from './site.mjs';
9
- let load = async () => [];
10
- export function useFunctions(loader) { load = loader; }
9
+ import {loadFunctions as load} from './function-registry.mjs';
10
+ export {useFunctions} from './function-registry.mjs';
11
11
  export async function hasFunctionType(type,env={}) { return [...await load(),...feedSubscriptions(env)].some(fn=>fn.event===type); }
12
12
  export async function syncFunctions(env, definitions = null, now = Date.now()) {
13
13
  definitions ??= await load();
@@ -52,8 +52,10 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
52
52
  if(typeof cat==='function')cat=await cat();
53
53
  definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions),env.STUDIO_EVENT_FEEDS?await eventHookSubscriptions(env,cat,{...webhookOptions,now}):[]);
54
54
  // An AI notification must not wait behind a slow function handler.
55
- const notify=()=>pushEvents(env,cat,origin).catch(error=>console.error('Event notification wake failed',error));
56
- const notifications=notify();
55
+ const notify=()=>pushEvents(env,cat,origin);
56
+ // A final pass retries an early failed wake. If that also fails the queue
57
+ // consumer retries the batch; it must never acknowledge a lost notification.
58
+ const notifications=notify().catch(error=>console.warn('Early event wake failed; retrying after functions',error));
57
59
  let more;
58
60
  do {
59
61
  more=false;
@@ -151,7 +153,7 @@ export async function functionRuns(env,{name,errors=false,before}={}) {
151
153
  WHERE (?1 IS NULL OR function=?1) AND (?2=0 OR outcome IN ('failed','interrupted')) AND (?3 IS NULL OR seq<?3)
152
154
  ORDER BY seq DESC LIMIT 101`).bind(name??null,errors?1:0,before??null).all()).results;
153
155
  const page=rows.slice(0,100),definitions=await load();
154
- let crons=[];try {crons=JSON.parse(env.STUDIO_FUNCTION_CRONS??'[]');if(!Array.isArray(crons))crons=[];}catch {}
156
+ let crons=[];try {crons=JSON.parse(env.STUDIO_FUNCTION_CRONS??'[]');if(!Array.isArray(crons))crons=[];}catch(error) {console.warn('Invalid STUDIO_FUNCTION_CRONS:',error.message);}
155
157
  const schedules=scheduleStatus(definitions.filter(f=>!name||f.name===name),crons);
156
158
  const tick=await env.DB.prepare("SELECT value FROM meta WHERE key='function-last-scheduled'").first();
157
159
  return {runs:page.map(r=>({...r,started:new Date(r.started).toISOString(),finished:r.finished===null?null:new Date(r.finished).toISOString()})),next:rows.length>100?page.at(-1).seq:null,schedules,crons,lastScheduled:tick?JSON.parse(tick.value):null,triggerEvidence:'Generated deployment configuration; lastScheduled proves an observed tick. Provider dashboard changes are not queried.'};
@@ -0,0 +1,61 @@
1
+ /** Admission routing without loading transport encoding or crowd simulation. */
2
+ /** Players a Gate holds in a room too big for its Table to hold directly.
3
+ *
4
+ * Two trials on Cloudflare set it. A Gate of 60 dense players that selected and encoded each one's view from
5
+ * scratch (0.45.4) could not keep 20 frames a second; the same work now costs a third as much. But twenty to a
6
+ * Gate (the first 0.46.2 build) made a room of 600 fifty channels, and the room spent its time on messages, not
7
+ * rules: what the room's one thread can bear is counted in messages, and a channel is two a tick. So a Gate is
8
+ * as full as it can serve, and a room opens only the Gates its players fill.
9
+ * A studio chooses its room's size and nothing else. */
10
+ export const GATE_PLAYERS = 50;
11
+ /** `per`: players a Gate, for a trial that is finding the right number (`HOMIE_GATE_PLAYERS` on the Worker). A studio never sets it. */
12
+ export const gatePlayers = (env) => { const n = Number(env?.HOMIE_GATE_PLAYERS); return Number.isInteger(n) && n >= 4 && n <= 200 ? n : GATE_PLAYERS; };
13
+ export function roomLayout(seats, per = GATE_PLAYERS) {
14
+ // A room of up to 64 is its Table alone, as it has been since 0.45.0.
15
+ const gates = seats <= 64 ? 1 : Math.ceil(seats / per);
16
+ return { gates, concentrators: 0 };
17
+ }
18
+ /** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
19
+ function hashedGate(key, gates) {
20
+ let hash = 2166136261;
21
+ for (const c of key || crypto.randomUUID()) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
22
+ return hash % gates;
23
+ }
24
+ /**
25
+ * Which Gate takes the next player: the lowest one with room. A room of 50 players in a 1,000-seat game is one
26
+ * Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
27
+ * Gate's players now and `promised` those sent to it who have not arrived yet.
28
+ */
29
+ export function nextGate(gates, held, promised = new Map(), per = GATE_PLAYERS) {
30
+ let least = 0, fewest = Infinity;
31
+ for (let i = 0; i < gates; i++) {
32
+ const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
33
+ if (n < per) return i;
34
+ if (n < fewest) { fewest = n; least = i; }
35
+ }
36
+ return least;
37
+ }
38
+ export function gateFor(env, game, room, seats, key) {
39
+ const layout = roomLayout(seats, gatePlayers(env));
40
+ // One logical Gate is embedded in the Table for a small room. Same relay,
41
+ // admission and delivery protocol; no extra Durable Object or seat clamp.
42
+ const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
43
+ if (layout.gates === 1) return table();
44
+ return { async fetch(request) {
45
+ const url = new URL(request.url);
46
+ // The room says which Gate has room (one short question a join; a join is the room's work anyway). If it
47
+ // cannot be asked in time the key's hash chooses, as before.
48
+ let index = null;
49
+ try {
50
+ const asked = await Promise.race([table().fetch(`https://table/__gate?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${seats}&gates=${layout.gates}`), new Promise(done => setTimeout(() => done(null), 2000))]);
51
+ const said = asked?.ok ? await asked.json() : null;
52
+ if (Number.isInteger(said?.gate) && said.gate >= 0 && said.gate < layout.gates) index = said.gate;
53
+ } catch { /* the hash chooses */ }
54
+ index ??= hashedGate(key, layout.gates);
55
+ url.searchParams.set('gate', String(index));
56
+ url.searchParams.set('gates', String(layout.gates));
57
+ return env.GATE.get(env.GATE.idFromName(`${game}/${room}/${index}`)).fetch(new Request(url, request));
58
+ } };
59
+ }
60
+
61
+ export function diagnosticsOf(env, game, room) { return env.GATE.get(env.GATE.idFromName(`${game}/${room}/diagnostics`)); }