@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 +32 -0
- package/bin/homie-studio.mjs +3 -3
- package/lib/perf.mjs +17 -10
- package/lib/port-check.mjs +77 -18
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/shoot.mjs +11 -5
- package/lib/template-history.json +44 -0
- package/netplay/NETPLAY.md +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +1 -1
- 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/worker/version.mjs +1 -1
- package/starters/gem-rush/src/main.ts +0 -1087
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)
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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 {
|
|
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')
|
|
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}.
|
|
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
|
@@ -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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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);
|
|
599
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
30
|
+
* and shoots it. A game with offline play enabled plays alone, with its bots, which is what pictures of the game itself want. The
|
|
31
31
|
* smoke is NOT RUN (there is no room to meet in: that half needs `dev`), and there is no loading cover to wait for.
|
|
32
32
|
* The result says which build the frames are of (`build`: its hash, the one `build` printed) and that the page
|
|
33
33
|
* really loaded that build's hashed bundle (`bundle`: lib/build.mjs bundleOf, never a guess at assets/main.js).
|
|
@@ -274,7 +274,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
274
274
|
const inAll = (fn, arg) => Promise.all(all().map((f) => T(f.evaluate(fn, arg), 20_000)));
|
|
275
275
|
const frozen = await inAll(() => (window.__homieClock ? (window.__homieClock.freeze(), true) : false));
|
|
276
276
|
if (!frozen.some(Boolean)) { result.why = 'the virtual clock is not in the page (it was opened before the script could be injected)'; return result; }
|
|
277
|
-
if (hold)
|
|
277
|
+
if (hold) {
|
|
278
|
+
// The play page owns the top-level focus; the game's key listener lives in its iframe.
|
|
279
|
+
if (gf) await T(gf.evaluate(() => window.focus()), 5000);
|
|
280
|
+
await first.page.keyboard.down(hold).catch(() => {});
|
|
281
|
+
}
|
|
278
282
|
const rows = [];
|
|
279
283
|
for (let n = 1; n <= frames; n++) {
|
|
280
284
|
if (left() < 3000) { result.partial = `stopped at frame ${n - 1} of ${frames}: the ${Math.round(timeoutMs / 1000)} s bound was reached (a slow renderer; ask for fewer frames or a longer --timeout)`; break; }
|
|
@@ -287,6 +291,7 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
287
291
|
rows.push({ n, file, virtualMs: +(n * (1000 / fps)).toFixed(3), clockMs: nows.find((v) => Number.isFinite(v)) ?? null, realMs: Date.now() - t, ...(await frameFacts(first) ?? {}) });
|
|
288
292
|
}
|
|
289
293
|
if (hold) await first.page.keyboard.up(hold).catch(() => {});
|
|
294
|
+
result.rules = gf ? await T(gf.evaluate(() => { const p = window.__homieNet?.probe; return typeof p?.hosted === 'function' ? { hosted: p.hosted(), status: p.status?.(), tick: p.tick?.() } : null; }), 5000) : null;
|
|
290
295
|
result.frames = rows.length;
|
|
291
296
|
result.realSeconds = +((Date.now() - started) / 1000).toFixed(1);
|
|
292
297
|
result.virtualSeconds = +((rows.length * 1000) / fps / 1000).toFixed(3);
|
|
@@ -294,10 +299,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
294
299
|
result.limits = 'Stepped: requestAnimationFrame, performance.now, Date, setTimeout, setInterval, CSS and Web animations, in the page and the game\'s frame. NOT stepped: the room\'s socket and its server clock (other players and the round arrive in real time), Web Audio\'s clock, <video>, workers. These frames show what was drawn; they are not a frame rate.';
|
|
295
300
|
const probed = rows.some((r) => r.phase !== undefined);
|
|
296
301
|
if (!probed) result.note = [result.note, 'the game exposes no port probe (exposePort): the frames carry no round phase or position'].filter(Boolean).join('; ');
|
|
297
|
-
writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
|
|
298
302
|
const smokeOk = result.smoke.ok !== false;
|
|
299
|
-
result.
|
|
300
|
-
|
|
303
|
+
const rulesOk = !result.rules || (result.rules.status === 'playing' && result.rules.tick > 0);
|
|
304
|
+
result.ok = rows.length === frames && smokeOk && rulesOk && !result.errors.length;
|
|
305
|
+
if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : !rulesOk ? 'The rules game is not playing. A server-only build needs its room: run homie-studio dev and shoot with --url (or explicitly enable room.offline for offline play).' : result.errors.length ? `game errors: ${result.errors.join('; ')}` : result.partial;
|
|
306
|
+
writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
|
|
301
307
|
return result;
|
|
302
308
|
} finally {
|
|
303
309
|
if (served?.server) { served.server.closeAllConnections?.(); served.server.close(); }
|
|
@@ -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
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2151,12 +2151,26 @@ and holds that lead from `lead`. A step whose sample (or claim) changed, or that
|
|
|
2151
2151
|
makes an entry. A period with no entry sends nothing; after 250 ms without a frame the held values
|
|
2152
2152
|
are sent again as an ordinary entry. A hidden tab sends one neutral entry and stops stepping.
|
|
2153
2153
|
|
|
2154
|
-
**
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2154
|
+
**Server movement with immediate local prediction.** The host runs `move` for every body.
|
|
2155
|
+
The view library runs the same guarded module for its own player, one step per tick and one
|
|
2156
|
+
throwaway preview between ticks. A snapshot replaces that body's position, velocity, heading,
|
|
2157
|
+
grounded flag and motion; the view replays pending inputs from there. Late presses are mirrored
|
|
2158
|
+
onto the next replay tick while they remain inside the quarter-second window. Scores, pickups,
|
|
2159
|
+
collisions between entities and effects are always the host's decisions. Bot and AI bodies
|
|
2160
|
+
are interpolated, never predicted by a player's view.
|
|
2161
|
+
|
|
2162
|
+
Small errors fade as a visual offset with a nominal 100 ms blend. Overlapping small corrections
|
|
2163
|
+
queue their individual fades, so their accumulated offset is not erased all at once. A disagreement
|
|
2164
|
+
beyond three ticks of travel plays the corrected path from the snapshot at 1.25 times speed,
|
|
2165
|
+
showing the hold and slide of a knock. Offset fades are limited by progress along the drawn
|
|
2166
|
+
path and the body's declared maximum movement speed, so a late-input correction cannot erase more travel than the player makes or compound a fast knock's catch-up speed. Catch-up preserves the drawn pose when a snapshot replaces its history, and uses the next movement preview when its next whole-tick history entry is not yet available. When movement
|
|
2167
|
+
stops after catch-up, the remaining fade follows the configured blend time, still bounded by that speed. An effect on the body waits for its drawn tick. Placements, epoch changes and clock rebases
|
|
2168
|
+
jump deliberately. Other bodies keep the helper's adaptive interpolation, with one send period
|
|
2169
|
+
as the default minimum delay. The `room.predict` settings customize these defaults.
|
|
2170
|
+
|
|
2171
|
+
`body: { move: 'owner' }` remains available for existing rules games: the browser supplies its
|
|
2172
|
+
position and the host limits each claim to `maxSpeed`. It cannot prevent a modified owner walking
|
|
2173
|
+
through a wall. New Coin Dash and Gem Rush use authoritative server movement instead.
|
|
2160
2174
|
|
|
2161
2175
|
### 29.3 The life of a server-hosted room
|
|
2162
2176
|
|
package/netplay/netplay.ts
CHANGED
|
@@ -631,6 +631,8 @@ export interface NetplayOptions<C = unknown> {
|
|
|
631
631
|
checkpointMs?: number;
|
|
632
632
|
/** Fixed interpolation delay in ms. Default: adaptive (arrival age p90 + 1.2 intervals). */
|
|
633
633
|
interpDelayMs?: number;
|
|
634
|
+
/** Floor under the adaptive interpolation buffer (rules views). */
|
|
635
|
+
interpFloorMs?: number;
|
|
634
636
|
/** Host: return the full rules state. Called every `checkpointMs`, before yielding, and on pagehide. */
|
|
635
637
|
checkpoint?: () => C;
|
|
636
638
|
/** ms to wait for a welcome before falling back to offline host, when the socket could not even open. Default 4000. */
|
|
@@ -2947,7 +2949,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2947
2949
|
|
|
2948
2950
|
function interpDelay(): number {
|
|
2949
2951
|
if (opts.interpDelayMs !== undefined) return opts.interpDelayMs;
|
|
2950
|
-
return delaySmooth || Math.max(50, iv * 1.6 + 8);
|
|
2952
|
+
return Math.max(opts.interpFloorMs ?? 0, delaySmooth || Math.max(50, iv * 1.6 + 8));
|
|
2951
2953
|
}
|
|
2952
2954
|
|
|
2953
2955
|
// ---------------------------------------------------------------- connect
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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
|
-
|
|
126
|
+
// The position is sampled now; rAF may carry an earlier timestamp on a busy renderer.
|
|
127
|
+
const loop = (): void => { sample(performance.now()); requestAnimationFrame(loop); };
|
|
127
128
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(loop);
|
|
128
129
|
const probe: PortProbe = {
|
|
129
130
|
v: 1,
|
package/rules/core.ts
CHANGED
|
@@ -177,7 +177,7 @@ export interface Core {
|
|
|
177
177
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
178
178
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
179
179
|
*/
|
|
180
|
-
export function createCore(c: Compiled, opts: { observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
180
|
+
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
181
181
|
if (c.dims !== 2) throw new Error('this release runs rules with space.dims: 2; bodies with height (dims: 3) arrive in a later one');
|
|
182
182
|
const dims = c.dims;
|
|
183
183
|
const tickHz = c.settings.tickHz;
|
|
@@ -1002,6 +1002,7 @@ export function createCore(c: Compiled, opts: { observe?: (kind: string, handler
|
|
|
1002
1002
|
}
|
|
1003
1003
|
if (!k.move) continue;
|
|
1004
1004
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1005
|
+
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1005
1006
|
moveRadius = body.radius;
|
|
1006
1007
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
1007
1008
|
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
@@ -1011,6 +1012,7 @@ export function createCore(c: Compiled, opts: { observe?: (kind: string, handler
|
|
|
1011
1012
|
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1012
1013
|
G.note = null;
|
|
1013
1014
|
e.grounded = own(b, 'grounded') === true;
|
|
1015
|
+
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
|
|
1014
1016
|
}
|
|
1015
1017
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
1016
1018
|
const list = [...ents.values()];
|
package/rules/host.ts
CHANGED
|
@@ -58,6 +58,8 @@ export interface HostClock {
|
|
|
58
58
|
}
|
|
59
59
|
export interface HostOptions {
|
|
60
60
|
game: string;
|
|
61
|
+
/** The build check compares a movement step before any tick handler can change it. */
|
|
62
|
+
moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
|
|
61
63
|
/** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
|
|
62
64
|
observe?(kind: string, handler: string, error?: string): void;
|
|
63
65
|
/** Told of a written value the runtime changed to make it fit (core.ts `noted`): the build check listens. */
|
|
@@ -226,7 +228,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
226
228
|
}
|
|
227
229
|
}
|
|
228
230
|
if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
229
|
-
const core = createCore(c, { observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
231
|
+
const core = createCore(c, { moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
230
232
|
let epoch = core.epoch;
|
|
231
233
|
if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
232
234
|
let lastSaveAt = o.clock.now();
|
package/rules/pack.ts
CHANGED
|
@@ -263,12 +263,39 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
|
|
|
263
263
|
*/
|
|
264
264
|
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
|
|
265
265
|
G.left = quota;
|
|
266
|
+
const bag = coerceFields(motion, body.motion, dims);
|
|
267
|
+
const target: Record<string, unknown> = {};
|
|
268
|
+
const work: Record<string, unknown> = {};
|
|
269
|
+
// Match core.typed: scalar writes round immediately; collections copy on first read and settle at the end.
|
|
270
|
+
for (const [name, fd] of motion) {
|
|
271
|
+
const held = (v: unknown): unknown => { naming('body.motion', name); const out = coerce(fd, v, dims); naming(); return out; };
|
|
272
|
+
Object.defineProperty(target, name, { enumerable: true,
|
|
273
|
+
get: () => {
|
|
274
|
+
if (!mutable(fd)) return bag[name];
|
|
275
|
+
if (work[name] === undefined) {
|
|
276
|
+
const size = fd.t === 'list' ? 1 + (bag[name] as unknown[]).length * cellsOf(fd.of as Field)
|
|
277
|
+
: fd.t === 'map' ? 1 + Object.keys(bag[name] as object).length * (1 + cellsOf(fd.of as Field)) : cellsOf(fd);
|
|
278
|
+
charge(6 * size); work[name] = thaw(fd, bag[name]);
|
|
279
|
+
}
|
|
280
|
+
return work[name];
|
|
281
|
+
},
|
|
282
|
+
set: (v: unknown) => {
|
|
283
|
+
charge(mutable(fd) ? 6 * est(fd, v) : cellsOf(fd) === 1 ? 3 : 6 * cellsOf(fd));
|
|
284
|
+
bag[name] = held(v); work[name] = undefined;
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
body.motion = Object.preventExtensions(target);
|
|
266
289
|
try { fn(body, input, ctx); } catch (error) { failed(error); }
|
|
290
|
+
try {
|
|
291
|
+
for (const [name, fd] of motion) if (work[name] !== undefined) {
|
|
292
|
+
charge(6 * est(fd, work[name])); naming('body.motion', name); bag[name] = coerce(fd, work[name], dims); naming();
|
|
293
|
+
}
|
|
294
|
+
} catch (error) { failed(error); }
|
|
267
295
|
const used = quota - (G.left > 0 ? G.left : 0);
|
|
268
296
|
G.left = Infinity;
|
|
269
|
-
naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims);
|
|
270
|
-
|
|
271
|
-
return { pos, vel, heading, grounded: body.grounded === true, motion: held, used };
|
|
297
|
+
naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims); naming();
|
|
298
|
+
return { pos, vel, heading, grounded: body.grounded === true, motion: thawFields(motion, bag), used };
|
|
272
299
|
}
|
|
273
300
|
|
|
274
301
|
/** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */
|