@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/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,67 @@ 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.39.0] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.39.0** · [#78](https://github.com/homie-rocks/homie/pull/78)
|
|
23
|
+
|
|
24
|
+
Your studio has its own MCP server. Connect your AI with one browser approval and add tools for the work your studio does.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Worker builds keep MCP libraries in separate modules, loaded when that surface is used. Ordinary page traffic performs no MCP storage work.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
41
|
+
- 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.
|
|
42
|
+
- 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.
|
|
43
|
+
|
|
44
|
+
### Upgrade notes
|
|
45
|
+
|
|
46
|
+
- 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.
|
|
47
|
+
- Studio tools are trusted code in your Worker. Review tools received as parts before adding them. The tool context does not expose raw credentials.
|
|
48
|
+
|
|
49
|
+
## [0.38.0] - 2026-10-09
|
|
50
|
+
|
|
51
|
+
**Plugin 0.39.0**
|
|
52
|
+
|
|
53
|
+
Your own character answers as soon as you press, even when the room runs on the server.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- Rules games predict your own movement with the same movement code the host runs. They ease corrections from the host and smoothly draw everyone else. Authors get this through the view library.
|
|
58
|
+
- The build checks that browser movement agrees with the server and stays within its budget.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- New Gem Rush games use rules plus view, with the server keeping movement, pickups and scores. The canvas, controls, waves and knockback keep their familiar look and play. Coin Dash also uses server movement with prediction.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- Rules prediction keeps movement continuous when snapshots replace its catch-up path or its input clock recalibrates, fixing backward steps on delayed, lossy connections.
|
|
67
|
+
- A rules correction that catches up to the current fractional tick uses the next movement preview instead of holding still and jumping when the next whole tick arrives. Snapshots also preserve the drawn catch-up pose when they replace its history. Offset fading is bounded by normal movement speed, so it does not compound a fast knock's catch-up speed.
|
|
68
|
+
- Gem Rush reports its downward Y axis to the rules probe, so control checks judge its actual on-screen direction. Rules views can supply their camera axes through `screenBasis`.
|
|
69
|
+
- Playtest no longer attributes its default timeout duration to a rules game that declares no round duration in `game.json`.
|
|
70
|
+
- Perf checks body presence in the last drawn frame without imposing a hidden 400 ms frame-rate requirement on software rendering.
|
|
71
|
+
- Studio checks accept two replicas when the server hosts the rules. Perf reports and regression guards include both replicas, and choose a replica as the default goal for server rooms.
|
|
72
|
+
- Shoot sends held keys to the game frame and reports a server-only preview without a running room as not playing. Its saved receipt now includes the final verdict.
|
|
73
|
+
- The Game Lab exits after writing its completed report, even when Chrome leaves a pipe open. Coin Dash includes a reproducible Lab take.
|
|
74
|
+
- Control timing starts at the game frame's receipt of a key or completed thumb gesture, judges the first changed frame, collects enough frames on a slow renderer, and measures the round clock between matching observations (including slow screenshots). The response and steering limits are unchanged.
|
|
75
|
+
- The controls check exercises a browser disconnect and late joining in server rooms as well as host takeover in browser rooms. A hold with too little runway is repeated after moving away from the wall, with the same steering requirements.
|
|
76
|
+
|
|
77
|
+
### Upgrade notes
|
|
78
|
+
|
|
79
|
+
- Existing games keep their own source when you upgrade. Games written the old way keep working.
|
|
80
|
+
|
|
20
81
|
## [0.37.2] - 2026-10-09
|
|
21
82
|
|
|
22
83
|
**Plugin 0.38.2** · [#70](https://github.com/homie-rocks/homie/pull/70)
|
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)
|
|
@@ -336,7 +338,7 @@ import { publish, publishBefore } from '../lib/directory.mjs';
|
|
|
336
338
|
import { look } from '../lib/look.mjs';
|
|
337
339
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
338
340
|
import { portCheck } from '../lib/port-check.mjs';
|
|
339
|
-
import {
|
|
341
|
+
import { perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
|
|
340
342
|
import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
|
|
341
343
|
import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
|
|
342
344
|
import { newStudio } from '../lib/scaffold.mjs';
|
|
@@ -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
|
};
|
|
@@ -878,11 +889,12 @@ async function main() {
|
|
|
878
889
|
}
|
|
879
890
|
if (cmd === 'perf' && sub === 'compare') {
|
|
880
891
|
if (!positional[2] || !positional[3]) return { ok: false, command: 'perf compare', why: 'usage: homie-studio perf compare <before dir> <after dir> [--goal <metric>] [--min 3]' };
|
|
881
|
-
return perfCompare(positional[2], positional[3], { goal: String(flags.get('goal')
|
|
892
|
+
return perfCompare(positional[2], positional[3], { goal: flags.has('goal') ? String(flags.get('goal')) : null, min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
|
|
882
893
|
}
|
|
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 });
|
|
@@ -1327,7 +1339,7 @@ try {
|
|
|
1327
1339
|
if (result && result.command !== 'help') print(result);
|
|
1328
1340
|
if (result?.ok === false) process.exitCode = 1;
|
|
1329
1341
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
1330
|
-
if (['check', 'port check', 'look', 'perf', 'shoot'].includes(result?.command)) process.exit(process.exitCode ?? 0);
|
|
1342
|
+
if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
|
|
1331
1343
|
} catch (error) {
|
|
1332
1344
|
await flushProgress().catch(() => {});
|
|
1333
1345
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
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');
|
|
@@ -689,6 +690,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
689
690
|
...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
|
|
690
691
|
...(studio.referrals === false ? { referrals: false } : {}),
|
|
691
692
|
...(lounge ? { lounge } : {}),
|
|
693
|
+
...(studio.mcp ? { mcp: studio.mcp } : {}),
|
|
692
694
|
build: buildInfo(root),
|
|
693
695
|
},
|
|
694
696
|
games: rows,
|
|
@@ -723,6 +725,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
723
725
|
// site, never before: a game that did not build has left both as they were.
|
|
724
726
|
await beforePublish();
|
|
725
727
|
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
|
|
728
|
+
await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
|
|
726
729
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
727
730
|
const swapped = swapIn(dist, live);
|
|
728
731
|
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/perf.mjs
CHANGED
|
@@ -194,6 +194,17 @@ export function renderCostOf(samples) {
|
|
|
194
194
|
return { samples: Math.max(calls.length, tris.length), drawCalls: sum(calls), triangles: sum(tris) };
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/** Presence in the last drawn frame is independent of its frame rate. Runs measure frame timing separately. */
|
|
198
|
+
export function gameReadiness() {
|
|
199
|
+
const p = window.__homiePort;
|
|
200
|
+
let self = null;
|
|
201
|
+
if (p && p.view !== 'board') {
|
|
202
|
+
const rows = p.rows(); const last = rows[rows.length - 1];
|
|
203
|
+
self = Boolean(last && Number.isFinite(last[1]) && Number.isFinite(last[2]));
|
|
204
|
+
}
|
|
205
|
+
return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
|
|
206
|
+
}
|
|
207
|
+
|
|
197
208
|
/** Open one browser of a run on the play page, instrumented, and wait until it is playable. */
|
|
198
209
|
async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
|
|
199
210
|
const dev = PERF_DEVICES[device];
|
|
@@ -243,12 +254,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
|
|
|
243
254
|
shell = await T(page.evaluate(() => { const s = window.__shell; const a = s?.arrival; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, arrival: a ? { phase: a.phase, lookMs: a.lookMs, liftedMs: a.liftedMs, by: a.by, mode: a.mode, explicitMs: a.explicitMs, lateMs: a.lateMs } : null } : null; }), 5000);
|
|
244
255
|
const now = Date.now();
|
|
245
256
|
if (seatedAt === null && shell?.room && Number.isInteger(shell.seat) && shell.role) seatedAt = now;
|
|
246
|
-
const g = await inGame(h,
|
|
247
|
-
const p = window.__homiePort;
|
|
248
|
-
let self = null;
|
|
249
|
-
if (p && p.view !== 'board') { const r = p.rows(p.now() - 400); const last = r[r.length - 1]; self = last ? Number.isFinite(last[1]) : false; }
|
|
250
|
-
return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
|
|
251
|
-
});
|
|
257
|
+
const g = await inGame(h, gameReadiness);
|
|
252
258
|
// The play page's arrival card (0.26.0) covers the game until the game says it is playable: not playable before.
|
|
253
259
|
const lifted = !shell?.arrival || shell.arrival.phase === 'done';
|
|
254
260
|
if (seatedAt !== null && g?.first !== null && g?.first !== undefined && lifted) {
|
|
@@ -651,7 +657,7 @@ function arrivalWords(summary, device, role, readyMs) {
|
|
|
651
657
|
function headlineOf(summary) {
|
|
652
658
|
const out = [];
|
|
653
659
|
for (const device of summary.devices) {
|
|
654
|
-
for (const role of ['host', 'replica']) {
|
|
660
|
+
for (const role of ['host', 'replica', 'replica-2']) {
|
|
655
661
|
const m = (k) => summary.metrics[`${device}.${role}.${k}`]?.median;
|
|
656
662
|
if (m('frame.p50') === undefined) continue;
|
|
657
663
|
out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; first look at ${m('load.look') ?? '?'} ms, playable (control-ready) at ${m('load.playable') ?? '?'} ms${arrivalWords(summary, device, role, m('load.ready'))}; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB; fetched before playable (measured) ${m('load.prePlayKb') ?? '?'} KB; renderer ${m('render.calls') !== undefined ? `${m('render.calls')} draw calls, ${m('render.triangles') ?? '?'} triangles (measured while playing)` : 'cost not exposed by the game (not measured)'}`);
|
|
@@ -749,8 +755,8 @@ export function readRuns(dir) {
|
|
|
749
755
|
/** The metrics guarded by default: frame time and main-thread work of each role, time to playable and heap. */
|
|
750
756
|
export function defaultGuards(devices, goal) {
|
|
751
757
|
const g = [];
|
|
752
|
-
for (const d of devices) for (const role of ['host', 'replica']) g.push(`${d}.${role}.frame.p95`, `${d}.${role}.busy`, `${d}.${role}.load.playable`);
|
|
753
|
-
for (const d of devices) g.push(`${d}.
|
|
758
|
+
for (const d of devices) for (const role of ['host', 'replica', 'replica-2']) g.push(`${d}.${role}.frame.p95`, `${d}.${role}.busy`, `${d}.${role}.load.playable`);
|
|
759
|
+
for (const d of devices) for (const role of ['host', 'replica', 'replica-2']) g.push(`${d}.${role}.heap`, `${d}.${role}.net.kbOut`);
|
|
754
760
|
return g.filter((k) => k !== goal);
|
|
755
761
|
}
|
|
756
762
|
|
|
@@ -758,10 +764,11 @@ export function defaultGuards(devices, goal) {
|
|
|
758
764
|
* `homie-studio perf compare <before> <after>`: the goal metric and the guards, before against after, from the runs
|
|
759
765
|
* in two folders. A run that was blocked, taken on a busy computer or profiled is left out (and counted).
|
|
760
766
|
*/
|
|
761
|
-
export function perfCompare(beforeDir, afterDir, { goal =
|
|
767
|
+
export function perfCompare(beforeDir, afterDir, { goal = null, guards = null, also = [], min = 0.03, write = true } = {}) {
|
|
762
768
|
const A = readRuns(beforeDir);
|
|
763
769
|
const B = readRuns(afterDir);
|
|
764
770
|
if (!A.runs.length || !B.runs.length) return { ok: false, command: 'perf compare', why: `no runs in ${!A.runs.length ? beforeDir : afterDir} (folders that homie-studio perf wrote)` };
|
|
771
|
+
goal ??= A.runs.every((r) => r.room?.hosted === 'server') ? DEFAULT_GOAL.replace('.host.', '.replica.') : DEFAULT_GOAL;
|
|
765
772
|
const usable = (r) => !r.blocked && !r.loaded;
|
|
766
773
|
const left = { before: A.runs.filter((r) => !usable(r)).length, after: B.runs.filter((r) => !usable(r)).length };
|
|
767
774
|
const flat = (runs, sizes) => {
|
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
|
|
|
@@ -60,6 +64,25 @@ function yawDeg(a, b) {
|
|
|
60
64
|
const d = a[5] * b[5] + a[6] * b[6]; const la = Math.hypot(a[5], a[6]) || 1; const lb = Math.hypot(b[5], b[6]) || 1;
|
|
61
65
|
return (Math.acos(Math.max(-1, Math.min(1, d / (la * lb)))) * 180) / Math.PI;
|
|
62
66
|
}
|
|
67
|
+
/** A server room keeps its authority and advances; a browser room must promote a host. */
|
|
68
|
+
export function roomContinued(before, after, serverHosted) {
|
|
69
|
+
return serverHosted
|
|
70
|
+
? after?.hosted === 'server' && after?.role === 'replica' && Number.isFinite(before?.tick) && after?.tick > before.tick && after?.frames > before.frames
|
|
71
|
+
: after?.role === 'host';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Compare two observations on the same browser clock, including time spent taking screenshots. */
|
|
75
|
+
export function roundClockContinued(before, after) {
|
|
76
|
+
return !before?.round || !after?.round || before.round.n !== after.round.n
|
|
77
|
+
|| Math.abs((before.round.leftMs - after.round.leftMs) - (after.observedAt - before.observedAt)) < 6000;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A wall can leave too little runway; retry the same hold after moving away, never excuse steering faults. */
|
|
81
|
+
export function needsRunway(res) {
|
|
82
|
+
return !res.ok && (res.why === 'barely moved' || res.why?.startsWith('blocked at once'))
|
|
83
|
+
&& res.yawChangeDeg < 10 && (res.dirErrDeg === null || res.dirErrDeg < 15);
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
const finite = (r) => r && Number.isFinite(r[1]) && Number.isFinite(r[2]);
|
|
64
87
|
/** A frame the player steers (the probe's `busy` says when a knockback, a stun or a respawn has the body). */
|
|
65
88
|
const steerable = (r) => finite(r) && !r[8];
|
|
@@ -134,6 +157,9 @@ export function judgePresses(rows, presses, size, view) {
|
|
|
134
157
|
const out = presses.map((p) => {
|
|
135
158
|
const seg = rows.filter((r) => r[0] >= p.a && r[0] <= p.b + 60 && steerable(r));
|
|
136
159
|
if (seg.length < 4) return rows.some((r) => r[0] >= p.a && r[0] <= p.b && finite(r) && r[8]) ? { dir: p.dir, ok: false, blocked: true, busy: true } : { dir: p.dir, ok: false, why: 'no samples' };
|
|
160
|
+
// The first post-input frame can already show the response. Keep its preceding drawn pose.
|
|
161
|
+
const before = rows.filter((r) => r[0] < p.a).at(-1);
|
|
162
|
+
if (before && steerable(before)) seg.unshift(before);
|
|
137
163
|
const u = DIRV[p.dir]; const r0 = seg[0];
|
|
138
164
|
let matchAt = null, after = 0, afterOk = 0, maxYaw = 0, peakAlong = 0, contact = false;
|
|
139
165
|
for (let i = 1; i < seg.length; i++) {
|
|
@@ -258,7 +284,7 @@ async function webkit(pw) {
|
|
|
258
284
|
const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
|
|
259
285
|
async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
|
|
260
286
|
const shell = (h) => T(h.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, results: s.results ?? [], facts: s.facts ? { counts: s.facts.counts } : null } : null; }), 8000, null);
|
|
261
|
-
const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
|
|
287
|
+
const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
|
|
262
288
|
const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
|
|
263
289
|
const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
|
|
264
290
|
|
|
@@ -285,13 +311,14 @@ async function focusGame(h) {
|
|
|
285
311
|
|
|
286
312
|
/* ------------------------------------------------------------------ input */
|
|
287
313
|
|
|
288
|
-
async function holdKey(h, code, ms) { await h.page.keyboard.down(code); await sleep(ms); await h.page.keyboard.up(code); }
|
|
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); }
|
|
289
315
|
|
|
290
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])]; }
|
|
291
|
-
async function touchDrag(h, from, dir, reach, holdMs) {
|
|
317
|
+
async function touchDrag(h, from, dir, reach, holdMs, ready) {
|
|
292
318
|
const [x0, y0] = from; const u = DIRV[dir];
|
|
293
319
|
await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
|
|
294
320
|
for (let k = 1; k <= 8; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / 8), y: Math.round(y0 + (u[1] * reach * k) / 8) }]); await sleep(12); }
|
|
321
|
+
if (ready) await ready();
|
|
295
322
|
const end = Date.now() + holdMs;
|
|
296
323
|
let w = 0;
|
|
297
324
|
while (Date.now() < end) {
|
|
@@ -302,6 +329,50 @@ async function touchDrag(h, from, dir, reach, holdMs) {
|
|
|
302
329
|
}
|
|
303
330
|
await h.touch('touchEnd', h.touchAll ? [] : [{ id: 1, x: Math.round(x0 + u[0] * reach), y: Math.round(y0 + u[1] * reach) }]);
|
|
304
331
|
}
|
|
332
|
+
/** Timestamp real input delivery, then keep it held until four frames can judge its direction.
|
|
333
|
+
* The response limit stays 600 ms; slow automation delivery and the thumb's ramp are not game latency. */
|
|
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
|
+
}
|
|
344
|
+
await inFrame(h, `(() => {
|
|
345
|
+
if (!window.__homieCheckInput) {
|
|
346
|
+
const state = window.__homieCheckInput = {};
|
|
347
|
+
addEventListener('keydown', e => { if (state.code === e.code && state.key === null) state.key = performance.now(); }, true);
|
|
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 });
|
|
352
|
+
}
|
|
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} });
|
|
354
|
+
})()`);
|
|
355
|
+
let a = null, b = null;
|
|
356
|
+
const ready = async () => {
|
|
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);
|
|
365
|
+
if (!Number.isFinite(a)) throw new Error('the game frame did not receive the measured input');
|
|
366
|
+
await sleep(420);
|
|
367
|
+
const until = Date.now() + 3000;
|
|
368
|
+
while (Date.now() < until && (await rowsSince(h, a)).length < 4) await sleep(50);
|
|
369
|
+
b = await frameNow(h);
|
|
370
|
+
};
|
|
371
|
+
if (how === 'keys') await holdKey(h, code, 0, ready);
|
|
372
|
+
else await touchDrag(h, from, dir, 70, 0, ready);
|
|
373
|
+
return { dir, a, b };
|
|
374
|
+
}
|
|
375
|
+
|
|
305
376
|
async function swipe(h, dir) {
|
|
306
377
|
const x0 = Math.round(h.vp.width * 0.5); const y0 = Math.round(h.vp.height * 0.55); const u = DIRV[dir]; const reach = Math.round(Math.min(h.vp.width, h.vp.height) * 0.3);
|
|
307
378
|
await h.touch('touchStart', [{ id: 2, x: x0, y: y0 }]);
|
|
@@ -365,11 +436,22 @@ async function ownerTests(h, how, log) {
|
|
|
365
436
|
const holds = view === 'side' ? ['right', 'left'] : ['down', 'left'];
|
|
366
437
|
out.holds = [];
|
|
367
438
|
for (const dir of holds) {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
439
|
+
const hold = async () => {
|
|
440
|
+
const a = await frameNow(h);
|
|
441
|
+
if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
|
|
442
|
+
const b = await frameNow(h);
|
|
443
|
+
await sleep(250);
|
|
444
|
+
return judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
|
|
445
|
+
};
|
|
446
|
+
let res = await hold();
|
|
447
|
+
if (needsRunway(res)) {
|
|
448
|
+
const first = res;
|
|
449
|
+
const away = { up: 'down', down: 'up', left: 'right', right: 'left' }[dir];
|
|
450
|
+
log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${first.why}; move ${away} and retry the same hold`);
|
|
451
|
+
if (how === 'keys') await holdKey(h, keys[away], 1100); else await touchDrag(h, thumbAt(h, i), away, 70, 1100);
|
|
452
|
+
await sleep(400);
|
|
453
|
+
res = { ...await hold(), runwayRetry: first };
|
|
454
|
+
}
|
|
373
455
|
out.holds.push(res);
|
|
374
456
|
log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${res.ok ? 'ok' : `FAIL (${res.why})`} travel ${res.travelBodies} bodies, yaw ${res.yawChangeDeg}°`);
|
|
375
457
|
await sleep(400);
|
|
@@ -383,12 +465,9 @@ async function ownerTests(h, how, log) {
|
|
|
383
465
|
const presses = [];
|
|
384
466
|
const a0 = await frameNow(h);
|
|
385
467
|
const until = Date.now() + 10_000;
|
|
386
|
-
for (let k = 0; Date.now() < until; k++) {
|
|
468
|
+
for (let k = 0; k < alt.length || Date.now() < until; k++) {
|
|
387
469
|
const dir = alt[k % alt.length];
|
|
388
|
-
|
|
389
|
-
if (how === 'keys') await holdKey(h, keys[dir], 420); else await touchDrag(h, thumbAt(h, i), dir, 70, 380);
|
|
390
|
-
const b = await frameNow(h);
|
|
391
|
-
presses.push({ dir, a, b });
|
|
470
|
+
presses.push(await measuredPress(h, how, keys[dir], thumbAt(h, i), dir));
|
|
392
471
|
await sleep(170);
|
|
393
472
|
}
|
|
394
473
|
await sleep(200);
|
|
@@ -595,10 +674,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
595
674
|
if (!oa.ok || !ob.ok) { row('host-kill', false, { why: 'a browser was never seated' }); row('late-join', null, { why: 'skipped: no room' }); collect(A); collect(B); await close(A); await close(B); }
|
|
596
675
|
else {
|
|
597
676
|
await sleep(3000);
|
|
598
|
-
const ia = await info(A);
|
|
599
|
-
|
|
677
|
+
const ia = await info(A); let ib = await info(B);
|
|
678
|
+
const serverHosted = ia?.hosted === 'server' && ib?.hosted === 'server';
|
|
679
|
+
if (ia?.role !== 'host' && !serverHosted) row('host-kill', null, { why: `the first browser was not the host (it was ${ia?.role}); skipped`, ia: ia?.role, ib: ib?.role });
|
|
600
680
|
else {
|
|
601
681
|
await shot(B, 'life-before');
|
|
682
|
+
// Screenshot encoding may span a round on a software renderer. Observe the room we actually leave.
|
|
683
|
+
ib = await info(B);
|
|
602
684
|
const pids = A.pid ? [A.pid, ...descendants(A.pid)] : [];
|
|
603
685
|
const killAt = Date.now();
|
|
604
686
|
for (const p of pids) { try { process.kill(p, 'SIGKILL'); } catch { /* gone */ } }
|
|
@@ -606,14 +688,14 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
606
688
|
collect(A);
|
|
607
689
|
void A.close();
|
|
608
690
|
let promoted = null; let ibAfter = null;
|
|
609
|
-
while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (ibAfter
|
|
691
|
+
while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (roomContinued(ib, ibAfter, serverHosted)) { promoted = Date.now() - killAt; break; } await sleep(100); }
|
|
610
692
|
await sleep(1500);
|
|
611
693
|
const ib2 = await info(B);
|
|
612
694
|
const sameRound = ib?.round && ib2?.round && (ib2.round.n === ib.round.n || (ib.round.phase === 'over' || ib.round.leftMs < 3000));
|
|
613
|
-
const clockOk =
|
|
695
|
+
const clockOk = roundClockContinued(ib, ib2);
|
|
614
696
|
const running = (ib2?.frames ?? 0) > (ibAfter?.frames ?? 0) && (ib2?.net?.snapHzOut ?? 0) >= 0;
|
|
615
697
|
await shot(B, 'life-after-kill');
|
|
616
|
-
row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { promotedMs: promoted, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? 'nobody took over as host within 15 s' : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
|
|
698
|
+
row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { hosted: serverHosted ? 'server' : 'browser', promotedMs: serverHosted ? null : promoted, continuedMs: serverHosted ? promoted : null, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? (serverHosted ? 'the server room stopped after a browser left' : 'nobody took over as host within 15 s') : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
|
|
617
699
|
// Late joiner into the same, running room.
|
|
618
700
|
const C = await launch('desk');
|
|
619
701
|
const joinAt = Date.now();
|
|
@@ -183,12 +183,14 @@ function play(c, companions, allow) {
|
|
|
183
183
|
const free = (seat) => { if (!seated.has(seat)) return; seated.delete(seat); did.push(`seat ${seat} left`); frames.push({ t: 'free', seat }); };
|
|
184
184
|
const bots = (mode) => { policy = { ...(policy ?? {}), bots: mode }; frames.push({ t: 'policy', policy }); };
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
const movesToCheck = [];
|
|
187
|
+
const moved = (...args) => movesToCheck.push(args);
|
|
188
|
+
let A = open({ observe, noted, moved, send });
|
|
187
189
|
for (const m of told()) A.frame(m);
|
|
188
190
|
/** The room stops and starts again as a deploy restarts it: a new epoch, everybody away until they are back. */
|
|
189
191
|
const restart = () => {
|
|
190
192
|
const bytes = A.save(); const epoch = A.epoch + 1; A.stop();
|
|
191
|
-
A = open({ observe, noted, send, restore: bytes, restoreEpoch: epoch });
|
|
193
|
+
A = open({ observe, noted, moved, send, restore: bytes, restoreEpoch: epoch });
|
|
192
194
|
for (const m of told()) A.frame(m);
|
|
193
195
|
// Everybody is back at once, but for the highest seat, which is a second late.
|
|
194
196
|
const top = Math.max(...seated.keys());
|
|
@@ -342,6 +344,17 @@ function play(c, companions, allow) {
|
|
|
342
344
|
const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
|
|
343
345
|
A.tickNow();
|
|
344
346
|
units += A.core.stats.tickUnits;
|
|
347
|
+
for (const [name, tick, input, before, after] of movesToCheck.splice(0)) {
|
|
348
|
+
const kind = c.kinds.find(k => k.name === name);
|
|
349
|
+
moveTick = tick;
|
|
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
|
+
error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
|
|
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);
|
|
355
|
+
delete result.used;
|
|
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
|
+
}
|
|
345
358
|
if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
|
|
346
359
|
if (A.core.stats.maxUnits > most) { most = A.core.stats.maxUnits; worst = A.core.stats.worst; }
|
|
347
360
|
const facts = A.facts();
|