@homie-rocks/studio 0.37.2 → 0.38.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 CHANGED
@@ -17,6 +17,38 @@ 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.38.0] - 2026-10-09
21
+
22
+ **Plugin 0.39.0**
23
+
24
+ Your own character answers as soon as you press, even when the room runs on the server.
25
+
26
+ ### Added
27
+
28
+ - 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.
29
+ - The build checks that browser movement agrees with the server and stays within its budget.
30
+
31
+ ### Changed
32
+
33
+ - 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.
34
+
35
+ ### Fixed
36
+
37
+ - Rules prediction keeps movement continuous when snapshots replace its catch-up path or its input clock recalibrates, fixing backward steps on delayed, lossy connections.
38
+ - 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.
39
+ - 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`.
40
+ - Playtest no longer attributes its default timeout duration to a rules game that declares no round duration in `game.json`.
41
+ - Perf checks body presence in the last drawn frame without imposing a hidden 400 ms frame-rate requirement on software rendering.
42
+ - 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.
43
+ - 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.
44
+ - The Game Lab exits after writing its completed report, even when Chrome leaves a pipe open. Coin Dash includes a reproducible Lab take.
45
+ - 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.
46
+ - 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.
47
+
48
+ ### Upgrade notes
49
+
50
+ - Existing games keep their own source when you upgrade. Games written the old way keep working.
51
+
20
52
  ## [0.37.2] - 2026-10-09
21
53
 
22
54
  **Plugin 0.38.2** · [#70](https://github.com/homie-rocks/homie/pull/70)
@@ -336,7 +336,7 @@ import { publish, publishBefore } from '../lib/directory.mjs';
336
336
  import { look } from '../lib/look.mjs';
337
337
  import { importPort, planPort } from '../lib/port.mjs';
338
338
  import { portCheck } from '../lib/port-check.mjs';
339
- import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
339
+ import { perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
340
340
  import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
341
341
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
342
342
  import { newStudio } from '../lib/scaffold.mjs';
@@ -878,7 +878,7 @@ async function main() {
878
878
  }
879
879
  if (cmd === 'perf' && sub === 'compare') {
880
880
  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') ?? DEFAULT_GOAL), 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) : [] });
881
+ 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
882
  }
883
883
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
884
884
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
@@ -1327,7 +1327,7 @@ try {
1327
1327
  if (result && result.command !== 'help') print(result);
1328
1328
  if (result?.ok === false) process.exitCode = 1;
1329
1329
  // 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);
1330
+ if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1331
1331
  } catch (error) {
1332
1332
  await flushProgress().catch(() => {});
1333
1333
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
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}.host.heap`, `${d}.host.net.kbOut`);
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 = DEFAULT_GOAL, guards = null, also = [], min = 0.03, write = true } = {}) {
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) => {
@@ -60,6 +60,25 @@ function yawDeg(a, b) {
60
60
  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
61
  return (Math.acos(Math.max(-1, Math.min(1, d / (la * lb)))) * 180) / Math.PI;
62
62
  }
63
+ /** A server room keeps its authority and advances; a browser room must promote a host. */
64
+ export function roomContinued(before, after, serverHosted) {
65
+ return serverHosted
66
+ ? after?.hosted === 'server' && after?.role === 'replica' && Number.isFinite(before?.tick) && after?.tick > before.tick && after?.frames > before.frames
67
+ : after?.role === 'host';
68
+ }
69
+
70
+ /** Compare two observations on the same browser clock, including time spent taking screenshots. */
71
+ export function roundClockContinued(before, after) {
72
+ return !before?.round || !after?.round || before.round.n !== after.round.n
73
+ || Math.abs((before.round.leftMs - after.round.leftMs) - (after.observedAt - before.observedAt)) < 6000;
74
+ }
75
+
76
+ /** A wall can leave too little runway; retry the same hold after moving away, never excuse steering faults. */
77
+ export function needsRunway(res) {
78
+ return !res.ok && (res.why === 'barely moved' || res.why?.startsWith('blocked at once'))
79
+ && res.yawChangeDeg < 10 && (res.dirErrDeg === null || res.dirErrDeg < 15);
80
+ }
81
+
63
82
  const finite = (r) => r && Number.isFinite(r[1]) && Number.isFinite(r[2]);
64
83
  /** A frame the player steers (the probe's `busy` says when a knockback, a stun or a respawn has the body). */
65
84
  const steerable = (r) => finite(r) && !r[8];
@@ -134,6 +153,9 @@ export function judgePresses(rows, presses, size, view) {
134
153
  const out = presses.map((p) => {
135
154
  const seg = rows.filter((r) => r[0] >= p.a && r[0] <= p.b + 60 && steerable(r));
136
155
  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' };
156
+ // The first post-input frame can already show the response. Keep its preceding drawn pose.
157
+ const before = rows.filter((r) => r[0] < p.a).at(-1);
158
+ if (before && steerable(before)) seg.unshift(before);
137
159
  const u = DIRV[p.dir]; const r0 = seg[0];
138
160
  let matchAt = null, after = 0, afterOk = 0, maxYaw = 0, peakAlong = 0, contact = false;
139
161
  for (let i = 1; i < seg.length; i++) {
@@ -258,7 +280,7 @@ async function webkit(pw) {
258
280
  const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
259
281
  async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
260
282
  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; })()');
283
+ 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
284
  const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
263
285
  const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
264
286
 
@@ -285,13 +307,14 @@ async function focusGame(h) {
285
307
 
286
308
  /* ------------------------------------------------------------------ input */
287
309
 
288
- async function holdKey(h, code, ms) { await h.page.keyboard.down(code); await sleep(ms); await h.page.keyboard.up(code); }
310
+ 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
311
 
290
312
  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) {
313
+ async function touchDrag(h, from, dir, reach, holdMs, ready) {
292
314
  const [x0, y0] = from; const u = DIRV[dir];
293
315
  await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
294
316
  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); }
317
+ if (ready) await ready();
295
318
  const end = Date.now() + holdMs;
296
319
  let w = 0;
297
320
  while (Date.now() < end) {
@@ -302,6 +325,31 @@ async function touchDrag(h, from, dir, reach, holdMs) {
302
325
  }
303
326
  await h.touch('touchEnd', h.touchAll ? [] : [{ id: 1, x: Math.round(x0 + u[0] * reach), y: Math.round(y0 + u[1] * reach) }]);
304
327
  }
328
+ /** Timestamp real input delivery, then keep it held until four frames can judge its direction.
329
+ * The response limit stays 600 ms; slow automation delivery and the thumb's ramp are not game latency. */
330
+ export async function measuredPress(h, how, code, from, dir) {
331
+ await inFrame(h, `(() => {
332
+ if (!window.__homieCheckInput) {
333
+ const state = window.__homieCheckInput = {};
334
+ addEventListener('keydown', e => { if (state.code === e.code && state.key === null) state.key = performance.now(); }, true);
335
+ addEventListener('touchmove', () => { state.touch = performance.now(); }, { capture: true, passive: true });
336
+ }
337
+ Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null });
338
+ })()`);
339
+ let a = null, b = null;
340
+ const ready = async () => {
341
+ a = await inFrame(h, `window.__homieCheckInput.${how === 'keys' ? 'key' : 'touch'}`);
342
+ if (!Number.isFinite(a)) throw new Error('the game frame did not receive the measured input');
343
+ await sleep(420);
344
+ const until = Date.now() + 3000;
345
+ while (Date.now() < until && (await rowsSince(h, a)).length < 4) await sleep(50);
346
+ b = await frameNow(h);
347
+ };
348
+ if (how === 'keys') await holdKey(h, code, 0, ready);
349
+ else await touchDrag(h, from, dir, 70, 0, ready);
350
+ return { dir, a, b };
351
+ }
352
+
305
353
  async function swipe(h, dir) {
306
354
  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
355
  await h.touch('touchStart', [{ id: 2, x: x0, y: y0 }]);
@@ -365,11 +413,22 @@ async function ownerTests(h, how, log) {
365
413
  const holds = view === 'side' ? ['right', 'left'] : ['down', 'left'];
366
414
  out.holds = [];
367
415
  for (const dir of holds) {
368
- const a = await frameNow(h);
369
- if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
370
- const b = await frameNow(h);
371
- await sleep(250);
372
- const res = judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
416
+ const hold = async () => {
417
+ const a = await frameNow(h);
418
+ if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
419
+ const b = await frameNow(h);
420
+ await sleep(250);
421
+ return judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
422
+ };
423
+ let res = await hold();
424
+ if (needsRunway(res)) {
425
+ const first = res;
426
+ const away = { up: 'down', down: 'up', left: 'right', right: 'left' }[dir];
427
+ log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${first.why}; move ${away} and retry the same hold`);
428
+ if (how === 'keys') await holdKey(h, keys[away], 1100); else await touchDrag(h, thumbAt(h, i), away, 70, 1100);
429
+ await sleep(400);
430
+ res = { ...await hold(), runwayRetry: first };
431
+ }
373
432
  out.holds.push(res);
374
433
  log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${res.ok ? 'ok' : `FAIL (${res.why})`} travel ${res.travelBodies} bodies, yaw ${res.yawChangeDeg}°`);
375
434
  await sleep(400);
@@ -383,12 +442,9 @@ async function ownerTests(h, how, log) {
383
442
  const presses = [];
384
443
  const a0 = await frameNow(h);
385
444
  const until = Date.now() + 10_000;
386
- for (let k = 0; Date.now() < until; k++) {
445
+ for (let k = 0; k < alt.length || Date.now() < until; k++) {
387
446
  const dir = alt[k % alt.length];
388
- const a = await frameNow(h);
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 });
447
+ presses.push(await measuredPress(h, how, keys[dir], thumbAt(h, i), dir));
392
448
  await sleep(170);
393
449
  }
394
450
  await sleep(200);
@@ -595,10 +651,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
595
651
  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
652
  else {
597
653
  await sleep(3000);
598
- const ia = await info(A); const ib = await info(B);
599
- if (ia?.role !== 'host') row('host-kill', null, { why: `the first browser was not the host (it was ${ia?.role}); skipped`, ia: ia?.role, ib: ib?.role });
654
+ const ia = await info(A); let ib = await info(B);
655
+ const serverHosted = ia?.hosted === 'server' && ib?.hosted === 'server';
656
+ 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
657
  else {
601
658
  await shot(B, 'life-before');
659
+ // Screenshot encoding may span a round on a software renderer. Observe the room we actually leave.
660
+ ib = await info(B);
602
661
  const pids = A.pid ? [A.pid, ...descendants(A.pid)] : [];
603
662
  const killAt = Date.now();
604
663
  for (const p of pids) { try { process.kill(p, 'SIGKILL'); } catch { /* gone */ } }
@@ -606,14 +665,14 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
606
665
  collect(A);
607
666
  void A.close();
608
667
  let promoted = null; let ibAfter = null;
609
- while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (ibAfter?.role === 'host') { promoted = Date.now() - killAt; break; } await sleep(100); }
668
+ while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (roomContinued(ib, ibAfter, serverHosted)) { promoted = Date.now() - killAt; break; } await sleep(100); }
610
669
  await sleep(1500);
611
670
  const ib2 = await info(B);
612
671
  const sameRound = ib?.round && ib2?.round && (ib2.round.n === ib.round.n || (ib.round.phase === 'over' || ib.round.leftMs < 3000));
613
- const clockOk = ib?.round && ib2?.round && ib2.round.n === ib.round.n ? Math.abs((ib.round.leftMs - ib2.round.leftMs) - (Date.now() - killAt + 0)) < 6000 : true;
672
+ const clockOk = roundClockContinued(ib, ib2);
614
673
  const running = (ib2?.frames ?? 0) > (ibAfter?.frames ?? 0) && (ib2?.net?.snapHzOut ?? 0) >= 0;
615
674
  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 });
675
+ 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
676
  // Late joiner into the same, running room.
618
677
  const C = await launch('desk');
619
678
  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
- let A = open({ observe, noted, send });
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();
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. The game plays alone, offline, with its bots, which is what pictures of the game itself want. The
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) await first.page.keyboard.down(hold).catch(() => {});
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.ok = rows.length === frames && smokeOk;
300
- if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : result.partial;
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(); }
@@ -2112,6 +2112,50 @@
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
+ }
2115
2159
  }
2116
2160
  }
2117
2161
  }
@@ -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
- **In this release a person's body is owner-moved** (`body: { move: 'owner' }` in the rules): its
2155
- browser runs the game's `move` code and claims a position, as `netplay.movement: 'owner'` does
2156
- today, and the server holds each claim to the body's `maxSpeed` (a tick of it every tick, a quarter
2157
- of a second banked at most). It does not stop a changed browser walking through a wall. Bots, and a
2158
- body whose player is away, are moved by the server with the same `move` code. Scores, pickups,
2159
- rounds and placements are the server's in either case.
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
 
@@ -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.37.2",
3
+ "version": "0.38.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",
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
- const loop = (t: number): void => { sample(t); requestAnimationFrame(loop); };
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
- naming('body', 'motion'); const held = thawFields(motion, coerceFields(motion, body.motion, dims)); naming();
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. */