@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
package/worker/room.mjs CHANGED
@@ -53,6 +53,10 @@ export class NetRoom {
53
53
  this.agentsOut = null;
54
54
  /** Revision 7: the game's agents.json (the Table reads it once), the only words an AI here may say. */
55
55
  this.vocab = null;
56
+ this.rawVocab = null;
57
+ this.officeDirty = false;
58
+ this.endedMatch = null;
59
+ this.snapAt = new Map();
56
60
  this.code = code;
57
61
  this.rules = rules === true;
58
62
  this.tickHz = tickHz;
@@ -401,7 +405,7 @@ export class NetRoom {
401
405
  /* ------------------------------------------------------------ sockets */
402
406
 
403
407
  /** A new relay socket. Returns the two callbacks the transport must call. */
404
- attach(conn) {
408
+ attach(conn, resume = null) {
405
409
  const now = this.now();
406
410
  const c = {
407
411
  id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
@@ -422,6 +426,7 @@ export class NetRoom {
422
426
  // address, so a helper from before revisions carries it too) when the transport gives one, else the hello's.
423
427
  verWord: 'ver' in conn ? versionOf(conn.ver) : undefined, ver: null, feat: [], staleTold: false,
424
428
  };
429
+ if(resume)Object.assign(c,resume,{conn});
425
430
  this.clients.set(c.id, c);
426
431
  return {
427
432
  id: c.id,
@@ -451,18 +456,19 @@ export class NetRoom {
451
456
  * The shell's watch socket: the room's facts, and (revision 6) the party's vote from the play page's card. A vote
452
457
  * from it counts as the seat of the client in this room with the same browser key (`conn.browser`).
453
458
  */
454
- watch(conn) {
459
+ watch(conn, restored = null) {
455
460
  this.watchers.add(conn);
456
- this.sendText(conn, JSON.stringify(this.facts()));
461
+ if (!restored) this.sendText(conn, JSON.stringify(this.facts()));
457
462
  // A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
458
463
  const held = this.heldNotice(conn);
459
- if (held) this.sendText(conn, JSON.stringify(held));
464
+ if (!restored && held) this.sendText(conn, JSON.stringify(held));
460
465
  // Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
461
466
  // studio holds out of the room, or a room it closed).
462
467
  // A line of this browser's or this account's own says so (`mine`), on this page's copy only: it may take it down.
463
- if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
464
- const rate = [];
468
+ if (!restored && (!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
469
+ const rate = restored?.rate ?? [];
465
470
  return {
471
+ state: {rate},
466
472
  onClose: () => this.watchers.delete(conn),
467
473
  onMessage: (text) => {
468
474
  const now = this.now();
@@ -974,7 +980,7 @@ export class NetRoom {
974
980
  const behind = !lite && this.currentVer !== undefined && c.ver !== this.currentVer;
975
981
  if (behind) c.staleTold = true;
976
982
  this.send(c, {
977
- t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
983
+ t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
978
984
  ...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
979
985
  role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
980
986
  round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
@@ -1345,18 +1351,19 @@ export class NetRoom {
1345
1351
  }
1346
1352
 
1347
1353
  /** Call every ~250 ms (setInterval locally, an alarm or the socket traffic on a Durable Object). */
1348
- tick() {
1354
+ tick(idle = false) {
1349
1355
  const now = this.now();
1350
1356
  // Sockets that never said hello, or went silent (a slept phone, a half-open link): close them, and a seat
1351
1357
  // becomes a bot instead of a frozen body.
1352
1358
  for (const c of [...this.clients.values()]) {
1359
+ c.lastSeen=Math.max(c.lastSeen,c.conn.lastPing?.()??0);
1353
1360
  if (!c.helloed) { if (now - c.joinedAt > 5000) { this.clients.delete(c.id); try { c.conn.close(4000, 'no-hello'); } catch { /* gone */ } } continue; }
1354
1361
  // A loopback (the Table's own house agent) cannot be half-open: it is never closed for silence.
1355
1362
  if (now - c.lastSeen > this.idleMs && !c.conn.loopback) this.kick(c, 'silent', 4000);
1356
1363
  }
1357
1364
  // A host that stopped sending snapshots while others are present.
1358
1365
  const h = this.host();
1359
- if (h && this.live().length > 1) {
1366
+ if (!idle && h && this.live().length > 1) {
1360
1367
  const since = Math.max(h.lastSnapAt, this.hostSince, this.lastJoinAt);
1361
1368
  if (now - since > this.stallMs) {
1362
1369
  const next = this.elect(h.id, 'host-stalled');
@@ -0,0 +1,22 @@
1
+ import {launchOf, settingsOf, accessOf, sameOrigin} from './office.mjs';
2
+ import {catalogue} from './catalogue.mjs';
3
+ /** Uses the same live session/account checks as the studio office. */
4
+ export async function isOwner(request, env) {
5
+ return (await import('./office.mjs')).isOwner(request, env);
6
+ }
7
+ /** Checks an app's declared role with its existing link, session and grant rules. */
8
+ export async function hasRole(request, env, app, role) {
9
+ const url = new URL(request.url);
10
+ const cat = await catalogue(env, url.origin);
11
+ const meta = cat.games?.find(item => item.id === app && item.kind === 'app');
12
+ if (!meta || typeof role !== 'string' || !Object.hasOwn(meta.roles ?? {}, role)) return false;
13
+ const launch = launchOf(meta, await settingsOf(env, {fresh:true}), env);
14
+ if (launch !== 'public' && !(await accessOf(request, env, app, launch)).ok) return false;
15
+ url.searchParams.set('role', role);
16
+ return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url)).ok === true;
17
+ }
18
+
19
+ /** Return a refusal response, or null when the browser supplied same-origin evidence. */
20
+ export function requireSameOrigin(request) {
21
+ return sameOrigin(request, new URL(request.url)) ? null : new Response('Same origin required', {status:403});
22
+ }
package/worker/routes.mjs CHANGED
@@ -1,4 +1,6 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  /** Route dispatch; large interfaces and tool runtimes load at their own entry points. */
3
+ import {customRoute} from './custom-routes.mjs';
2
4
  import { catalogue } from './catalogue.mjs';
3
5
 
4
6
  import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
@@ -40,6 +42,7 @@ export default async function route(request, env, ctx) {
40
42
  let pubP = null;
41
43
  // Every game (for a game's own pages and the owner's), and the public ones (every list, the rooms, the manifest).
42
44
  const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
45
+ const customFallback = async () => (await customRoute(request, env, ctx)) ?? fallback(request, env, url, getCat);
43
46
  const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
44
47
 
45
48
  // Keep tool transports and discovery on the alias; redirect browser pages only.
@@ -281,7 +284,7 @@ export default async function route(request, env, ctx) {
281
284
  if (game && GAME_ID.test(game)) {
282
285
  const all = await getAll();
283
286
  const meta = (all.games ?? []).find((g) => g.id === game);
284
- if (!meta) return fallback(request, env, url, getCat);
287
+ if (!meta) return customFallback();
285
288
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
286
289
  let sub = parts.slice(1).join('/');
287
290
  // Game bundles were already public assets; only the document carries access context.
@@ -338,7 +341,7 @@ export default async function route(request, env, ctx) {
338
341
  if(!meta.room)return appRoleSession((await import('./records-page.mjs')).recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game,undefined,{request,meta,launch}):null),request,env,meta,launch);
339
342
  }
340
343
  }
341
- const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
344
+ const lobby = () => lobbyFor(env,game,/^[A-Za-z0-9_-]{16,43}$/.test(url.searchParams.get('b')??'')?url.searchParams.get('b'):'');
342
345
  if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
343
346
 
344
347
  // SERVERS (worker/servers.mjs, NETPLAY.md section 17): which server a request is for — its path (/s/<id>/), its
@@ -629,7 +632,7 @@ export default async function route(request, env, ctx) {
629
632
  const cur = versionOf(meta?.netplay?.version) ?? '';
630
633
  const gv = versionOf(url.searchParams.get('gv')) ?? '';
631
634
  const stall = Number(meta?.netplay?.stallMs) || 0;
632
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
635
+ const target = `https://table/${sub}?relay=1&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
633
636
  // The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
634
637
  // tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
635
638
  // uncaught error with nothing on it.
@@ -663,5 +666,5 @@ export default async function route(request, env, ctx) {
663
666
  }
664
667
  if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
665
668
  }
666
- return fallback(request, env, url, getCat);
669
+ return customFallback();
667
670
  }
@@ -1,6 +1,8 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {studioTools,directoryOf} from './tool-registry.mjs';
2
3
  export {useTools,getStudioTools} from './tool-registry.mjs';
3
4
  import {json,finish} from './responses.mjs';
5
+ import {liveRoomSummaries} from './lobby-summary.mjs';
4
6
  /** Shared catalogue, assets, and room presentation helpers. */
5
7
  import {catalogue,builtPosts} from './catalogue.mjs';
6
8
 
@@ -138,11 +140,11 @@ async function roomsOf(env, games, { servers = null } = {}) {
138
140
  const live = {};
139
141
  // Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
140
142
  const chatRows = await chatRowsOf(env);
141
- const summaries=null;
143
+ const summaries=await liveRoomSummaries(env);
142
144
  await Promise.all(games.map(async (g) => {
143
145
  let list = [];
144
146
  if(summaries)list=summaries.get(g.id)??[];
145
- else try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
147
+ else try { list = (await (await lobbyFor(env,g.id).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
146
148
  const busy = list.filter((r) => (r.players || 0) > 0);
147
149
  live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
148
150
  const known = busy.some((r) => r.server && r.server !== 'public') ? (servers?.[g.id] ?? await serversOf(env, g).catch(() => [])) : [];
@@ -428,7 +430,7 @@ async function weekOf(env, game) {
428
430
  const w = await playedThisWeek(env, game);
429
431
  if (!w) return null;
430
432
  let peak = 0;
431
- try { peak = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
433
+ try { peak = (await (await lobbyFor(env,game).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
432
434
  return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
433
435
  }
434
436
 
@@ -0,0 +1,24 @@
1
+ /** Explicit containers keep user checkpoint keys from colliding with type tags. */
2
+ function pack(value) {
3
+ if(value===undefined)return ['u'];
4
+ if(typeof value==='number'&&!Number.isFinite(value))return ['n',String(value)];
5
+ if(value===null||typeof value!=='object')return ['v',value];
6
+ if(value instanceof Map)return ['m',[...value].map(([key,item])=>[pack(key),pack(item)])];
7
+ if(value instanceof Set)return ['s',[...value].map(pack)];
8
+ if(Array.isArray(value))return ['a',value.map(pack)];
9
+ return ['o',Object.entries(value).filter(([,item])=>typeof item!=='function').map(([key,item])=>[key,pack(item)])];
10
+ }
11
+ function unpack([type,value]) {
12
+ switch(type) {
13
+ case 'u':return undefined;
14
+ case 'n':return Number(value);
15
+ case 'v':return value;
16
+ case 'm':return new Map(value.map(([key,item])=>[unpack(key),unpack(item)]));
17
+ case 's':return new Set(value.map(unpack));
18
+ case 'a':return value.map(unpack);
19
+ case 'o':return Object.fromEntries(value.map(([key,item])=>[key,unpack(item)]));
20
+ default:throw new Error('Invalid room session value');
21
+ }
22
+ }
23
+ export const encodeSession=value=>JSON.stringify(pack(value));
24
+ export const decodeSession=value=>unpack(JSON.parse(value));
package/worker/stats.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {writePageMetric,analyticsStatus} from './analytics.mjs';
2
3
  /**
3
4
  * STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
@@ -255,7 +256,7 @@ async function playingNow(env, games) {
255
256
  const out = {};
256
257
  await Promise.all(games.map(async (g) => {
257
258
  try {
258
- const res = await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/now');
259
+ const res = await lobbyFor(env,g.id).fetch('https://lobby/now');
259
260
  const body = await res.json();
260
261
  out[g.id] = { players: Number(body.players) || 0, rooms: Number(body.rooms) || 0 };
261
262
  } catch { out[g.id] = { players: 0, rooms: 0 }; }