@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.
- package/CHANGELOG.md +39 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +49 -22
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +2 -0
- package/lib/template-history.json +46 -1
- package/lib/worker-build.mjs +4 -1
- package/lib/worker-config.mjs +7 -0
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/background-queue.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/gate.mjs +94 -23
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +2 -0
- package/worker/lazy-objects.mjs +3 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +86 -18
- package/worker/lounge-store.mjs +2 -2
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/office.mjs +2 -1
- package/worker/operational-schema.mjs +2 -0
- package/worker/pages.mjs +1 -1
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +4 -3
- package/worker/room-chat.mjs +2 -1
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +16 -9
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +7 -4
- package/worker/runtime-support.mjs +5 -3
- package/worker/session-codec.mjs +24 -0
- package/worker/stats.mjs +2 -1
- package/worker/table.mjs +277 -41
- package/worker/version.mjs +1 -1
- 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
|
|
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.
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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 }; }
|