@homie-rocks/studio 0.38.0 → 0.40.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 +57 -1
- package/bin/homie-studio.mjs +13 -1
- package/lib/build.mjs +4 -0
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev.mjs +12 -1
- package/lib/mcp-tools.mjs +10 -0
- package/lib/port-check.mjs +33 -7
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/scaffold.mjs +19 -6
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +82 -0
- package/lib/tools-build.mjs +55 -0
- package/lib/tools-cli.mjs +55 -0
- package/lib/worker-build.mjs +35 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +17 -6
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/tools/TOOLS.md +113 -0
- package/tools/tools.ts +28 -0
- package/worker/app-records.mjs +8 -0
- package/worker/index.mjs +26 -0
- package/worker/mcp-room.mjs +61 -0
- package/worker/mcp-store.mjs +45 -0
- package/worker/mcp-tools.mjs +105 -0
- package/worker/mcp.mjs +159 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/purchase-mcp.mjs +3 -3
- package/worker/tool-events.mjs +14 -0
- package/worker/tool-identity.mjs +4 -0
- package/worker/tool-webhook.mjs +28 -0
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,65 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.40.0] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.40.0**
|
|
23
|
+
|
|
24
|
+
New 3D games keep their matches on the server and answer your controls immediately.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Rules games can move and face in three dimensions, jump and land, and collide with boxes, spheres, upright capsules and sloped height tiles. Declared level data reaches the server and the browser together and is checked with the game's save and restore.
|
|
29
|
+
- Prediction follows vertical movement and facing, eases corrections, and draws other players' poses and animation state smoothly. The build reports the completed local room-capacity trial separately from its generated game check.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Gem Rush 3D, Hero Rush 3D and Ember Vale now use rules plus view with the server keeping the match, including bots, companions, pickups and combat. Ember Vale keeps its saved hero. All five game starters now use the rules runtime; Ember Vale keeps its two-dimensional play.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Predicted movement respects the server's floor and ceiling, and catch-up animation follows the pose being drawn.
|
|
38
|
+
- Knockback no longer stores blocked travel and bursts around obstacles. A jump or direction change partway through a tick preserves the current drawn position before advancing.
|
|
39
|
+
- Phone control checks start measuring with the first directional touch movement, so slow browser automation cannot cross the arena before measurement begins.
|
|
40
|
+
- Gem Rush keeps sliding along arena walls under thumb input, and a delayed animation frame cannot give a newly arrived effect a negative radius.
|
|
41
|
+
|
|
42
|
+
### Upgrade notes
|
|
43
|
+
|
|
44
|
+
- Existing games keep their source and their hosting choice. The game skill is unchanged; its authoring update is the next slice.
|
|
45
|
+
- Capacity measurements in this release are local. Cloudflare capacity and billing have not been measured.
|
|
46
|
+
|
|
47
|
+
## [0.39.0] - 2026-10-09
|
|
48
|
+
|
|
49
|
+
**Plugin 0.39.0** · [#78](https://github.com/homie-rocks/homie/pull/78)
|
|
50
|
+
|
|
51
|
+
Your studio has its own MCP server. Connect your AI with one browser approval and add tools for the work your studio does.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- Connect Claude, ChatGPT or Grok to your studio's own `/mcp` address. Use your existing studio account; there is no key to find or copy. Client setup and workspace policies still apply.
|
|
56
|
+
- Let your AI use the office, app records, shared parts and marked guide seats in rooms. Staff keep their own app permissions. Destructive office controls still wait for your confirmation.
|
|
57
|
+
- Add a typed tool with `homie-studio tool new`, test it with `tool call` and share its code as a part. Tools can also supply prompts and signed inbound integrations; readable app records are resources.
|
|
58
|
+
- Review AI connections and recent tool calls in the office. Revoke a connection there. Role checks, validated input and configurable call limits apply on the server.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Worker builds keep MCP libraries in separate modules, loaded when that surface is used. Ordinary page traffic performs no MCP storage work.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- Browser input checks wait for the final touch movement to reach the game before measuring its response, including when Chrome delays delivery. Successful browser calls clear their timeout timers.
|
|
67
|
+
|
|
68
|
+
- Clean installs on Node 24 include the MCP SDK required by the payment library, so purchase tools and selling builds work alongside the studio MCP server.
|
|
69
|
+
- MCP integration tests close clients after failures as well as successes. The desktop check clears request timers instead of waiting an extra minute after success. Package and plugin test files have a hard timeout.
|
|
70
|
+
|
|
71
|
+
### Upgrade notes
|
|
72
|
+
|
|
73
|
+
- Ask your AI to upgrade and build your studio. The build writes Worker modules and the normal deploy applies the new connection and audit tables. No separate Homie service or Cloudflare resource is needed.
|
|
74
|
+
- Studio tools are trusted code in your Worker. Review tools received as parts before adding them. The tool context does not expose raw credentials.
|
|
75
|
+
|
|
20
76
|
## [0.38.0] - 2026-10-09
|
|
21
77
|
|
|
22
|
-
**Plugin 0.39.0**
|
|
78
|
+
**Plugin 0.39.0** · [#77](https://github.com/homie-rocks/homie/pull/77)
|
|
23
79
|
|
|
24
80
|
Your own character answers as soon as you press, even when the room runs on the server.
|
|
25
81
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -155,6 +155,8 @@
|
|
|
155
155
|
* homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
|
|
156
156
|
* [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
|
|
157
157
|
* [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--listed on|off]
|
|
158
|
+
* homie-studio tool new <name> [--app <id>]
|
|
159
|
+
* homie-studio tool call <name> --input <JSON> [--url <site>] [--public]
|
|
158
160
|
* homie-studio servers set <game> <server> [the same flags]
|
|
159
161
|
* homie-studio servers close <game> <server> [--reopen]
|
|
160
162
|
* homie-studio servers level <game> <room> <1-5> (the AI's level in one room: Rookie, Steady, Fair, Strong, Maxed)
|
|
@@ -377,7 +379,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
|
|
|
377
379
|
|
|
378
380
|
const argv = process.argv.slice(2);
|
|
379
381
|
const flags = new Map();
|
|
380
|
-
const BOOL_FLAGS = ['lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
382
|
+
const BOOL_FLAGS = ['public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
381
383
|
const positional = [];
|
|
382
384
|
for (let i = 0; i < argv.length; i++) {
|
|
383
385
|
const a = argv[i];
|
|
@@ -487,7 +489,15 @@ function print(result) {
|
|
|
487
489
|
lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
|
|
488
490
|
break;
|
|
489
491
|
}
|
|
492
|
+
case 'tool new':
|
|
493
|
+
lines.push(result.file, result.next);
|
|
494
|
+
break;
|
|
495
|
+
case 'tool call':
|
|
496
|
+
case 'tool list':
|
|
497
|
+
lines.push(JSON.stringify(result.result, null, 2));
|
|
498
|
+
break;
|
|
490
499
|
case 'deploy':
|
|
500
|
+
if (result.mcp) lines.push(`Connect your AI: ${result.mcp} (approve in your studio's browser page; no key)`);
|
|
491
501
|
if (result.ci) {
|
|
492
502
|
lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}${g.hash ? ` [${deployWords(g)}]` : ''}`));
|
|
493
503
|
break;
|
|
@@ -823,6 +833,7 @@ const OUTWARD_FLAGS = {
|
|
|
823
833
|
'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
|
|
824
834
|
'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
|
|
825
835
|
'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
|
|
836
|
+
'tool new': ['app'], 'tool call': ['url', 'input', 'public'], 'tool list': ['url', 'public'],
|
|
826
837
|
// With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
|
|
827
838
|
'standalone run': ['for', 'device', 'site'],
|
|
828
839
|
};
|
|
@@ -883,6 +894,7 @@ async function main() {
|
|
|
883
894
|
// The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
|
|
884
895
|
if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
|
|
885
896
|
const root = requireStudio();
|
|
897
|
+
if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
|
|
886
898
|
// A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
|
|
887
899
|
if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
|
|
888
900
|
if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
|
package/lib/build.mjs
CHANGED
|
@@ -491,6 +491,7 @@ export function orderGames(games, order, log = () => {}) {
|
|
|
491
491
|
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
|
|
492
492
|
const esbuild = await studioEsbuild(root);
|
|
493
493
|
const studio = readStudio(root);
|
|
494
|
+
await (await import('./tools-build.mjs')).buildTools(root, esbuild);
|
|
494
495
|
// The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
|
|
495
496
|
const shop = shopForBuild(root, { log });
|
|
496
497
|
const live = join(root, 'site', 'dist');
|
|
@@ -543,6 +544,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
543
544
|
if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
|
|
544
545
|
built.push({
|
|
545
546
|
id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
|
|
547
|
+
...(rules ? { capacityTrial: rules.check.capacityTrial } : {}),
|
|
546
548
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
547
549
|
});
|
|
548
550
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
|
@@ -689,6 +691,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
689
691
|
...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
|
|
690
692
|
...(studio.referrals === false ? { referrals: false } : {}),
|
|
691
693
|
...(lounge ? { lounge } : {}),
|
|
694
|
+
...(studio.mcp ? { mcp: studio.mcp } : {}),
|
|
692
695
|
build: buildInfo(root),
|
|
693
696
|
},
|
|
694
697
|
games: rows,
|
|
@@ -723,6 +726,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
723
726
|
// site, never before: a game that did not build has left both as they were.
|
|
724
727
|
await beforePublish();
|
|
725
728
|
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
|
|
729
|
+
await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
|
|
726
730
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
727
731
|
const swapped = swapIn(dist, live);
|
|
728
732
|
return {
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -466,7 +466,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
466
466
|
const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
|
|
467
467
|
writeStudio(root, { ...readStudio(root), cloudflare: next });
|
|
468
468
|
return {
|
|
469
|
-
ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
469
|
+
ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
470
470
|
...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
|
|
471
471
|
games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
|
|
472
472
|
...(delta.removed.length ? { removed: delta.removed } : {}),
|
|
@@ -628,7 +628,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
628
628
|
const hashes = builtGames(root, games);
|
|
629
629
|
for (const id of games) if (hashes[id]?.hash) step(`${id}: ${deployWords(hashes[id])}`);
|
|
630
630
|
return {
|
|
631
|
-
ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
631
|
+
ok: true, command: 'deploy', ci: true, url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
632
632
|
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
633
633
|
// A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
|
|
634
634
|
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
|
package/lib/dev.mjs
CHANGED
|
@@ -459,7 +459,18 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
459
459
|
return;
|
|
460
460
|
}
|
|
461
461
|
};
|
|
462
|
-
const
|
|
462
|
+
const { toolFiles } = await import('./tools-build.mjs');
|
|
463
|
+
const toolsStamp = () => toolFiles(root).map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
|
|
464
|
+
let lastTools = toolsStamp();
|
|
465
|
+
const toolsLook = async () => {
|
|
466
|
+
if (rebuilding || ending) return;
|
|
467
|
+
const stamp = toolsStamp(); if (stamp === lastTools) return;
|
|
468
|
+
lastTools = stamp; rebuilding = true;
|
|
469
|
+
try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); }
|
|
470
|
+
catch (error) { log(`Studio tools did not build: ${error.message}`); }
|
|
471
|
+
finally { rebuilding = false; }
|
|
472
|
+
};
|
|
473
|
+
const timer = setInterval(() => { look().catch(() => {}); rulesLook().catch(() => {}); toolsLook().catch(() => {}); }, watchMs);
|
|
463
474
|
timer.unref?.();
|
|
464
475
|
|
|
465
476
|
/** Wait for the local site, then hold it to its own address (socketOriginProblem); a mismatch ends dev. */
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -782,6 +782,16 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
782
782
|
return r.ok ? ok('Node and npm are ready. AI: continue studio_scaffold.', r) : fail(r.next, r);
|
|
783
783
|
},
|
|
784
784
|
},
|
|
785
|
+
{
|
|
786
|
+
name: 'studio_connect_ai', title: 'Connect your AI to the studio',
|
|
787
|
+
description: 'Give the person this studio’s own remote MCP address and connection page. Their AI connects directly with browser approval and their existing studio account; no key or local server.',
|
|
788
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
789
|
+
annotations: { title: 'Connect your AI', ...RO },
|
|
790
|
+
run: async (a) => {
|
|
791
|
+
const root = ctx.root(a.studio), site = siteUrl(root);
|
|
792
|
+
return site ? ok(`Connect your AI to ${site}/mcp. Approve with your studio account in the browser. Manage connections at ${site}/_studio/office/connections. Client setup varies; do not promise a one-click import a client does not support.`, { mcp: `${site}/mcp`, connections: `${site}/_studio/office/connections` }) : fail('This studio has no published address yet. Local tools can be tested with homie-studio dev.');
|
|
793
|
+
},
|
|
794
|
+
},
|
|
785
795
|
{
|
|
786
796
|
name: 'studio_domain', title: 'Set the studio domain',
|
|
787
797
|
description: 'Prepare a requested custom hostname for deployment. Cloudflare creates DNS and TLS using its existing browser sign-in. The AI handles configuration; never ask for an account id, zone id or DNS value. Does not deploy or change the domain registrar.',
|
package/lib/port-check.mjs
CHANGED
|
@@ -44,7 +44,11 @@ const GPU = [
|
|
|
44
44
|
const TAG = 'homie-studio-check';
|
|
45
45
|
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36';
|
|
46
46
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
-
const T = (p, ms, v = null) =>
|
|
47
|
+
const T = async (p, ms, v = null) => {
|
|
48
|
+
let timer;
|
|
49
|
+
try { return await Promise.race([Promise.resolve(p).catch(() => v), new Promise(resolve => { timer = setTimeout(() => resolve(v), ms); })]); }
|
|
50
|
+
finally { clearTimeout(timer); }
|
|
51
|
+
};
|
|
48
52
|
const DIRV = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
|
49
53
|
const ALL = ['owner-desk', 'owner-phone', 'owner-iphone', 'round', 'life', 'tv'];
|
|
50
54
|
|
|
@@ -310,10 +314,10 @@ async function focusGame(h) {
|
|
|
310
314
|
async function holdKey(h, code, ms, ready) { await h.page.keyboard.down(code); if (ready) await ready(); await sleep(ms); await h.page.keyboard.up(code); }
|
|
311
315
|
|
|
312
316
|
function thumbAt(h, i) { const t = i?.thumb ?? [0.24, 0.74]; return [Math.round(h.vp.width * t[0]), Math.round(h.vp.height * t[1])]; }
|
|
313
|
-
async function touchDrag(h, from, dir, reach, holdMs, ready) {
|
|
317
|
+
async function touchDrag(h, from, dir, reach, holdMs, ready, steps = 8) {
|
|
314
318
|
const [x0, y0] = from; const u = DIRV[dir];
|
|
315
319
|
await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
|
|
316
|
-
for (let k = 1; k <=
|
|
320
|
+
for (let k = 1; k <= steps; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / steps), y: Math.round(y0 + (u[1] * reach * k) / steps) }]); await sleep(12); }
|
|
317
321
|
if (ready) await ready();
|
|
318
322
|
const end = Date.now() + holdMs;
|
|
319
323
|
let w = 0;
|
|
@@ -328,17 +332,36 @@ async function touchDrag(h, from, dir, reach, holdMs, ready) {
|
|
|
328
332
|
/** Timestamp real input delivery, then keep it held until four frames can judge its direction.
|
|
329
333
|
* The response limit stays 600 ms; slow automation delivery and the thumb's ramp are not game latency. */
|
|
330
334
|
export async function measuredPress(h, how, code, from, dir) {
|
|
335
|
+
// CDP points use page coordinates; DOM touch receipts use frame coordinates.
|
|
336
|
+
let offset = { x: 0, y: 0 };
|
|
337
|
+
if (how !== 'keys') {
|
|
338
|
+
const element = await frameOf(h)?.frameElement();
|
|
339
|
+
if (element) {
|
|
340
|
+
try { offset = await element.boundingBox() ?? offset; }
|
|
341
|
+
finally { await element.dispose(); }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
331
344
|
await inFrame(h, `(() => {
|
|
332
345
|
if (!window.__homieCheckInput) {
|
|
333
346
|
const state = window.__homieCheckInput = {};
|
|
334
347
|
addEventListener('keydown', e => { if (state.code === e.code && state.key === null) state.key = performance.now(); }, true);
|
|
335
|
-
addEventListener('touchmove',
|
|
348
|
+
addEventListener('touchmove', e => {
|
|
349
|
+
const touch = e.touches[0];
|
|
350
|
+
if (state.touch === null && touch && touch.clientX === state.touchX && touch.clientY === state.touchY) state.touch = performance.now();
|
|
351
|
+
}, { capture: true, passive: true });
|
|
336
352
|
}
|
|
337
|
-
Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null });
|
|
353
|
+
Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null, touchX: ${Math.round(from[0] + DIRV[dir][0] * 70) - offset.x}, touchY: ${Math.round(from[1] + DIRV[dir][1] * 70) - offset.y} });
|
|
338
354
|
})()`);
|
|
339
355
|
let a = null, b = null;
|
|
340
356
|
const ready = async () => {
|
|
341
|
-
|
|
357
|
+
// CDP acknowledgement is not DOM delivery. Wait for the final ramp point,
|
|
358
|
+
// then latch that receipt so later/coalesced moves cannot change it.
|
|
359
|
+
const deliveryDeadline = Date.now() + 3000;
|
|
360
|
+
do {
|
|
361
|
+
a = await inFrame(h, `window.__homieCheckInput.${how === 'keys' ? 'key' : 'touch'}`);
|
|
362
|
+
if (Number.isFinite(a)) break;
|
|
363
|
+
await sleep(20);
|
|
364
|
+
} while (Date.now() < deliveryDeadline);
|
|
342
365
|
if (!Number.isFinite(a)) throw new Error('the game frame did not receive the measured input');
|
|
343
366
|
await sleep(420);
|
|
344
367
|
const until = Date.now() + 3000;
|
|
@@ -346,7 +369,10 @@ export async function measuredPress(h, how, code, from, dir) {
|
|
|
346
369
|
b = await frameNow(h);
|
|
347
370
|
};
|
|
348
371
|
if (how === 'keys') await holdKey(h, code, 0, ready);
|
|
349
|
-
|
|
372
|
+
// One real move establishes the measured direction. An eight-event ramp can
|
|
373
|
+
// take seconds on software Chrome and drive all the way into a wall before
|
|
374
|
+
// its final-point receipt starts the measurement. Holds still exercise ramps.
|
|
375
|
+
else await touchDrag(h, from, dir, 70, 0, ready, 1);
|
|
350
376
|
return { dir, a, b };
|
|
351
377
|
}
|
|
352
378
|
|
package/lib/rules-build.mjs
CHANGED
|
@@ -33,6 +33,10 @@ import { typecheckRules } from './typecheck.mjs';
|
|
|
33
33
|
export { isRulesGame };
|
|
34
34
|
export { stateHash } from './rules-check.mjs';
|
|
35
35
|
|
|
36
|
+
// A reference workload, not a promise about every game's rules or a Cloudflare measurement.
|
|
37
|
+
// Receipt and method: docs/rooms-slice-7-notes.md, "Budget measurements".
|
|
38
|
+
export const RULES_CAPACITY_TRIAL = Object.freeze({ game: 'hero-rush-3d', seats: 32, tickHz: 30, minutes: 20, host: 'Node', platform: 'macOS arm64', cloudflare: false });
|
|
39
|
+
|
|
36
40
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
37
41
|
const RULES_FILES = { [RULES_MODULE]: 'rules.ts', [GUARD_MODULE]: 'guard.ts', [`${RULES_MODULE}/view`]: 'view.ts', [`${RULES_MODULE}/host`]: 'host.ts' };
|
|
38
42
|
|
|
@@ -150,6 +154,9 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
150
154
|
for (const p of checked.problems) log(`warning: games/${g.id}/game.json: ${p}`);
|
|
151
155
|
for (const line of checked.information) log(`info: ${line}`);
|
|
152
156
|
const { settings, schema, publicTune, rounds, stateHash } = checked;
|
|
157
|
+
const trial = RULES_CAPACITY_TRIAL;
|
|
158
|
+
log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
|
|
159
|
+
if (settings.tickHz > trial.tickHz) log(`info: games/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
|
|
153
160
|
const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
|
|
154
161
|
const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
|
|
155
162
|
const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
|
|
@@ -171,7 +178,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
171
178
|
build: createHash('sha256').update(guarded.code).update(JSON.stringify(built)).digest('hex'),
|
|
172
179
|
snapshotCap, checkpointCap, snapshotBytes: stats.snapshotBytes, checkpointBytes: stats.checkpointBytes,
|
|
173
180
|
units: checked.maxUnits, tickUnits: checked.maxTickUnits,
|
|
174
|
-
check: { information: checked.information, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
181
|
+
check: { information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
175
182
|
};
|
|
176
183
|
}
|
|
177
184
|
|
|
@@ -236,7 +236,7 @@ function play(c, companions, allow) {
|
|
|
236
236
|
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
237
237
|
const steering = new Map();
|
|
238
238
|
let moveTick = 0;
|
|
239
|
-
const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, dims: c.dims })]));
|
|
239
|
+
const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
|
|
240
240
|
/**
|
|
241
241
|
* An owner-moved body's own browser, for one tick: the game's `move` from where the last snapshot has the body, by
|
|
242
242
|
* the step a browser takes (pack.ts `stepMove`), and the claim of where that leaves it. Its units are the play's.
|
|
@@ -350,8 +350,8 @@ function play(c, companions, allow) {
|
|
|
350
350
|
const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
|
|
351
351
|
error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
|
|
352
352
|
units += result.used;
|
|
353
|
-
const r = kind.body.radius, bounds = c.map.bounds;
|
|
354
|
-
result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: result.pos.z }, c.dims);
|
|
353
|
+
const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
|
|
354
|
+
result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: c.dims === 3 ? Math.max(bounds.min.z, Math.min(bounds.max.z - height, result.pos.z)) : 0 }, c.dims);
|
|
355
355
|
delete result.used;
|
|
356
356
|
if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
|
|
357
357
|
}
|
package/lib/rules-check.mjs
CHANGED
|
@@ -28,7 +28,9 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
|
|
|
28
28
|
|
|
29
29
|
/** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
|
|
30
30
|
export function stateHash(c) {
|
|
31
|
-
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, save } = c;
|
|
31
|
+
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save } = c;
|
|
32
|
+
// Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
|
|
33
|
+
const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
|
|
32
34
|
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
33
35
|
}
|
|
34
36
|
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
|
|
1
2
|
import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
|
|
2
3
|
import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
|
|
3
4
|
/**
|
|
@@ -67,8 +68,8 @@ export function slugify(name) {
|
|
|
67
68
|
* the binding names and keep it linked.
|
|
68
69
|
*/
|
|
69
70
|
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
|
|
70
|
-
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.
|
|
71
|
-
: { schema: 'node_modules', main: 'site/src/worker.
|
|
71
|
+
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
|
|
72
|
+
: { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
|
|
72
73
|
const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
|
|
73
74
|
const config = {
|
|
74
75
|
$schema: `${at.schema}/wrangler/config-schema.json`,
|
|
@@ -80,7 +81,8 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
|
|
|
80
81
|
// subdomain) reaches it instead of failing with 1042.
|
|
81
82
|
// `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
|
|
82
83
|
// later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
|
|
83
|
-
|
|
84
|
+
no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
|
|
85
|
+
compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
|
|
84
86
|
...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ratelimits: [partsRateLimit ?? { name: 'PURCHASE_RATE_LIMITER', namespace_id: '1001', simple: { limit: 60, period: 60 } }] } : {}),
|
|
85
87
|
workers_dev: true,
|
|
86
88
|
preview_urls: true,
|
|
@@ -152,6 +154,8 @@ An app is ONE screen that morphs with context: camera moves, panels unfold, its
|
|
|
152
154
|
|
|
153
155
|
Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
|
|
154
156
|
|
|
157
|
+
Your studio also serves MCP at \`/mcp\`. Staff connect their own AI with their existing account and one browser approval; never ask them for a key. Add business actions with \`homie-studio tool new <name> --app <id>\`, keep their declared role narrow, and reuse the app's existing record IDs, field formats and versions. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
|
|
158
|
+
|
|
155
159
|
Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
|
|
156
160
|
|
|
157
161
|
## Commands (all through the pinned CLI in node_modules)
|
|
@@ -645,6 +649,8 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
|
|
|
645
649
|
`;
|
|
646
650
|
|
|
647
651
|
const GITIGNORE = `node_modules/
|
|
652
|
+
site/src/runtime/
|
|
653
|
+
site/src/tools/
|
|
648
654
|
# This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
|
|
649
655
|
.studio/
|
|
650
656
|
# This person's own Claude Code settings for this studio (the status line, for one).
|
|
@@ -734,11 +740,12 @@ export function studioFiles({ name, slug, homie, template = false }) {
|
|
|
734
740
|
'site/README.md': SITE_README,
|
|
735
741
|
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
|
|
736
742
|
// The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
|
|
737
|
-
import { hostRules } from '@homie-rocks/studio/worker';
|
|
743
|
+
import { hostRules, useTools } from '@homie-rocks/studio/worker';
|
|
738
744
|
// The rules of this studio's games that run on the server (written by homie-studio build; empty until a game has some).
|
|
739
745
|
import rules from './rules/index.mjs';
|
|
740
746
|
|
|
741
747
|
hostRules(rules);
|
|
748
|
+
useTools(async () => (await import('./tools/index.mjs')).default);
|
|
742
749
|
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
743
750
|
`,
|
|
744
751
|
'site/src/rules/index.mjs': rulesIndex([]),
|
|
@@ -755,6 +762,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
755
762
|
[`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
|
|
756
763
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
757
764
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
765
|
+
[`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
|
|
758
766
|
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
759
767
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
760
768
|
'.claude/skills/.gitkeep': '',
|
|
@@ -926,13 +934,18 @@ function ensurePartsStateMigration(root) {
|
|
|
926
934
|
return `site/migrations/${PURCHASE_STATE_FILE}`;
|
|
927
935
|
}
|
|
928
936
|
|
|
937
|
+
function ensureMcpMigration(root) {
|
|
938
|
+
const file=join(root,'site','migrations',MCP_MIGRATION_FILE);
|
|
939
|
+
if(existsSync(file))return null;
|
|
940
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
|
|
941
|
+
}
|
|
929
942
|
export function ensureMigrations(root) {
|
|
930
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root)].filter(Boolean);
|
|
943
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root)].filter(Boolean);
|
|
931
944
|
}
|
|
932
945
|
|
|
933
946
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
934
947
|
export function migrationWord(file) {
|
|
935
|
-
return /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
948
|
+
return /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
936
949
|
}
|
|
937
950
|
|
|
938
951
|
/**
|
package/lib/studio.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
10
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
-
const RESERVED_IDS = new Set(['api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
|
|
11
|
+
const RESERVED_IDS = new Set(['mcp', 'oauth', 'hooks', 'api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
|
|
12
12
|
|
|
13
13
|
export function findStudio(from = process.cwd()) {
|
|
14
14
|
let at = resolve(from);
|
|
@@ -2156,6 +2156,88 @@
|
|
|
2156
2156
|
}
|
|
2157
2157
|
}
|
|
2158
2158
|
}
|
|
2159
|
+
},
|
|
2160
|
+
"0.39.0": {
|
|
2161
|
+
"files": {
|
|
2162
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2163
|
+
"README.md": "684b3cca8001673c",
|
|
2164
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2165
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2166
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2167
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2168
|
+
"posts/README.md": "37841aae476bd540",
|
|
2169
|
+
"site/README.md": "732894273a32dc3f",
|
|
2170
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2171
|
+
},
|
|
2172
|
+
"sections": {
|
|
2173
|
+
"AGENTS.md": {
|
|
2174
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2175
|
+
"## Layout": "c18362ed28975d81",
|
|
2176
|
+
"## Apps: one screen, roles and parts": "be6f8fa45f93211a",
|
|
2177
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
2178
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2179
|
+
"## Making games": "2da7fa74c8884834",
|
|
2180
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2181
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2182
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2183
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
2184
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2185
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2186
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2187
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2188
|
+
"## Beta": "9d02638207a26e73"
|
|
2189
|
+
}
|
|
2190
|
+
},
|
|
2191
|
+
"named": {
|
|
2192
|
+
"Homie Arcade": {
|
|
2193
|
+
"sections": {
|
|
2194
|
+
"AGENTS.md": {
|
|
2195
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
},
|
|
2201
|
+
"0.40.0": {
|
|
2202
|
+
"files": {
|
|
2203
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2204
|
+
"README.md": "684b3cca8001673c",
|
|
2205
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2206
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2207
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2208
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2209
|
+
"posts/README.md": "37841aae476bd540",
|
|
2210
|
+
"site/README.md": "732894273a32dc3f",
|
|
2211
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2212
|
+
},
|
|
2213
|
+
"sections": {
|
|
2214
|
+
"AGENTS.md": {
|
|
2215
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2216
|
+
"## Layout": "c18362ed28975d81",
|
|
2217
|
+
"## Apps: one screen, roles and parts": "be6f8fa45f93211a",
|
|
2218
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
2219
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2220
|
+
"## Making games": "2da7fa74c8884834",
|
|
2221
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2222
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2223
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2224
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
2225
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2226
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2227
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2228
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2229
|
+
"## Beta": "9d02638207a26e73"
|
|
2230
|
+
}
|
|
2231
|
+
},
|
|
2232
|
+
"named": {
|
|
2233
|
+
"Homie Arcade": {
|
|
2234
|
+
"sections": {
|
|
2235
|
+
"AGENTS.md": {
|
|
2236
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2159
2241
|
}
|
|
2160
2242
|
}
|
|
2161
2243
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { partsPlugin, resolvePartImport } from './parts-build.mjs';
|
|
6
|
+
const require=createRequire(import.meta.url);
|
|
7
|
+
export function toolFiles(root) {
|
|
8
|
+
const dirs=[join(root,'tools')];
|
|
9
|
+
const apps=join(root,'apps');if(existsSync(apps))for(const app of readdirSync(apps,{withFileTypes:true}))if(app.isDirectory())dirs.push(join(apps,app.name,'tools'));
|
|
10
|
+
return dirs.flatMap(dir=>existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]);
|
|
11
|
+
}
|
|
12
|
+
export async function buildTools(root,esbuild) {
|
|
13
|
+
const files=toolFiles(root),dir=join(root,'site/src/tools');
|
|
14
|
+
mkdirSync(dir,{recursive:true});
|
|
15
|
+
if(files.length){
|
|
16
|
+
const ts=require('typescript');
|
|
17
|
+
const options={target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,strict:true,noEmit:true,skipLibCheck:true,allowImportingTsExtensions:true};
|
|
18
|
+
const host=ts.createCompilerHost(options);
|
|
19
|
+
host.resolveModuleNames=(names,containing)=>names.map(name=>{
|
|
20
|
+
if(name.startsWith('@parts/')){const part=resolvePartImport(root,name);if(part.path)return {resolvedFileName:part.path,extension:part.path.endsWith('.ts')?ts.Extension.Ts:ts.Extension.Js};}
|
|
21
|
+
return ts.resolveModuleName(name,containing,options,host).resolvedModule;
|
|
22
|
+
});
|
|
23
|
+
const program=ts.createProgram(files,options,host);
|
|
24
|
+
const errors=ts.getPreEmitDiagnostics(program);
|
|
25
|
+
if(errors.length)throw new Error(ts.formatDiagnosticsWithColorAndContext(errors,{getCurrentDirectory:()=>root,getCanonicalFileName:f=>f,getNewLine:()=> '\n'}));
|
|
26
|
+
}
|
|
27
|
+
const contents=files.map((f,i)=>`import t${i} from ${JSON.stringify(f)};`).join('\n')+`\nexport default [${files.map((_,i)=>'t'+i).join(',')}];\n`;
|
|
28
|
+
const result=await esbuild.build({stdin:{contents,resolveDir:root},bundle:true,format:'esm',platform:'neutral',target:'es2022',write:false,plugins:[partsPlugin(root)],logLevel:'silent'});
|
|
29
|
+
const temp=join(dir,'index.mjs');writeFileSync(temp,result.outputFiles[0].text);
|
|
30
|
+
// Trusted studio code, as with the existing build command. No Worker bindings exist during build.
|
|
31
|
+
const defs=(await import(pathToFileURL(temp).href+'?build='+Date.now())).default;
|
|
32
|
+
const { CfWorkerJsonSchemaValidator }=await import('@modelcontextprotocol/server/validators/cf-worker');
|
|
33
|
+
const validator=new CfWorkerJsonSchemaValidator(),seen=new Set();
|
|
34
|
+
for(const t of defs){
|
|
35
|
+
if(!/^[a-z][a-z0-9_]{0,63}$/.test(t.name)||/^(studio_|app_|agent_|room_|shop_)/.test(t.name)||seen.has(t.name))throw new Error(`tool name is invalid, reserved or duplicated: ${t.name}`);
|
|
36
|
+
seen.add(t.name);
|
|
37
|
+
if(typeof t.description!=='string'||!t.description.trim()||typeof t.handler!=='function')throw new Error(`tool ${t.name} needs description and handler`);
|
|
38
|
+
if(!['owner','signed-in','public'].includes(t.audience)&&!(t.audience&&typeof t.audience.app==='string'&&typeof t.audience.role==='string'))throw new Error(`tool ${t.name} needs an audience`);
|
|
39
|
+
if(t.inputSchema?.type!=='object')throw new Error(`tool ${t.name} input schema must be an object`);
|
|
40
|
+
validator.getValidator(t.inputSchema);
|
|
41
|
+
if(t.kind!==undefined&&!['tool','prompt'].includes(t.kind))throw new Error(`tool ${t.name}: invalid kind`);
|
|
42
|
+
if(t.webhook&&(!/^[A-Z][A-Z0-9_]{0,63}$/.test(t.webhook.secret)||typeof t.webhook.person!=='string'||!t.webhook.person))throw new Error(`tool ${t.name}: webhook needs a named secret and account`);
|
|
43
|
+
for(const [name,event] of Object.entries(t.events??{})){
|
|
44
|
+
if(!/^external:[a-z][a-z0-9_-]{0,40}$/.test(name)||!event.game||!event.schema)throw new Error(`tool ${t.name}: invalid external event`);
|
|
45
|
+
validator.getValidator(event.schema);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Existing studios retain their Worker customization; install the small registry hook once.
|
|
49
|
+
const worker=join(root,'site/src/worker.mjs');
|
|
50
|
+
if(existsSync(worker)){
|
|
51
|
+
let text=readFileSync(worker,'utf8');
|
|
52
|
+
if(!text.includes('useTools(')){text += "\nimport { useTools } from '@homie-rocks/studio/worker';\nuseTools(async () => (await import('./tools/index.mjs')).default);\n";writeFileSync(worker,text);}
|
|
53
|
+
}
|
|
54
|
+
return defs.map(t=>t.name);
|
|
55
|
+
}
|