@homie-rocks/studio 0.37.2 → 0.39.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 +61 -0
- package/bin/homie-studio.mjs +16 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev.mjs +12 -1
- package/lib/mcp-tools.mjs +10 -0
- package/lib/perf.mjs +17 -10
- package/lib/port-check.mjs +101 -19
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/scaffold.mjs +19 -6
- package/lib/shoot.mjs +11 -5
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +85 -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 +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +17 -6
- package/port/probe.ts +2 -1
- package/rules/core.ts +3 -1
- package/rules/host.ts +3 -1
- package/rules/pack.ts +30 -3
- package/rules/view.ts +232 -40
- package/starters/coin-dash/lab.json +17 -0
- package/starters/coin-dash/src/rules.ts +3 -3
- package/starters/gem-rush/game.json +19 -4
- package/starters/gem-rush/map/main.json +50 -0
- package/starters/gem-rush/src/move.ts +23 -0
- package/starters/gem-rush/src/rules.ts +118 -0
- package/starters/gem-rush/src/view.ts +429 -0
- package/starters/gem-rush/tunables.json +98 -11
- 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/starters/gem-rush/src/main.ts +0 -1087
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/shoot.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*
|
|
28
28
|
* `--preview` (no --url): THE FRAMES HALF WITHOUT THE WHOLE SITE. It starts the light preview server itself
|
|
29
29
|
* (lib/preview.mjs: the one built game's files on 127.0.0.1, no Wrangler, no rooms), opens the game's own page there
|
|
30
|
-
* and shoots it.
|
|
30
|
+
* and shoots it. A game with offline play enabled plays alone, with its bots, which is what pictures of the game itself want. The
|
|
31
31
|
* smoke is NOT RUN (there is no room to meet in: that half needs `dev`), and there is no loading cover to wait for.
|
|
32
32
|
* The result says which build the frames are of (`build`: its hash, the one `build` printed) and that the page
|
|
33
33
|
* really loaded that build's hashed bundle (`bundle`: lib/build.mjs bundleOf, never a guess at assets/main.js).
|
|
@@ -274,7 +274,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
274
274
|
const inAll = (fn, arg) => Promise.all(all().map((f) => T(f.evaluate(fn, arg), 20_000)));
|
|
275
275
|
const frozen = await inAll(() => (window.__homieClock ? (window.__homieClock.freeze(), true) : false));
|
|
276
276
|
if (!frozen.some(Boolean)) { result.why = 'the virtual clock is not in the page (it was opened before the script could be injected)'; return result; }
|
|
277
|
-
if (hold)
|
|
277
|
+
if (hold) {
|
|
278
|
+
// The play page owns the top-level focus; the game's key listener lives in its iframe.
|
|
279
|
+
if (gf) await T(gf.evaluate(() => window.focus()), 5000);
|
|
280
|
+
await first.page.keyboard.down(hold).catch(() => {});
|
|
281
|
+
}
|
|
278
282
|
const rows = [];
|
|
279
283
|
for (let n = 1; n <= frames; n++) {
|
|
280
284
|
if (left() < 3000) { result.partial = `stopped at frame ${n - 1} of ${frames}: the ${Math.round(timeoutMs / 1000)} s bound was reached (a slow renderer; ask for fewer frames or a longer --timeout)`; break; }
|
|
@@ -287,6 +291,7 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
287
291
|
rows.push({ n, file, virtualMs: +(n * (1000 / fps)).toFixed(3), clockMs: nows.find((v) => Number.isFinite(v)) ?? null, realMs: Date.now() - t, ...(await frameFacts(first) ?? {}) });
|
|
288
292
|
}
|
|
289
293
|
if (hold) await first.page.keyboard.up(hold).catch(() => {});
|
|
294
|
+
result.rules = gf ? await T(gf.evaluate(() => { const p = window.__homieNet?.probe; return typeof p?.hosted === 'function' ? { hosted: p.hosted(), status: p.status?.(), tick: p.tick?.() } : null; }), 5000) : null;
|
|
290
295
|
result.frames = rows.length;
|
|
291
296
|
result.realSeconds = +((Date.now() - started) / 1000).toFixed(1);
|
|
292
297
|
result.virtualSeconds = +((rows.length * 1000) / fps / 1000).toFixed(3);
|
|
@@ -294,10 +299,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
294
299
|
result.limits = 'Stepped: requestAnimationFrame, performance.now, Date, setTimeout, setInterval, CSS and Web animations, in the page and the game\'s frame. NOT stepped: the room\'s socket and its server clock (other players and the round arrive in real time), Web Audio\'s clock, <video>, workers. These frames show what was drawn; they are not a frame rate.';
|
|
295
300
|
const probed = rows.some((r) => r.phase !== undefined);
|
|
296
301
|
if (!probed) result.note = [result.note, 'the game exposes no port probe (exposePort): the frames carry no round phase or position'].filter(Boolean).join('; ');
|
|
297
|
-
writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
|
|
298
302
|
const smokeOk = result.smoke.ok !== false;
|
|
299
|
-
result.
|
|
300
|
-
|
|
303
|
+
const rulesOk = !result.rules || (result.rules.status === 'playing' && result.rules.tick > 0);
|
|
304
|
+
result.ok = rows.length === frames && smokeOk && rulesOk && !result.errors.length;
|
|
305
|
+
if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : !rulesOk ? 'The rules game is not playing. A server-only build needs its room: run homie-studio dev and shoot with --url (or explicitly enable room.offline for offline play).' : result.errors.length ? `game errors: ${result.errors.join('; ')}` : result.partial;
|
|
306
|
+
writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
|
|
301
307
|
return result;
|
|
302
308
|
} finally {
|
|
303
309
|
if (served?.server) { served.server.closeAllConnections?.(); served.server.close(); }
|
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);
|
|
@@ -2112,6 +2112,91 @@
|
|
|
2112
2112
|
}
|
|
2113
2113
|
}
|
|
2114
2114
|
}
|
|
2115
|
+
},
|
|
2116
|
+
"0.38.0": {
|
|
2117
|
+
"files": {
|
|
2118
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2119
|
+
"README.md": "684b3cca8001673c",
|
|
2120
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2121
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2122
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2123
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2124
|
+
"posts/README.md": "37841aae476bd540",
|
|
2125
|
+
"site/README.md": "732894273a32dc3f",
|
|
2126
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
2127
|
+
},
|
|
2128
|
+
"sections": {
|
|
2129
|
+
"AGENTS.md": {
|
|
2130
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2131
|
+
"## Layout": [
|
|
2132
|
+
"8baf49e4291e7e88",
|
|
2133
|
+
"c18362ed28975d81"
|
|
2134
|
+
],
|
|
2135
|
+
"## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
|
|
2136
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
2137
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2138
|
+
"## Making games": "2da7fa74c8884834",
|
|
2139
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2140
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2141
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2142
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
2143
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2144
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2145
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2146
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2147
|
+
"## Beta": "9d02638207a26e73"
|
|
2148
|
+
}
|
|
2149
|
+
},
|
|
2150
|
+
"named": {
|
|
2151
|
+
"Homie Arcade": {
|
|
2152
|
+
"sections": {
|
|
2153
|
+
"AGENTS.md": {
|
|
2154
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
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
|
+
}
|
|
2115
2200
|
}
|
|
2116
2201
|
}
|
|
2117
2202
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {existsSync,mkdirSync,writeFileSync,readFileSync,chmodSync} from 'node:fs';
|
|
2
|
+
import {join,dirname} from 'node:path';
|
|
3
|
+
import {createServer} from 'node:http';
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import {spawn} from 'node:child_process';
|
|
6
|
+
export function newTool(root,name,{app=null}={}) {
|
|
7
|
+
if(!/^[a-z][a-z0-9-]{0,39}$/.test(name??'') || app!==null&&!/^[a-z][a-z0-9-]{0,39}$/.test(app))throw new Error('name uses lowercase letters, numbers and hyphens');
|
|
8
|
+
if(app&&!existsSync(join(root,'apps',app,'app.json')))throw new Error('unknown app');
|
|
9
|
+
const file=join(root,...(app?['apps',app,'tools']:['tools']),name+'.ts');if(existsSync(file))throw new Error('tool already exists');
|
|
10
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,`import { defineTool } from '@homie-rocks/studio/tools';\n\nexport default defineTool<{ message: string }>({\n name: '${name.replaceAll('-','_')}',\n description: 'Describe what this tool does and when to use it.',\n audience: ${app?JSON.stringify({app,role:'staff'}):"'owner'"},\n inputSchema: { type: 'object', properties: { message: { type: 'string', maxLength: 200 } }, required: ['message'], additionalProperties: false },\n async handler({ message }, context) {\n return { message, by: context.caller.id };\n },\n});\n`);
|
|
11
|
+
return {ok:true,command:'tool new',file,next:'Edit the definition, then homie-studio dev. Call it with homie-studio tool call '+name.replaceAll('-','_')+' --input \'{"message":"Hello"}\' --url http://localhost:8787'};
|
|
12
|
+
}
|
|
13
|
+
/** The official SDK owns discovery, registration, PKCE, refresh and issuer checks. */
|
|
14
|
+
export async function connectToolClient(root,site,{publicOnly=false,open=defaultOpen,log=()=>{}}={}) {
|
|
15
|
+
const {Client,StreamableHTTPClientTransport,auth}=await import('mcp-studio-client');
|
|
16
|
+
const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
|
|
17
|
+
const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
|
|
18
|
+
if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
|
|
19
|
+
const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
|
|
20
|
+
let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
|
|
21
|
+
// Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
|
|
22
|
+
if((state.tokens&&!state.tokens.issuer)||(state.client&&!state.client.issuer))state={};
|
|
23
|
+
const save=()=>{mkdirSync(dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(state),{mode:0o600});chmodSync(file,0o600);};
|
|
24
|
+
let accept;const returned=new Promise(resolve=>{accept=resolve;});
|
|
25
|
+
const csrf=randomUUID();
|
|
26
|
+
const server=createServer((req,res)=>{const back=new URL(req.url,'http://localhost');if(back.pathname!=='/callback'||back.searchParams.get('state')!==csrf){res.writeHead(400);res.end('Invalid callback');return;}res.end('Your AI is connected. You can close this tab.');accept(back);});
|
|
27
|
+
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
28
|
+
const redirect=`http://127.0.0.1:${server.address().port}/callback`;
|
|
29
|
+
const provider={redirectUrl:redirect,clientMetadata:{client_name:'Homie studio tool CLI',redirect_uris:[redirect],grant_types:['authorization_code','refresh_token'],response_types:['code'],token_endpoint_auth_method:'none'},state:()=>csrf,
|
|
30
|
+
clientInformation:()=>state.client,saveClientInformation:v=>{state.client=v;save();},tokens:()=>state.tokens,saveTokens:v=>{state.tokens=v;save();},
|
|
31
|
+
codeVerifier:()=>state.verifier,saveCodeVerifier:v=>{state.verifier=v;save();},
|
|
32
|
+
discoveryState:()=>state.discovery,saveDiscoveryState:v=>{state.discovery=v;save();},
|
|
33
|
+
invalidateCredentials:scope=>{if(scope==='all')state={};else {delete state[scope==='tokens'?'tokens':scope];if(scope==='tokens')delete state.client;}save();},
|
|
34
|
+
redirectToAuthorization:async u=>{log('Approve this connection in your browser.');await open(u.href);},
|
|
35
|
+
};
|
|
36
|
+
try{
|
|
37
|
+
// A new loopback port needs a matching registered redirect; refresh still uses the prior client.
|
|
38
|
+
if(!state.tokens){delete state.client;delete state.discovery;}
|
|
39
|
+
const result=await auth(provider,{serverUrl:url,scope:'studio offline_access'});
|
|
40
|
+
if(result==='REDIRECT'){
|
|
41
|
+
let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Browser approval timed out')),300000);});
|
|
42
|
+
const back=await Promise.race([returned,timeout]).finally(()=>clearTimeout(timer));
|
|
43
|
+
if(back.searchParams.has('error'))throw new Error('Connection declined');
|
|
44
|
+
await auth(provider,{serverUrl:url,authorizationCode:back.searchParams.get('code'),iss:back.searchParams.get('iss')??undefined});
|
|
45
|
+
}
|
|
46
|
+
await client.connect(new StreamableHTTPClientTransport(url,{authProvider:provider}));return client;
|
|
47
|
+
}finally{server.close();}
|
|
48
|
+
}
|
|
49
|
+
function defaultOpen(url){const cmd=process.platform==='darwin'?'open':process.platform==='win32'?'explorer':'xdg-open';const child=spawn(cmd,[url],{stdio:'ignore',detached:true});child.unref();}
|
|
50
|
+
export async function toolCommand(root,sub,positional,flags,{log=()=>{}}={}) {
|
|
51
|
+
if(sub==='new')return newTool(root,positional[2],{app:flags.get('app')??null});
|
|
52
|
+
if(!['call','list'].includes(sub))throw new Error('Use tool new <name>, tool list, or tool call <name> --input <JSON>');
|
|
53
|
+
const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',{publicOnly:flags.has('public'),log});
|
|
54
|
+
try{const result=sub==='list'?await client.listTools():await client.callTool({name:positional[2],arguments:JSON.parse(flags.get('input')??'{}')});return {ok:result.isError!==true,command:'tool '+sub,result,...(result.isError?{why:'The tool refused or failed; see its result.'}:{})};}finally{await client.close();}
|
|
55
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
|
|
2
|
+
import {join,relative,dirname} from 'node:path';
|
|
3
|
+
import {readConfig} from './routes.mjs';
|
|
4
|
+
import {paidReleases} from './parts-upload.mjs';
|
|
5
|
+
import {configPath} from './studio.mjs';
|
|
6
|
+
/** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
|
|
7
|
+
export async function buildWorker(root,esbuild) {
|
|
8
|
+
const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
|
|
9
|
+
const out=join(root,'site/src/runtime');
|
|
10
|
+
const alias={...(readConfig(root)?.alias??{})};
|
|
11
|
+
if(paidReleases(root).length)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
|
|
12
|
+
const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
|
|
13
|
+
rmSync(out,{recursive:true,force:true});
|
|
14
|
+
mkdirSync(out,{recursive:true});
|
|
15
|
+
for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
|
|
16
|
+
const config=configPath(root);
|
|
17
|
+
if(existsSync(config)){
|
|
18
|
+
const original=readFileSync(config,'utf8');
|
|
19
|
+
const json=JSON.parse(original.replace(/^\s*\/\/.*$/gm,''));
|
|
20
|
+
const before=JSON.stringify(json);
|
|
21
|
+
json.main=relative(dirname(config),join(out,'worker.js')).split('\\').join('/');
|
|
22
|
+
json.no_bundle=true;json.find_additional_modules=true;
|
|
23
|
+
json.rules=[...(json.rules??[]).filter(r=>!r.homieMcp),{type:'ESModule',globs:['**/*.js'],fallthrough:true}];
|
|
24
|
+
// No duplicate rule on rebuild.
|
|
25
|
+
json.rules=json.rules.filter((r,i,all)=>all.findIndex(x=>JSON.stringify(x)===JSON.stringify(r))===i);
|
|
26
|
+
json.compatibility_flags=[...new Set([...(json.compatibility_flags??[]),'nodejs_compat'])];
|
|
27
|
+
if(JSON.stringify(json)!==before) {
|
|
28
|
+
const comments=original.match(/^(?:\s*\/\/[^\n]*\n)*/)?.[0]??'';
|
|
29
|
+
writeFileSync(config,comments+JSON.stringify(json,null,2)+'\n');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
|
|
33
|
+
writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
|
|
34
|
+
return sizes;
|
|
35
|
+
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2151,12 +2151,26 @@ and holds that lead from `lead`. A step whose sample (or claim) changed, or that
|
|
|
2151
2151
|
makes an entry. A period with no entry sends nothing; after 250 ms without a frame the held values
|
|
2152
2152
|
are sent again as an ordinary entry. A hidden tab sends one neutral entry and stops stepping.
|
|
2153
2153
|
|
|
2154
|
-
**
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2154
|
+
**Server movement with immediate local prediction.** The host runs `move` for every body.
|
|
2155
|
+
The view library runs the same guarded module for its own player, one step per tick and one
|
|
2156
|
+
throwaway preview between ticks. A snapshot replaces that body's position, velocity, heading,
|
|
2157
|
+
grounded flag and motion; the view replays pending inputs from there. Late presses are mirrored
|
|
2158
|
+
onto the next replay tick while they remain inside the quarter-second window. Scores, pickups,
|
|
2159
|
+
collisions between entities and effects are always the host's decisions. Bot and AI bodies
|
|
2160
|
+
are interpolated, never predicted by a player's view.
|
|
2161
|
+
|
|
2162
|
+
Small errors fade as a visual offset with a nominal 100 ms blend. Overlapping small corrections
|
|
2163
|
+
queue their individual fades, so their accumulated offset is not erased all at once. A disagreement
|
|
2164
|
+
beyond three ticks of travel plays the corrected path from the snapshot at 1.25 times speed,
|
|
2165
|
+
showing the hold and slide of a knock. Offset fades are limited by progress along the drawn
|
|
2166
|
+
path and the body's declared maximum movement speed, so a late-input correction cannot erase more travel than the player makes or compound a fast knock's catch-up speed. Catch-up preserves the drawn pose when a snapshot replaces its history, and uses the next movement preview when its next whole-tick history entry is not yet available. When movement
|
|
2167
|
+
stops after catch-up, the remaining fade follows the configured blend time, still bounded by that speed. An effect on the body waits for its drawn tick. Placements, epoch changes and clock rebases
|
|
2168
|
+
jump deliberately. Other bodies keep the helper's adaptive interpolation, with one send period
|
|
2169
|
+
as the default minimum delay. The `room.predict` settings customize these defaults.
|
|
2170
|
+
|
|
2171
|
+
`body: { move: 'owner' }` remains available for existing rules games: the browser supplies its
|
|
2172
|
+
position and the host limits each claim to `maxSpeed`. It cannot prevent a modified owner walking
|
|
2173
|
+
through a wall. New Coin Dash and Gem Rush use authoritative server movement instead.
|
|
2160
2174
|
|
|
2161
2175
|
### 29.3 The life of a server-hosted room
|
|
2162
2176
|
|
package/netplay/netplay.ts
CHANGED
|
@@ -631,6 +631,8 @@ export interface NetplayOptions<C = unknown> {
|
|
|
631
631
|
checkpointMs?: number;
|
|
632
632
|
/** Fixed interpolation delay in ms. Default: adaptive (arrival age p90 + 1.2 intervals). */
|
|
633
633
|
interpDelayMs?: number;
|
|
634
|
+
/** Floor under the adaptive interpolation buffer (rules views). */
|
|
635
|
+
interpFloorMs?: number;
|
|
634
636
|
/** Host: return the full rules state. Called every `checkpointMs`, before yielding, and on pagehide. */
|
|
635
637
|
checkpoint?: () => C;
|
|
636
638
|
/** ms to wait for a welcome before falling back to offline host, when the socket could not even open. Default 4000. */
|
|
@@ -2947,7 +2949,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2947
2949
|
|
|
2948
2950
|
function interpDelay(): number {
|
|
2949
2951
|
if (opts.interpDelayMs !== undefined) return opts.interpDelayMs;
|
|
2950
|
-
return delaySmooth || Math.max(50, iv * 1.6 + 8);
|
|
2952
|
+
return Math.max(opts.interpFloorMs ?? 0, delaySmooth || Math.max(50, iv * 1.6 + 8));
|
|
2951
2953
|
}
|
|
2952
2954
|
|
|
2953
2955
|
// ---------------------------------------------------------------- connect
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -84,7 +84,11 @@
|
|
|
84
84
|
"types": "./apps/apps.ts",
|
|
85
85
|
"default": "./apps/apps.ts"
|
|
86
86
|
},
|
|
87
|
-
"./worker/selling": "./worker/selling/index.mjs"
|
|
87
|
+
"./worker/selling": "./worker/selling/index.mjs",
|
|
88
|
+
"./tools": {
|
|
89
|
+
"types": "./tools/tools.ts",
|
|
90
|
+
"default": "./tools/tools.ts"
|
|
91
|
+
}
|
|
88
92
|
},
|
|
89
93
|
"files": [
|
|
90
94
|
"bin/",
|
|
@@ -111,7 +115,8 @@
|
|
|
111
115
|
"NOTICE",
|
|
112
116
|
"links/",
|
|
113
117
|
"apps/",
|
|
114
|
-
"app-starters/"
|
|
118
|
+
"app-starters/",
|
|
119
|
+
"tools/"
|
|
115
120
|
],
|
|
116
121
|
"engines": {
|
|
117
122
|
"node": ">=22"
|
|
@@ -122,17 +127,22 @@
|
|
|
122
127
|
"@babel/parser": "8.0.7",
|
|
123
128
|
"@babel/traverse": "8.0.7",
|
|
124
129
|
"@babel/types": "8.0.6",
|
|
130
|
+
"@cloudflare/workers-oauth-provider": "1.2.3",
|
|
125
131
|
"@coinbase/x402": "2.1.0",
|
|
126
132
|
"@gltf-transform/core": "4.5.1",
|
|
127
133
|
"@gltf-transform/extensions": "4.5.1",
|
|
128
134
|
"@gltf-transform/functions": "4.5.1",
|
|
129
135
|
"@jridgewell/trace-mapping": "0.3.31",
|
|
130
|
-
"@modelcontextprotocol/sdk": "1.
|
|
136
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
137
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
131
138
|
"@x402/core": "2.28.0",
|
|
132
139
|
"@x402/evm": "2.28.0",
|
|
140
|
+
"agents": "0.28.0",
|
|
133
141
|
"esbuild": "0.28.1",
|
|
134
142
|
"gltf-validator": "2.0.0-dev.3.10",
|
|
135
143
|
"jose": "6.2.12",
|
|
144
|
+
"mcp-purchase-sdk": "npm:@modelcontextprotocol/sdk@1.32.1",
|
|
145
|
+
"mcp-studio-client": "npm:@modelcontextprotocol/client@2.3.1",
|
|
136
146
|
"meshoptimizer": "1.3.0",
|
|
137
147
|
"mppx": "0.13.2",
|
|
138
148
|
"puppeteer-core": "25.8.0",
|
|
@@ -140,10 +150,11 @@
|
|
|
140
150
|
"spdx-expression-parse": "5.0.0",
|
|
141
151
|
"stripe": "23.0.0",
|
|
142
152
|
"three": "0.185.1",
|
|
153
|
+
"typescript": "5.9.3",
|
|
143
154
|
"viem": "2.57.4",
|
|
144
|
-
"
|
|
155
|
+
"zod": "4.3.6"
|
|
145
156
|
},
|
|
146
157
|
"scripts": {
|
|
147
|
-
"test": "node --test test/*.test.mjs"
|
|
158
|
+
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
|
|
148
159
|
}
|
|
149
160
|
}
|
package/port/probe.ts
CHANGED
|
@@ -123,7 +123,8 @@ export function exposePort(net: Netplay<unknown, unknown, unknown> | null, opts:
|
|
|
123
123
|
rows.push([t, me ? me.x : NaN, me ? me.y : NaN, b.right[0], b.right[1], b.up[0], b.up[1], m, busy]);
|
|
124
124
|
if (rows.length > RING) rows.splice(0, rows.length - RING);
|
|
125
125
|
};
|
|
126
|
-
|
|
126
|
+
// The position is sampled now; rAF may carry an earlier timestamp on a busy renderer.
|
|
127
|
+
const loop = (): void => { sample(performance.now()); requestAnimationFrame(loop); };
|
|
127
128
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(loop);
|
|
128
129
|
const probe: PortProbe = {
|
|
129
130
|
v: 1,
|
package/rules/core.ts
CHANGED
|
@@ -177,7 +177,7 @@ export interface Core {
|
|
|
177
177
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
178
178
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
179
179
|
*/
|
|
180
|
-
export function createCore(c: Compiled, opts: { observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
180
|
+
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
181
181
|
if (c.dims !== 2) throw new Error('this release runs rules with space.dims: 2; bodies with height (dims: 3) arrive in a later one');
|
|
182
182
|
const dims = c.dims;
|
|
183
183
|
const tickHz = c.settings.tickHz;
|
|
@@ -1002,6 +1002,7 @@ export function createCore(c: Compiled, opts: { observe?: (kind: string, handler
|
|
|
1002
1002
|
}
|
|
1003
1003
|
if (!k.move) continue;
|
|
1004
1004
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1005
|
+
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1005
1006
|
moveRadius = body.radius;
|
|
1006
1007
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
1007
1008
|
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
@@ -1011,6 +1012,7 @@ export function createCore(c: Compiled, opts: { observe?: (kind: string, handler
|
|
|
1011
1012
|
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1012
1013
|
G.note = null;
|
|
1013
1014
|
e.grounded = own(b, 'grounded') === true;
|
|
1015
|
+
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
|
|
1014
1016
|
}
|
|
1015
1017
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
1016
1018
|
const list = [...ents.values()];
|
package/rules/host.ts
CHANGED
|
@@ -58,6 +58,8 @@ export interface HostClock {
|
|
|
58
58
|
}
|
|
59
59
|
export interface HostOptions {
|
|
60
60
|
game: string;
|
|
61
|
+
/** The build check compares a movement step before any tick handler can change it. */
|
|
62
|
+
moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
|
|
61
63
|
/** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
|
|
62
64
|
observe?(kind: string, handler: string, error?: string): void;
|
|
63
65
|
/** Told of a written value the runtime changed to make it fit (core.ts `noted`): the build check listens. */
|
|
@@ -226,7 +228,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
226
228
|
}
|
|
227
229
|
}
|
|
228
230
|
if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
229
|
-
const core = createCore(c, { observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
231
|
+
const core = createCore(c, { moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
230
232
|
let epoch = core.epoch;
|
|
231
233
|
if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
232
234
|
let lastSaveAt = o.clock.now();
|
package/rules/pack.ts
CHANGED
|
@@ -263,12 +263,39 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
|
|
|
263
263
|
*/
|
|
264
264
|
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
|
|
265
265
|
G.left = quota;
|
|
266
|
+
const bag = coerceFields(motion, body.motion, dims);
|
|
267
|
+
const target: Record<string, unknown> = {};
|
|
268
|
+
const work: Record<string, unknown> = {};
|
|
269
|
+
// Match core.typed: scalar writes round immediately; collections copy on first read and settle at the end.
|
|
270
|
+
for (const [name, fd] of motion) {
|
|
271
|
+
const held = (v: unknown): unknown => { naming('body.motion', name); const out = coerce(fd, v, dims); naming(); return out; };
|
|
272
|
+
Object.defineProperty(target, name, { enumerable: true,
|
|
273
|
+
get: () => {
|
|
274
|
+
if (!mutable(fd)) return bag[name];
|
|
275
|
+
if (work[name] === undefined) {
|
|
276
|
+
const size = fd.t === 'list' ? 1 + (bag[name] as unknown[]).length * cellsOf(fd.of as Field)
|
|
277
|
+
: fd.t === 'map' ? 1 + Object.keys(bag[name] as object).length * (1 + cellsOf(fd.of as Field)) : cellsOf(fd);
|
|
278
|
+
charge(6 * size); work[name] = thaw(fd, bag[name]);
|
|
279
|
+
}
|
|
280
|
+
return work[name];
|
|
281
|
+
},
|
|
282
|
+
set: (v: unknown) => {
|
|
283
|
+
charge(mutable(fd) ? 6 * est(fd, v) : cellsOf(fd) === 1 ? 3 : 6 * cellsOf(fd));
|
|
284
|
+
bag[name] = held(v); work[name] = undefined;
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
body.motion = Object.preventExtensions(target);
|
|
266
289
|
try { fn(body, input, ctx); } catch (error) { failed(error); }
|
|
290
|
+
try {
|
|
291
|
+
for (const [name, fd] of motion) if (work[name] !== undefined) {
|
|
292
|
+
charge(6 * est(fd, work[name])); naming('body.motion', name); bag[name] = coerce(fd, work[name], dims); naming();
|
|
293
|
+
}
|
|
294
|
+
} catch (error) { failed(error); }
|
|
267
295
|
const used = quota - (G.left > 0 ? G.left : 0);
|
|
268
296
|
G.left = Infinity;
|
|
269
|
-
naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims);
|
|
270
|
-
|
|
271
|
-
return { pos, vel, heading, grounded: body.grounded === true, motion: held, used };
|
|
297
|
+
naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims); naming();
|
|
298
|
+
return { pos, vel, heading, grounded: body.grounded === true, motion: thawFields(motion, bag), used };
|
|
272
299
|
}
|
|
273
300
|
|
|
274
301
|
/** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */
|