@homie-rocks/studio 0.2.0 → 0.3.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/README.md +14 -2
- package/bin/homie-studio.mjs +34 -3
- package/lib/build.mjs +83 -21
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +6 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +11 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +19 -0
- package/worker/pages.mjs +30 -10
- package/worker/qr.mjs +501 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio port check <game> --url <site>` — a port is done when this
|
|
3
|
+
* passes. It runs the tests people's hands taught us, on the real play page
|
|
4
|
+
* (the sandboxed frame, the room, the relay), with at most two browsers open:
|
|
5
|
+
*
|
|
6
|
+
* owner-desk keys: hold one direction 5 s → one straight line the pressed
|
|
7
|
+
* way, camera yaw change under 10°; hold another 5 s; then
|
|
8
|
+
* alternate directions for 10 s → every press goes the pressed
|
|
9
|
+
* way within 600 ms. (Board games: every key and every swipe is
|
|
10
|
+
* the move the game applies.)
|
|
11
|
+
* owner-phone the same with REAL touch (CDP Input.dispatchTouchEvent) on an
|
|
12
|
+
* emulated Android Chrome (Pixel 7, DPR 2.625), plus how much of
|
|
13
|
+
* the screen the UI covers (≤ 12%, nothing opaque in the middle).
|
|
14
|
+
* owner-iphone the same on WebKit with the iPhone 15 profile (Playwright's
|
|
15
|
+
* WebKit, touch through its protocol), when installed.
|
|
16
|
+
* round two fresh browsers (a computer and a phone) press Play, meet in
|
|
17
|
+
* the same public room and both see a round finish with both of
|
|
18
|
+
* them in the results.
|
|
19
|
+
* life a computer hosts, a phone plays; the host's browser is killed
|
|
20
|
+
* (SIGKILL, the whole process tree): the phone must take over the
|
|
21
|
+
* SAME round within 5 s; then a late joiner arrives mid-round and
|
|
22
|
+
* is seated in a bot's place with the world on its first frames.
|
|
23
|
+
* tv the big screen (/<game>/tv): a spectator with no body, the join
|
|
24
|
+
* QR, a picture that is not black, frames moving.
|
|
25
|
+
* audio/errors audio running after the first input; no uncaught errors.
|
|
26
|
+
*
|
|
27
|
+
* The game must call exposePort() (from @homie-rocks/studio/port, or HomiePort in a
|
|
28
|
+
* static game). Receipts and screenshots go to games/<id>/.port/check-<stamp>/.
|
|
29
|
+
* Exit 0 only when every row passed.
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
|
|
37
|
+
const CHROMES = [
|
|
38
|
+
process.env.CHROME_PATH,
|
|
39
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
40
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
41
|
+
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
42
|
+
].filter(Boolean);
|
|
43
|
+
const GPU = [
|
|
44
|
+
...(process.platform === 'darwin' ? ['--use-angle=metal'] : []),
|
|
45
|
+
'--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required',
|
|
46
|
+
'--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
|
47
|
+
'--no-first-run', '--no-default-browser-check',
|
|
48
|
+
];
|
|
49
|
+
const TAG = 'homie-studio-check';
|
|
50
|
+
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36';
|
|
51
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
52
|
+
const T = (p, ms, v = null) => Promise.race([Promise.resolve(p).catch(() => v), sleep(ms).then(() => v)]);
|
|
53
|
+
const DIRV = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
|
54
|
+
const ALL = ['owner-desk', 'owner-phone', 'owner-iphone', 'round', 'life', 'tv'];
|
|
55
|
+
|
|
56
|
+
/* ------------------------------------------------------------------ judging */
|
|
57
|
+
|
|
58
|
+
/** A row's ground displacement → screen (x right, y down) using the row's own camera basis. */
|
|
59
|
+
function toScreen(r0, dx, dy) {
|
|
60
|
+
const rx = r0[3], ry = r0[4], ux = r0[5], uy = r0[6];
|
|
61
|
+
return { x: dx * rx + dy * ry, y: -(dx * ux + dy * uy) };
|
|
62
|
+
}
|
|
63
|
+
const angDeg = (v, u) => { const a = Math.atan2(v.y, v.x) - Math.atan2(u[1], u[0]); return Math.abs(((a * 180) / Math.PI + 540) % 360 - 180); };
|
|
64
|
+
function yawDeg(a, b) {
|
|
65
|
+
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;
|
|
66
|
+
return (Math.acos(Math.max(-1, Math.min(1, d / (la * lb)))) * 180) / Math.PI;
|
|
67
|
+
}
|
|
68
|
+
const finite = (r) => r && Number.isFinite(r[1]) && Number.isFinite(r[2]);
|
|
69
|
+
/** A frame the player steers (the probe's `busy` says when a knockback, a stun or a respawn has the body). */
|
|
70
|
+
const steerable = (r) => finite(r) && !r[8];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One held direction: straight, the pressed way, the camera still. `side`: only the horizontal matters (gravity owns y).
|
|
74
|
+
* Only the free run is judged: once the body stops making progress the pressed way (a wall, the arena's edge),
|
|
75
|
+
* sliding along the obstacle is contact, not a wrong turn.
|
|
76
|
+
*/
|
|
77
|
+
export function judgeHold(rows, dir, a, b, size, view) {
|
|
78
|
+
const seg = rows.filter((r) => r[0] >= a && r[0] <= b + 40 && steerable(r));
|
|
79
|
+
const busyFrames = rows.filter((r) => r[0] >= a && r[0] <= b + 40 && finite(r) && r[8]).length;
|
|
80
|
+
if (seg.length < 8) return { ok: false, dir, busyFrames, why: busyFrames ? `the body was busy (knocked back, stunned, respawning) for ${busyFrames} frames of the hold` : `too few samples (${seg.length}): no body on the probe?` };
|
|
81
|
+
const u = DIRV[dir]; const r0 = seg[0];
|
|
82
|
+
const side = view === 'side';
|
|
83
|
+
const tiIdx = Math.max(0, seg.findIndex((r) => r[0] >= a + 400));
|
|
84
|
+
const ti = seg[tiIdx] ?? r0;
|
|
85
|
+
const along = (r) => { const d = toScreen(r0, r[1] - ti[1], r[2] - ti[2]); return side ? d.x * u[0] : d.x * u[0] + d.y * u[1]; };
|
|
86
|
+
// Where progress the pressed way stops for good (250 ms without gaining 2% of a body): the free run ends there.
|
|
87
|
+
let best = 0; let bestT = ti[0]; let contact = null;
|
|
88
|
+
for (let i = tiIdx; i < seg.length; i++) {
|
|
89
|
+
const r = seg[i]; const p = along(r);
|
|
90
|
+
if (p > best + size * 0.02) { best = p; bestT = r[0]; }
|
|
91
|
+
else if (r[0] - bestT > 250 && best > size) { contact = bestT; break; }
|
|
92
|
+
}
|
|
93
|
+
const free = seg.filter((r) => contact === null || r[0] <= contact);
|
|
94
|
+
const last = free[free.length - 1] ?? r0;
|
|
95
|
+
const tot = toScreen(r0, last[1] - ti[1], last[2] - ti[2]);
|
|
96
|
+
const travel = side ? Math.abs(tot.x) : Math.hypot(tot.x, tot.y);
|
|
97
|
+
let moving = 0, wrong = 0, off = 0, prev = null, maxYaw = 0;
|
|
98
|
+
const minSpeed = Math.max(size * 0.5, 1e-3);
|
|
99
|
+
for (const r of seg) maxYaw = Math.max(maxYaw, yawDeg(r0, r));
|
|
100
|
+
for (const r of free) {
|
|
101
|
+
if (r[0] < ti[0]) { prev = r; continue; }
|
|
102
|
+
const d = toScreen(r0, r[1] - ti[1], r[2] - ti[2]);
|
|
103
|
+
if (!side) off = Math.max(off, Math.abs(d.x * -u[1] + d.y * u[0]));
|
|
104
|
+
if (prev && r[0] > prev[0]) {
|
|
105
|
+
const v = toScreen(r0, r[1] - prev[1], r[2] - prev[2]);
|
|
106
|
+
const dt = (r[0] - prev[0]) / 1000;
|
|
107
|
+
const sp = (side ? Math.abs(v.x) : Math.hypot(v.x, v.y)) / dt;
|
|
108
|
+
if (sp > minSpeed) { moving += 1; if (side ? Math.sign(v.x) !== u[0] : angDeg(v, u) > 25) wrong += 1; }
|
|
109
|
+
}
|
|
110
|
+
prev = r;
|
|
111
|
+
}
|
|
112
|
+
const dirErr = travel > 0 ? (side ? (Math.sign(tot.x) === u[0] ? 0 : 180) : angDeg(tot, u)) : null;
|
|
113
|
+
const res = {
|
|
114
|
+
dir, samples: seg.length, freeRunMs: Math.round((contact ?? last[0]) - a), contactMs: contact === null ? null : Math.round(contact - a),
|
|
115
|
+
travel: +travel.toFixed(2), travelBodies: +(travel / size).toFixed(1), dirErrDeg: dirErr === null ? null : +dirErr.toFixed(1),
|
|
116
|
+
offLine: +off.toFixed(2), offLineShare: travel ? +(off / travel).toFixed(3) : null, movingFrames: moving, wrongFrames: wrong, yawChangeDeg: +maxYaw.toFixed(2),
|
|
117
|
+
};
|
|
118
|
+
res.ok = res.yawChangeDeg < 10 && travel > size * 2 && dirErr !== null && dirErr < 15 && (moving ? wrong / moving : 1) < 0.1 && (side || off <= Math.max(size * 1.5, travel * 0.12));
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
res.why = res.yawChangeDeg >= 10 ? 'the camera turned by itself' : travel <= size * 2 ? (contact !== null && contact - a < 900 ? 'blocked at once (it started against something); move the spawn or run again' : 'barely moved') : dirErr === null || dirErr >= 15 ? 'went the wrong way' : 'not a straight line';
|
|
121
|
+
// The path on screen (x right, y down, in bodies from the start), every ~50 ms, to see what happened.
|
|
122
|
+
let lastT = -Infinity;
|
|
123
|
+
res.path = seg.filter((r) => { if (r[0] - lastT < 50) return false; lastT = r[0]; return true; })
|
|
124
|
+
.map((r) => { const d = toScreen(r0, r[1] - r0[1], r[2] - r0[2]); return [Math.round(r[0] - a), +(d.x / size).toFixed(2), +(d.y / size).toFixed(2)]; });
|
|
125
|
+
}
|
|
126
|
+
return res;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Alternating presses: each goes the pressed way within 600 ms, and keeps going that way.
|
|
131
|
+
* A maze (view 'maze') keeps its buffered turns: a turn into a wall waits for the next opening while the body goes
|
|
132
|
+
* on, so a press there only fails if the body then moves AGAINST the pressed way. A reversal is always open in a
|
|
133
|
+
* corridor, so it must still answer within 600 ms.
|
|
134
|
+
*/
|
|
135
|
+
export function judgePresses(rows, presses, size, view) {
|
|
136
|
+
const maze = view === 'maze';
|
|
137
|
+
const side = view === 'side';
|
|
138
|
+
const minSpeed = Math.max(size * 0.5, 1e-3);
|
|
139
|
+
const out = presses.map((p) => {
|
|
140
|
+
const seg = rows.filter((r) => r[0] >= p.a && r[0] <= p.b + 60 && steerable(r));
|
|
141
|
+
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' };
|
|
142
|
+
const u = DIRV[p.dir]; const r0 = seg[0];
|
|
143
|
+
let matchAt = null, after = 0, afterOk = 0, maxYaw = 0, peakAlong = 0, contact = false;
|
|
144
|
+
for (let i = 1; i < seg.length; i++) {
|
|
145
|
+
const a = seg[i - 1], b = seg[i]; const dt = (b[0] - a[0]) / 1000; if (dt <= 0) continue;
|
|
146
|
+
maxYaw = Math.max(maxYaw, yawDeg(r0, b));
|
|
147
|
+
const v = toScreen(r0, b[1] - a[1], b[2] - a[2]);
|
|
148
|
+
const sp = (side ? Math.abs(v.x) : Math.hypot(v.x, v.y)) / dt;
|
|
149
|
+
const alongSp = (side ? v.x * u[0] : v.x * u[0] + v.y * u[1]) / dt;
|
|
150
|
+
// After it got going the pressed way, a drop to under a third of its best speed that way is something in
|
|
151
|
+
// the way (a wall it slides along, a ledge): contact, not the controls. The hold row judges curves.
|
|
152
|
+
if (matchAt !== null && peakAlong > minSpeed * 2 && alongSp < peakAlong * 0.33) contact = true;
|
|
153
|
+
if (contact || sp <= minSpeed) continue;
|
|
154
|
+
const good = side ? Math.sign(v.x) === u[0] : angDeg(v, u) <= 30;
|
|
155
|
+
if (good && matchAt === null) matchAt = Math.round(b[0] - p.a);
|
|
156
|
+
if (matchAt !== null) { after += 1; if (good) afterOk += 1; peakAlong = Math.max(peakAlong, alongSp); }
|
|
157
|
+
}
|
|
158
|
+
const share = after ? afterOk / after : 0;
|
|
159
|
+
if (maze && matchAt === null) {
|
|
160
|
+
// No turn yet: queued behind a wall (fine) unless it moved against the pressed way, or it was a reversal.
|
|
161
|
+
let against = false; let prevDir = null;
|
|
162
|
+
for (let i = 1; i < seg.length; i++) {
|
|
163
|
+
const a = seg[i - 1], b = seg[i]; const dt = (b[0] - a[0]) / 1000; if (dt <= 0) continue;
|
|
164
|
+
const v = toScreen(r0, b[1] - a[1], b[2] - a[2]); const sp = Math.hypot(v.x, v.y) / dt;
|
|
165
|
+
if (sp <= minSpeed) continue;
|
|
166
|
+
if (prevDir === null) prevDir = v;
|
|
167
|
+
if (angDeg(v, [-u[0], -u[1]]) <= 30 && i > seg.length / 3) against = true;
|
|
168
|
+
}
|
|
169
|
+
const reversal = prevDir && angDeg(prevDir, [-u[0], -u[1]]) <= 30;
|
|
170
|
+
return { dir: p.dir, matchMs: null, afterShare: 0, yawDeg: +maxYaw.toFixed(1), blocked: !against && !reversal, queued: !against && !reversal, ok: false };
|
|
171
|
+
}
|
|
172
|
+
// A press that moved nothing at all ran into something (a wall, a platform edge): blocked, not wrong.
|
|
173
|
+
const moved = seg.some((b, i) => i > 0 && b[0] > seg[i - 1][0] && ((side ? Math.abs(toScreen(r0, b[1] - seg[i - 1][1], b[2] - seg[i - 1][2]).x) : Math.hypot(toScreen(r0, b[1] - seg[i - 1][1], b[2] - seg[i - 1][2]).x, toScreen(r0, b[1] - seg[i - 1][1], b[2] - seg[i - 1][2]).y)) / ((b[0] - seg[i - 1][0]) / 1000)) > minSpeed);
|
|
174
|
+
// In a maze a queued turn may happen later (when the opening arrives); only a reversal must answer in 600 ms.
|
|
175
|
+
let limit = 600;
|
|
176
|
+
if (maze) {
|
|
177
|
+
const first = seg.find((b, i) => i > 0 && Math.hypot(toScreen(r0, b[1] - seg[i - 1][1], b[2] - seg[i - 1][2]).x, toScreen(r0, b[1] - seg[i - 1][1], b[2] - seg[i - 1][2]).y) > 0);
|
|
178
|
+
const i0 = first ? seg.indexOf(first) : -1;
|
|
179
|
+
const v0 = i0 > 0 ? toScreen(r0, seg[i0][1] - seg[i0 - 1][1], seg[i0][2] - seg[i0 - 1][2]) : null;
|
|
180
|
+
if (!(v0 && angDeg(v0, [-u[0], -u[1]]) <= 30)) limit = Infinity;
|
|
181
|
+
}
|
|
182
|
+
return { dir: p.dir, matchMs: matchAt, afterShare: +share.toFixed(2), yawDeg: +maxYaw.toFixed(1), blocked: !moved, contact, ok: moved && matchAt !== null && matchAt < limit && share >= 0.8 && maxYaw < 10 };
|
|
183
|
+
});
|
|
184
|
+
const movedRows = out.filter((x) => !x.blocked);
|
|
185
|
+
const passed = movedRows.filter((x) => x.ok).length;
|
|
186
|
+
const blocked = out.length - movedRows.length;
|
|
187
|
+
return { presses: out.length, blocked, passed, ok: movedRows.length >= Math.ceil(out.length * 0.7) && passed >= Math.ceil(movedRows.length * 0.9) && out.every((x) => x.yawDeg === undefined || x.yawDeg < 10), wrongWay: movedRows.filter((x) => !x.ok).map((x) => x.dir), rows: out };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------------ browsers */
|
|
191
|
+
|
|
192
|
+
function descendants(pid) {
|
|
193
|
+
const kids = (spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' }).stdout ?? '').split('\n').filter(Boolean).map(Number);
|
|
194
|
+
return kids.flatMap((k) => [k, ...descendants(k)]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function chrome(puppeteer, kind, exe) {
|
|
198
|
+
const dir = mkdtempSync(join(tmpdir(), 'homie-port-check-'));
|
|
199
|
+
const vp = kind === 'phone' ? { width: 412, height: 915 } : kind === 'tv' ? { width: 1280, height: 720 } : { width: 1280, height: 800 };
|
|
200
|
+
const browser = await puppeteer.launch({ executablePath: exe, headless: true, userDataDir: dir, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
|
|
201
|
+
const page = (await browser.pages())[0] ?? await browser.newPage();
|
|
202
|
+
if (kind === 'phone') await page.emulate({ viewport: { ...vp, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true }, userAgent: `${ANDROID_UA} ${TAG}` });
|
|
203
|
+
else { await page.setViewport({ ...vp, deviceScaleFactor: 1 }); await page.setUserAgent(`${await browser.userAgent()} ${TAG}`); }
|
|
204
|
+
const cdp = await page.createCDPSession();
|
|
205
|
+
const h = {
|
|
206
|
+
engine: 'chrome', kind, browser, page, vp, pid: browser.process()?.pid ?? null, errors: [], badResponses: [],
|
|
207
|
+
touch: (type, pts) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: pts.map((p) => ({ ...p, radiusX: 11, radiusY: 11, force: 1 })) }),
|
|
208
|
+
touchAll: true,
|
|
209
|
+
async close() { await T(browser.close(), 10_000); try { browser.process()?.kill('SIGKILL'); } catch { /* gone */ } rmSync(dir, { recursive: true, force: true }); },
|
|
210
|
+
};
|
|
211
|
+
page.on('pageerror', (e) => h.errors.push(`pageerror: ${String(e?.message ?? e).slice(0, 300)}`));
|
|
212
|
+
page.on('console', (m) => { if (m.type() === 'error') h.errors.push(`console: ${m.text().slice(0, 300)}`); });
|
|
213
|
+
page.on('response', (r) => { if (r.status() >= 400 && !/favicon/.test(r.url())) h.badResponses.push(`${r.status()} ${r.url().slice(0, 160)}`); });
|
|
214
|
+
return h;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function webkit(pw) {
|
|
218
|
+
const browser = await pw.webkit.launch({ headless: true });
|
|
219
|
+
const dev = pw.devices['iPhone 15'];
|
|
220
|
+
const ctx = await browser.newContext({ ...dev, userAgent: `${dev.userAgent} ${TAG}` });
|
|
221
|
+
const page = await ctx.newPage();
|
|
222
|
+
const impl = page._connection.toImpl(page);
|
|
223
|
+
const sess = (impl.delegate ?? impl._delegate)._pageProxySession;
|
|
224
|
+
const h = {
|
|
225
|
+
engine: 'webkit', kind: 'phone', browser, page, vp: dev.viewport, pid: null, errors: [], badResponses: [],
|
|
226
|
+
touch: (type, pts) => sess.send('Input.dispatchTouchEvent', { type, touchPoints: pts }),
|
|
227
|
+
touchAll: false,
|
|
228
|
+
async close() { await T(browser.close(), 10_000); },
|
|
229
|
+
};
|
|
230
|
+
page.on('pageerror', (e) => h.errors.push(`pageerror: ${String(e?.message ?? e).slice(0, 300)}`));
|
|
231
|
+
page.on('console', (m) => { if (m.type() === 'error') h.errors.push(`console: ${m.text().slice(0, 300)}`); });
|
|
232
|
+
page.on('response', (r) => { if (r.status() >= 400 && !/favicon/.test(r.url())) h.badResponses.push(`${r.status()} ${r.url().slice(0, 160)}`); });
|
|
233
|
+
return h;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
|
|
237
|
+
async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
|
|
238
|
+
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);
|
|
239
|
+
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; })()');
|
|
240
|
+
const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
|
|
241
|
+
const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
|
|
242
|
+
|
|
243
|
+
async function open(h, url) {
|
|
244
|
+
await T(h.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }), 65_000);
|
|
245
|
+
const t0 = Date.now();
|
|
246
|
+
// Seated, probe up, and a body (or a board) on the probe.
|
|
247
|
+
while (Date.now() - t0 < 60_000) {
|
|
248
|
+
const s = await shell(h);
|
|
249
|
+
const i = await info(h);
|
|
250
|
+
if (s?.room && i && i.seat !== null && i.seat !== undefined && (i.frames ?? 0) > 5) {
|
|
251
|
+
if (i.view === 'board' || (await rowsSince(h, 0)).some(finite)) return { ok: true, ms: Date.now() - t0, shell: s, info: i };
|
|
252
|
+
}
|
|
253
|
+
await sleep(300);
|
|
254
|
+
}
|
|
255
|
+
const s = await shell(h);
|
|
256
|
+
return { ok: false, ms: Date.now() - t0, why: frameOf(h) ? 'the game never reported a body on window.__homiePort (call exposePort with self)' : 'no game frame', shell: s, info: await info(h) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function focusGame(h) {
|
|
260
|
+
await T(h.page.evaluate(() => { const f = document.querySelector('iframe.game'); f?.focus(); try { f?.contentWindow?.focus(); } catch { /* opaque */ } }), 3000);
|
|
261
|
+
await inFrame(h, '(() => { try { window.focus(); (document.querySelector("canvas") || document.body)?.focus?.(); } catch (e) {} return true; })()');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ------------------------------------------------------------------ input */
|
|
265
|
+
|
|
266
|
+
async function holdKey(h, code, ms) { await h.page.keyboard.down(code); await sleep(ms); await h.page.keyboard.up(code); }
|
|
267
|
+
|
|
268
|
+
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])]; }
|
|
269
|
+
async function touchDrag(h, from, dir, reach, holdMs) {
|
|
270
|
+
const [x0, y0] = from; const u = DIRV[dir];
|
|
271
|
+
await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
|
|
272
|
+
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); }
|
|
273
|
+
const end = Date.now() + holdMs;
|
|
274
|
+
let w = 0;
|
|
275
|
+
while (Date.now() < end) {
|
|
276
|
+
w += 1;
|
|
277
|
+
const wob = (w % 2 ? 1.5 : -1.5);
|
|
278
|
+
await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + u[0] * reach + (u[1] ? wob : 0)), y: Math.round(y0 + u[1] * reach + (u[0] ? wob : 0)) }]);
|
|
279
|
+
await sleep(25);
|
|
280
|
+
}
|
|
281
|
+
await h.touch('touchEnd', h.touchAll ? [] : [{ id: 1, x: Math.round(x0 + u[0] * reach), y: Math.round(y0 + u[1] * reach) }]);
|
|
282
|
+
}
|
|
283
|
+
async function swipe(h, dir) {
|
|
284
|
+
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);
|
|
285
|
+
await h.touch('touchStart', [{ id: 2, x: x0, y: y0 }]);
|
|
286
|
+
for (let k = 1; k <= 6; k++) { await h.touch('touchMove', [{ id: 2, x: Math.round(x0 + (u[0] * reach * k) / 6), y: Math.round(y0 + (u[1] * reach * k) / 6) }]); await sleep(16); }
|
|
287
|
+
await h.touch('touchEnd', h.touchAll ? [] : [{ id: 2, x: Math.round(x0 + u[0] * reach), y: Math.round(y0 + u[1] * reach) }]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ------------------------------------------------------------ the owner tests */
|
|
291
|
+
|
|
292
|
+
const UI_COVER = `(() => {
|
|
293
|
+
const W = innerWidth, H = innerHeight, S = 8, gw = Math.ceil(W / S), gh = Math.ceil(H / S); const grid = new Uint8Array(gw * gh); const centre = [];
|
|
294
|
+
const alpha = (c) => { const m = /rgba?\\(([^)]+)\\)/.exec(c || ''); if (!m) return 0; const p = m[1].split(/[ ,\\/]+/).filter(Boolean); return p.length >= 4 ? parseFloat(p[3]) : 1; };
|
|
295
|
+
const eff = (el) => { let o = 1; for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const cs = getComputedStyle(e); if (cs.display === 'none' || cs.visibility === 'hidden') return 0; o *= parseFloat(cs.opacity); } return o; };
|
|
296
|
+
let world = []; try { const sel = window.__homiePort && window.__homiePort.world; if (sel) world = [...document.querySelectorAll(sel)]; } catch (e) {}
|
|
297
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
298
|
+
if (el.tagName === 'CANVAS' && el.getBoundingClientRect().width > 0.5 * W) continue;
|
|
299
|
+
if (world.some((w) => w === el || w.contains(el))) continue;
|
|
300
|
+
if (['SCRIPT', 'STYLE', 'IFRAME'].includes(el.tagName)) continue;
|
|
301
|
+
const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1 || r.right <= 0 || r.bottom <= 0 || r.left >= W || r.top >= H) continue;
|
|
302
|
+
const cs = getComputedStyle(el); const bg = alpha(cs.backgroundColor); const img = cs.backgroundImage !== 'none';
|
|
303
|
+
let text = false; for (const n of el.childNodes) if (n.nodeType === 3 && n.textContent.trim()) text = true;
|
|
304
|
+
const border = parseFloat(cs.borderTopWidth) > 0 && alpha(cs.borderTopColor) > 0.04;
|
|
305
|
+
if (!(bg > 0.04 || img || text || border || el.tagName === 'IMG' || el.tagName === 'svg')) continue;
|
|
306
|
+
const o = eff(el); if (o < 0.05) continue;
|
|
307
|
+
if (r.width * r.height > 0.4 * W * H && bg < 0.3 && !img) continue;
|
|
308
|
+
const x0 = Math.max(0, Math.floor(r.left / S)), x1 = Math.min(gw, Math.ceil(r.right / S)), y0 = Math.max(0, Math.floor(r.top / S)), y1 = Math.min(gh, Math.ceil(r.bottom / S));
|
|
309
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) grid[y * gw + x] = 1;
|
|
310
|
+
if (r.right > W / 3 && r.left < 2 * W / 3 && r.bottom > H / 3 && r.top < 2 * H / 3 && (bg * o > 0.6 || img)) centre.push({ tag: el.tagName, cls: String(el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className).slice(0, 40), w: Math.round(r.width), h: Math.round(r.height) });
|
|
311
|
+
}
|
|
312
|
+
let n = 0; for (const v of grid) n += v;
|
|
313
|
+
return { cover: +(n / grid.length).toFixed(3), opaqueCentre: centre.slice(0, 8) };
|
|
314
|
+
})()`;
|
|
315
|
+
|
|
316
|
+
async function ownerTests(h, how, log) {
|
|
317
|
+
const i = await info(h);
|
|
318
|
+
const view = i?.v ?? 'top';
|
|
319
|
+
const size = Number(i?.size) || 1;
|
|
320
|
+
const keys = i?.keys ?? { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
|
|
321
|
+
const out = { view, size, how };
|
|
322
|
+
await focusGame(h);
|
|
323
|
+
await sleep(600);
|
|
324
|
+
if (view === 'board') {
|
|
325
|
+
// Every press / swipe is the move the game applies.
|
|
326
|
+
const seq = ['left', 'up', 'right', 'down', 'left', 'down', 'right', 'up', 'left', 'right'];
|
|
327
|
+
const rows = [];
|
|
328
|
+
for (const dir of seq) {
|
|
329
|
+
const before = await info(h);
|
|
330
|
+
if (how === 'keys') await holdKey(h, keys[dir], 60); else await swipe(h, dir);
|
|
331
|
+
let after = null;
|
|
332
|
+
const t0 = Date.now();
|
|
333
|
+
while (Date.now() - t0 < 1200) { after = await info(h); if ((after?.moves ?? 0) > (before?.moves ?? 0)) break; await sleep(60); }
|
|
334
|
+
rows.push({ dir, applied: after?.lastMove ?? null, moved: (after?.moves ?? 0) > (before?.moves ?? 0), ms: Date.now() - t0 });
|
|
335
|
+
await sleep(250);
|
|
336
|
+
}
|
|
337
|
+
const passed = rows.filter((r) => r.moved && r.applied === r.dir).length;
|
|
338
|
+
out.board = { presses: rows.length, passed, rows };
|
|
339
|
+
out.ok = passed >= Math.ceil(rows.length * 0.9);
|
|
340
|
+
if (!out.ok) out.why = `${rows.length - passed} of ${rows.length} ${how === 'keys' ? 'key presses' : 'swipes'} were not applied as the pressed direction (probe lastMove/moves)`;
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
const holds = view === 'side' ? ['right', 'left'] : ['down', 'left'];
|
|
344
|
+
out.holds = [];
|
|
345
|
+
for (const dir of holds) {
|
|
346
|
+
const a = await frameNow(h);
|
|
347
|
+
if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
|
|
348
|
+
const b = await frameNow(h);
|
|
349
|
+
await sleep(250);
|
|
350
|
+
const res = judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
|
|
351
|
+
out.holds.push(res);
|
|
352
|
+
log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${res.ok ? 'ok' : `FAIL (${res.why})`} travel ${res.travelBodies} bodies, yaw ${res.yawChangeDeg}°`);
|
|
353
|
+
await sleep(400);
|
|
354
|
+
}
|
|
355
|
+
// Back toward the middle before the alternating presses (the holds ended against a wall); not judged.
|
|
356
|
+
for (const dir of view === 'side' ? ['right'] : ['up', 'right']) {
|
|
357
|
+
if (how === 'keys') await holdKey(h, keys[dir], 1100); else await touchDrag(h, thumbAt(h, i), dir, 70, 1100);
|
|
358
|
+
}
|
|
359
|
+
await sleep(300);
|
|
360
|
+
const alt = view === 'side' ? ['left', 'right'] : ['down', 'up', 'left', 'right'];
|
|
361
|
+
const presses = [];
|
|
362
|
+
const a0 = await frameNow(h);
|
|
363
|
+
const until = Date.now() + 10_000;
|
|
364
|
+
for (let k = 0; Date.now() < until; k++) {
|
|
365
|
+
const dir = alt[k % alt.length];
|
|
366
|
+
const a = await frameNow(h);
|
|
367
|
+
if (how === 'keys') await holdKey(h, keys[dir], 420); else await touchDrag(h, thumbAt(h, i), dir, 70, 380);
|
|
368
|
+
const b = await frameNow(h);
|
|
369
|
+
presses.push({ dir, a, b });
|
|
370
|
+
await sleep(170);
|
|
371
|
+
}
|
|
372
|
+
await sleep(200);
|
|
373
|
+
out.alternate = judgePresses(await rowsSince(h, a0 - 50), presses, size, view);
|
|
374
|
+
log(` ${h.engine}-${h.kind} ${how} alternate: ${out.alternate.passed}/${out.alternate.presses - out.alternate.blocked} moving presses went the pressed way (${out.alternate.blocked} blocked)`);
|
|
375
|
+
out.ok = out.holds.every((x) => x.ok) && out.alternate.ok;
|
|
376
|
+
if (!out.ok) out.why = [...out.holds.filter((x) => !x.ok).map((x) => `hold ${x.dir}: ${x.why}`), out.alternate.ok ? null : `alternate: ${out.alternate.wrongWay.length} presses went the wrong way (${out.alternate.wrongWay.join(', ')}), ${out.alternate.blocked} of ${out.alternate.presses} moved nothing`].filter(Boolean).join('; ');
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ------------------------------------------------------------------ the check */
|
|
381
|
+
|
|
382
|
+
export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000 }) {
|
|
383
|
+
if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
|
|
384
|
+
const base = String(url).replace(/\/+$/, '');
|
|
385
|
+
const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
|
|
386
|
+
const exe = CHROMES.find((p) => existsSync(p));
|
|
387
|
+
if (!exe) return { ok: false, command: 'port check', why: 'no Chrome found (set CHROME_PATH)' };
|
|
388
|
+
const req = createRequire(join(root ?? process.cwd(), 'package.json'));
|
|
389
|
+
let puppeteer;
|
|
390
|
+
try { puppeteer = (await import('puppeteer-core')).default; } catch { try { puppeteer = req('puppeteer-core'); } catch { return { ok: false, command: 'port check', why: 'puppeteer-core is missing (it comes with @homie-rocks/studio: npm install)' }; } }
|
|
391
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '');
|
|
392
|
+
const out = shots ?? (root ? join(root, 'games', game, '.port', `check-${stamp}`) : mkdtempSync(join(tmpdir(), 'homie-port-check-out-')));
|
|
393
|
+
mkdirSync(out, { recursive: true });
|
|
394
|
+
if (root) { try { writeFileSync(join(root, 'games', game, '.port', '.gitignore'), '*\n'); } catch { /* fine */ } }
|
|
395
|
+
const rows = [];
|
|
396
|
+
const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); };
|
|
397
|
+
const open_ = [];
|
|
398
|
+
const launch = async (kind) => { const h = await chrome(puppeteer, kind, exe); open_.push(h); return h; };
|
|
399
|
+
const close = async (h) => { const i = open_.indexOf(h); if (i >= 0) open_.splice(i, 1); await h.close(); };
|
|
400
|
+
const shot = async (h, name) => { await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000); };
|
|
401
|
+
const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
|
|
402
|
+
const errorsSeen = [];
|
|
403
|
+
const collect = (h) => { for (const e of h.errors) errorsSeen.push(`${h.engine}-${h.kind}: ${e}`); for (const r of h.badResponses) errorsSeen.push(`${h.engine}-${h.kind}: HTTP ${r}`); };
|
|
404
|
+
const started = Date.now();
|
|
405
|
+
// The site must answer before each row: a dev server that died mid-check made every later row fail as "no game
|
|
406
|
+
// frame", which reads like a broken game. Say what it is and stop.
|
|
407
|
+
const alive = async () => {
|
|
408
|
+
try { const r = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(8000) }); return r.ok; } catch { return false; }
|
|
409
|
+
};
|
|
410
|
+
const gate = async (name) => {
|
|
411
|
+
if (await alive()) return true;
|
|
412
|
+
row(name, false, { why: `${base} stopped answering: is the dev server still running? (restart it as a background task that outlives this command, then rerun the check)` });
|
|
413
|
+
return false;
|
|
414
|
+
};
|
|
415
|
+
if (!(await alive())) {
|
|
416
|
+
const result = { ok: false, command: 'port check', game, url: base, why: `${base}/${game}/play does not answer: start the site first (npm run dev, kept running in the background) or check the address` };
|
|
417
|
+
writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
/* ---- owner tests, a computer on keys (alone in a private room: bots only) */
|
|
422
|
+
if (want.has('owner-desk') && await gate('owner-desk')) {
|
|
423
|
+
try {
|
|
424
|
+
const h = await launch('desk');
|
|
425
|
+
const o = await open(h, `${base}/${game}/play?room=${privateRoom('desk')}&name=Check`);
|
|
426
|
+
if (!o.ok) row('owner-desk', false, { why: o.why, seated: o });
|
|
427
|
+
else {
|
|
428
|
+
const res = await ownerTests(h, 'keys', log);
|
|
429
|
+
const after = await info(h);
|
|
430
|
+
await shot(h, 'desk-playing');
|
|
431
|
+
const audio = after?.audio ?? null;
|
|
432
|
+
row('owner-desk', res.ok, { ...res, seatedMs: o.ms, fps: after?.fps ?? null });
|
|
433
|
+
if (audio) {
|
|
434
|
+
const ctxs = audio.contexts ?? [];
|
|
435
|
+
row('audio', ctxs.length ? ctxs.every((s) => s === 'running') : null, { contexts: ctxs, refusedMedia: audio.refusedMedia, why: ctxs.length ? (ctxs.every((s) => s === 'running') ? undefined : 'an AudioContext is still suspended after the first keys') : 'no Web Audio in this game (media elements only, or silent)' });
|
|
436
|
+
}
|
|
437
|
+
row('sandbox', true, { sandbox: after?.sandbox ?? null });
|
|
438
|
+
}
|
|
439
|
+
collect(h); await close(h);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
row('owner-desk', false, { why: `the check itself hit an error: ${String(e?.message ?? e).split('\n')[0]}` });
|
|
442
|
+
for (const h of [...open_]) { collect(h); await close(h).catch(() => {}); }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* ---- owner tests with real touch: Android Chrome, then iPhone WebKit */
|
|
447
|
+
if (want.has('owner-phone') && await gate('owner-phone')) {
|
|
448
|
+
try {
|
|
449
|
+
const h = await launch('phone');
|
|
450
|
+
const o = await open(h, `${base}/${game}/play?room=${privateRoom('phone')}&name=Thumb`);
|
|
451
|
+
if (!o.ok) row('owner-phone', false, { why: o.why, seated: o });
|
|
452
|
+
else {
|
|
453
|
+
await sleep(800);
|
|
454
|
+
const cover = await inFrame(h, UI_COVER);
|
|
455
|
+
const res = await ownerTests(h, 'touch', log);
|
|
456
|
+
const after = await info(h);
|
|
457
|
+
await shot(h, 'phone-playing');
|
|
458
|
+
row('owner-phone', res.ok, { ...res, seatedMs: o.ms, fps: after?.fps ?? null });
|
|
459
|
+
row('ui-cover', cover ? cover.cover <= 0.12 && !cover.opaqueCentre.length : null, { ...(cover ?? {}), why: cover && (cover.cover > 0.12 || cover.opaqueCentre.length) ? `UI covers ${Math.round(cover.cover * 100)}% of a phone${cover.opaqueCentre.length ? ' and something opaque sits in the middle' : ''}` : undefined });
|
|
460
|
+
}
|
|
461
|
+
collect(h); await close(h);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
row('owner-phone', false, { why: `the check itself hit an error: ${String(e?.message ?? e).split('\n')[0]}` });
|
|
464
|
+
for (const h of [...open_]) { collect(h); await close(h).catch(() => {}); }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (want.has('owner-iphone') && await gate('owner-iphone')) {
|
|
468
|
+
try {
|
|
469
|
+
let pw = null;
|
|
470
|
+
try { pw = req('playwright-core'); } catch { /* not installed */ }
|
|
471
|
+
if (!pw) row('owner-iphone', null, { why: 'WebKit is not installed: npm i -D playwright-core@1.58.2 && npx playwright-core install webkit, then run the check again' });
|
|
472
|
+
else {
|
|
473
|
+
let h = null;
|
|
474
|
+
try { h = await webkit(pw); open_.push(h); } catch (e) { row('owner-iphone', null, { why: `WebKit did not start (${String(e.message ?? e).split('\n')[0]}): npx playwright-core install webkit` }); }
|
|
475
|
+
if (h) {
|
|
476
|
+
const o = await open(h, `${base}/${game}/play?room=${privateRoom('iphone')}&name=Pocket`);
|
|
477
|
+
if (!o.ok) row('owner-iphone', false, { why: o.why, seated: o });
|
|
478
|
+
else {
|
|
479
|
+
const res = await ownerTests(h, 'touch', log);
|
|
480
|
+
const after = await info(h);
|
|
481
|
+
await shot(h, 'iphone-playing');
|
|
482
|
+
row('owner-iphone', res.ok, { ...res, seatedMs: o.ms, fps: after?.fps ?? null });
|
|
483
|
+
}
|
|
484
|
+
collect(h); await close(h);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch (e) {
|
|
488
|
+
row('owner-iphone', false, { why: `the check itself hit an error: ${String(e?.message ?? e).split('\n')[0]}` });
|
|
489
|
+
for (const h of [...open_]) { collect(h); await close(h).catch(() => {}); }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* ---- two fresh browsers meet through the lobby and finish a round */
|
|
494
|
+
if (want.has('round') && await gate('round')) {
|
|
495
|
+
try {
|
|
496
|
+
const A = await launch('desk');
|
|
497
|
+
const B = await launch('phone');
|
|
498
|
+
const [oa, ob] = [await open(A, `${base}/${game}/play?name=Desk`), await open(B, `${base}/${game}/play?name=Phone`)];
|
|
499
|
+
if (!oa.ok || !ob.ok) row('round', false, { why: `a browser was never seated (${!oa.ok ? `computer: ${oa.why}` : ''} ${!ob.ok ? `phone: ${ob.why}` : ''})` });
|
|
500
|
+
else if (oa.shell.room !== ob.shell.room) row('round', false, { why: `the two browsers landed in different rooms (${oa.shell.room}, ${ob.shell.room})` });
|
|
501
|
+
else {
|
|
502
|
+
const seats = [oa.shell.seat, ob.shell.seat];
|
|
503
|
+
const bothAt = Date.now();
|
|
504
|
+
const i0 = await info(A);
|
|
505
|
+
const keys = i0?.keys ?? { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
|
|
506
|
+
let over = null; let k = 0;
|
|
507
|
+
await focusGame(A);
|
|
508
|
+
await shot(A, 'round-desk'); await shot(B, 'round-phone');
|
|
509
|
+
const deadline = Date.now() + roundTimeoutMs;
|
|
510
|
+
while (Date.now() < deadline && !over) {
|
|
511
|
+
const dir = ['right', 'down', 'left', 'up'][k++ % 4];
|
|
512
|
+
if (i0?.v === 'board') { await holdKey(A, keys[dir], 60); await swipe(B, dir); await sleep(300); }
|
|
513
|
+
else { await holdKey(A, keys[dir], 500); await touchDrag(B, thumbAt(B, i0), ['left', 'up', 'right', 'down'][k % 4], 60, 400); }
|
|
514
|
+
const [sa, sb] = [await shell(A), await shell(B)];
|
|
515
|
+
const hit = (s) => (s?.results ?? []).find((x) => x.at > bothAt && seats.every((seat) => x.results.some((r) => r.seat === seat)));
|
|
516
|
+
const ha = hit(sa); const hb = hit(sb);
|
|
517
|
+
if (ha && hb) over = { a: ha, b: hb };
|
|
518
|
+
}
|
|
519
|
+
await shot(A, 'round-over-desk'); await shot(B, 'round-over-phone');
|
|
520
|
+
if (!over) row('round', false, { why: 'no round finished with both browsers in the results in time', room: oa.shell.room });
|
|
521
|
+
else {
|
|
522
|
+
const same = over.a.n === over.b.n && over.a.endsAt === over.b.endsAt;
|
|
523
|
+
const humans = over.a.results.filter((r) => !r.bot).length;
|
|
524
|
+
row('round', same && humans >= 2, { room: oa.shell.room, n: over.a.n, humans, bots: over.a.results.length - humans, results: over.a.results, seatedMs: [oa.ms, ob.ms], ms: Date.now() - bothAt, why: !same ? 'the two browsers saw different rounds' : humans < 2 ? 'the results do not list both people' : undefined });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
collect(A); collect(B); await close(A); await close(B);
|
|
528
|
+
} catch (e) {
|
|
529
|
+
row('round', false, { why: `the check itself hit an error: ${String(e?.message ?? e).split('\n')[0]}` });
|
|
530
|
+
for (const h of [...open_]) { collect(h); await close(h).catch(() => {}); }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* ---- the host dies mid-round; then a late joiner takes a bot's place */
|
|
535
|
+
if (want.has('life') && await gate('life')) {
|
|
536
|
+
try {
|
|
537
|
+
const room = privateRoom('life');
|
|
538
|
+
const A = await launch('desk');
|
|
539
|
+
const oa = await open(A, `${base}/${game}/play?room=${room}&name=Host`);
|
|
540
|
+
const B = await launch('phone');
|
|
541
|
+
const ob = await open(B, `${base}/${game}/play?room=${room}&name=Stays`);
|
|
542
|
+
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); }
|
|
543
|
+
else {
|
|
544
|
+
await sleep(3000);
|
|
545
|
+
const ia = await info(A); const ib = await info(B);
|
|
546
|
+
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 });
|
|
547
|
+
else {
|
|
548
|
+
await shot(B, 'life-before');
|
|
549
|
+
const pids = A.pid ? [A.pid, ...descendants(A.pid)] : [];
|
|
550
|
+
const killAt = Date.now();
|
|
551
|
+
for (const p of pids) { try { process.kill(p, 'SIGKILL'); } catch { /* gone */ } }
|
|
552
|
+
open_.splice(open_.indexOf(A), 1);
|
|
553
|
+
collect(A);
|
|
554
|
+
void A.close();
|
|
555
|
+
let promoted = null; let ibAfter = null;
|
|
556
|
+
while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (ibAfter?.role === 'host') { promoted = Date.now() - killAt; break; } await sleep(100); }
|
|
557
|
+
await sleep(1500);
|
|
558
|
+
const ib2 = await info(B);
|
|
559
|
+
const sameRound = ib?.round && ib2?.round && (ib2.round.n === ib.round.n || (ib.round.phase === 'over' || ib.round.leftMs < 3000));
|
|
560
|
+
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;
|
|
561
|
+
const running = (ib2?.frames ?? 0) > (ibAfter?.frames ?? 0) && (ib2?.net?.snapHzOut ?? 0) >= 0;
|
|
562
|
+
await shot(B, 'life-after-kill');
|
|
563
|
+
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 });
|
|
564
|
+
// Late joiner into the same, running room.
|
|
565
|
+
const C = await launch('desk');
|
|
566
|
+
const joinAt = Date.now();
|
|
567
|
+
const oc = await open(C, `${base}/${game}/play?room=${room}&name=Late`);
|
|
568
|
+
await sleep(1500);
|
|
569
|
+
const ic = await info(C); const ib3 = await info(B);
|
|
570
|
+
const slots = ic?.slots ?? [];
|
|
571
|
+
const humans = slots.filter((s) => !s.bot).length;
|
|
572
|
+
const before = ib2?.slots ?? [];
|
|
573
|
+
const tookBot = before.some((s) => s.bot) ? slots.length === before.length : true;
|
|
574
|
+
const snapIn = ic?.net?.snapHzIn ?? 0;
|
|
575
|
+
const clockSkew = ic?.round && ib3?.round ? Math.abs(ic.round.leftMs - ib3.round.leftMs) : null;
|
|
576
|
+
// The joiner must steer its body at once, not only hold a seat: a short press moves it.
|
|
577
|
+
let steers = null;
|
|
578
|
+
if (ic?.v !== 'board' && oc.ok) {
|
|
579
|
+
await focusGame(C);
|
|
580
|
+
const keysC = ic?.keys ?? { right: 'ArrowRight', left: 'ArrowLeft' };
|
|
581
|
+
const r0 = (await rowsSince(C, 0)).filter(finite).pop();
|
|
582
|
+
await holdKey(C, keysC.right, 700);
|
|
583
|
+
await holdKey(C, keysC.left, 700);
|
|
584
|
+
const moved = (await rowsSince(C, 0)).filter(finite);
|
|
585
|
+
const far = r0 ? Math.max(0, ...moved.map((r) => Math.hypot(r[1] - r0[1], r[2] - r0[2]))) : 0;
|
|
586
|
+
steers = far > (Number(ic?.size) || 1) * 0.5;
|
|
587
|
+
}
|
|
588
|
+
await shot(C, 'late-joiner');
|
|
589
|
+
const ok = oc.ok && ic?.seat !== null && ic?.round && ib3?.round && ic.round.n === ib3.round.n && humans >= 2 && tookBot && snapIn > 5 && (clockSkew === null || clockSkew < 2500) && steers !== false;
|
|
590
|
+
row('late-join', Boolean(ok), { seatedMs: oc.ms, sinceJoinMs: Date.now() - joinAt, role: ic?.role, seat: ic?.seat, round: ic?.round, hostRound: ib3?.round, humans, bodies: slots.length, bodiesBefore: before.length, tookBot, steers, snapHzIn: snapIn, clockSkewMs: clockSkew,
|
|
591
|
+
why: !oc.ok ? oc.why : ic?.round?.n !== ib3?.round?.n ? 'the late joiner is in a different round' : humans < 2 ? 'the late joiner is not one of the people in the roster' : !tookBot ? 'the late joiner added a body instead of taking a bot\'s place' : steers === false ? 'the late joiner has a seat but its keys do not move its body' : snapIn <= 5 ? 'no world is flowing to the late joiner' : clockSkew !== null && clockSkew >= 2500 ? `the late joiner's clock is ${clockSkew} ms off the host's` : undefined });
|
|
592
|
+
collect(C); await close(C);
|
|
593
|
+
}
|
|
594
|
+
collect(B); await close(B);
|
|
595
|
+
if (open_.includes(A)) { collect(A); await close(A); }
|
|
596
|
+
}
|
|
597
|
+
} catch (e) {
|
|
598
|
+
row('host-kill', false, { why: `the check itself hit an error: ${String(e?.message ?? e).split('\n')[0]}` });
|
|
599
|
+
for (const h of [...open_]) { collect(h); await close(h).catch(() => {}); }
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ---- the big screen */
|
|
604
|
+
if (want.has('tv') && await gate('tv')) {
|
|
605
|
+
try {
|
|
606
|
+
const h = await launch('tv');
|
|
607
|
+
await T(h.page.goto(`${base}/${game}/tv`, { waitUntil: 'domcontentloaded', timeout: 60_000 }), 65_000);
|
|
608
|
+
let i = null; const t0 = Date.now();
|
|
609
|
+
while (Date.now() - t0 < 30_000) { i = await info(h); if (i && (i.frames ?? 0) > 30) break; await sleep(400); }
|
|
610
|
+
await sleep(2500);
|
|
611
|
+
const i2 = await info(h);
|
|
612
|
+
const s = await shell(h);
|
|
613
|
+
const qr = await T(h.page.evaluate(() => Boolean(document.querySelector('[data-join] svg'))), 5000, false);
|
|
614
|
+
const png = await T(h.page.screenshot({ path: join(out, 'tv.png') }), 15_000);
|
|
615
|
+
const lum = png ? await lumaOf(h) : null;
|
|
616
|
+
const me = (await rowsSince(h, 0)).some(finite);
|
|
617
|
+
const ok = Boolean(i2 && s && s.seat === null && !me && qr && (i2.frames ?? 0) > (i?.frames ?? 0) && (lum === null || lum > 6));
|
|
618
|
+
row('tv', ok, { role: i2?.role, seat: s?.seat ?? null, hasBody: me, qr, frames: i2?.frames ?? null, luma: lum, why: !i2 ? 'no game probe on the big screen' : s?.seat !== null ? 'the big screen took a seat' : me ? 'the big screen reports a body of its own' : !qr ? 'no join QR on the big screen' : lum !== null && lum <= 6 ? 'the big screen picture is black' : undefined });
|
|
619
|
+
collect(h); await close(h);
|
|
620
|
+
} catch (e) {
|
|
621
|
+
row('tv', false, { why: `the check itself hit an error: ${String(e?.message ?? e).split('\n')[0]}` });
|
|
622
|
+
for (const h of [...open_]) { collect(h); await close(h).catch(() => {}); }
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/* ---- errors across every browser */
|
|
627
|
+
const fatal = errorsSeen.filter((e) => /pageerror/.test(e));
|
|
628
|
+
row('errors', fatal.length === 0, { count: errorsSeen.length, fatal: fatal.slice(0, 12), other: errorsSeen.filter((e) => !/pageerror/.test(e)).slice(0, 12), why: fatal.length ? `${fatal.length} uncaught error(s) in the game` : undefined });
|
|
629
|
+
} finally {
|
|
630
|
+
for (const h of [...open_]) await h.close().catch(() => {});
|
|
631
|
+
}
|
|
632
|
+
const failed = rows.filter((r) => r.ok === false);
|
|
633
|
+
const skipped = rows.filter((r) => r.ok === null);
|
|
634
|
+
const result = { ok: failed.length === 0 && rows.length > 0, command: 'port check', game, url: base, out, totalMs: Date.now() - started, passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
|
|
635
|
+
writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
|
|
636
|
+
return result;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Mean luma of the page as the screenshot sees it (0-255), from a small downscaled capture. */
|
|
640
|
+
async function lumaOf(h) {
|
|
641
|
+
try {
|
|
642
|
+
// The game's picture only: the left 60% and top 75%, away from the join card.
|
|
643
|
+
const buf = await h.page.screenshot({ type: 'jpeg', quality: 40, clip: { x: 0, y: 0, width: Math.round(h.vp.width * 0.6), height: Math.round(h.vp.height * 0.75) }, encoding: 'base64' });
|
|
644
|
+
return await h.page.evaluate(async (b64) => {
|
|
645
|
+
const img = new Image(); img.src = `data:image/jpeg;base64,${b64}`; await img.decode();
|
|
646
|
+
const c = document.createElement('canvas'); c.width = 64; c.height = 36; const g = c.getContext('2d'); g.drawImage(img, 0, 0, 64, 36);
|
|
647
|
+
const d = g.getImageData(0, 0, 64, 36).data; let s = 0; for (let i = 0; i < d.length; i += 4) s += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
|
|
648
|
+
return Math.round(s / (d.length / 4));
|
|
649
|
+
}, buf);
|
|
650
|
+
} catch { return null; }
|
|
651
|
+
}
|