@homie-rocks/studio 0.8.0 → 0.9.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 +28 -4
- package/bin/homie-studio.mjs +37 -1
- package/lib/build.mjs +2 -0
- package/lib/check.mjs +96 -37
- package/lib/look.mjs +3 -2
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +13 -1
- package/lib/site.mjs +8 -1
- package/lib/template-history.json +157 -0
- package/lib/upgrade.mjs +285 -0
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +50 -21
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
|
|
|
10
10
|
and the person approves Cloudflare once in their browser.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
npx -y --package=https://homie.rocks/npm/homie-studio-0.
|
|
13
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio new ./night-owls --name "Night Owls"
|
|
14
14
|
cd night-owls && npm install
|
|
15
15
|
npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
|
|
16
16
|
npx homie-studio dev # the whole site locally
|
|
@@ -19,6 +19,7 @@ npx homie-studio deploy --plan # what deploy will create
|
|
|
19
19
|
npx homie-studio deploy # the studio's own Cloudflare
|
|
20
20
|
npx homie-studio publish # the homie.rocks directory
|
|
21
21
|
npx homie-studio stats # the studio's own numbers, for its owner
|
|
22
|
+
npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
## The site: the hub's shape, and a landing for every game
|
|
@@ -33,8 +34,30 @@ live rooms, credits (the original and its licence for a port), and "Make a game
|
|
|
33
34
|
files. `site/SITE.md` says all of it.
|
|
34
35
|
|
|
35
36
|
The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
|
|
36
|
-
screen and the room code.
|
|
37
|
-
|
|
37
|
+
screen and the room code. game.json's `screen.share` puts it where each game has room, per device (a corner or
|
|
38
|
+
the top's middle, moved in by `x` / `y`, or kept a small icon), so it never covers a scoreboard. A game whose
|
|
39
|
+
picture is white or cream gets a light landing (`landing.scheme: "light"`). Every card and the directory show a
|
|
40
|
+
game's landing still. Every HTML answer is `no-transform`, and the site's pages are never framed by another
|
|
41
|
+
site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
|
|
42
|
+
`not-a-homie`, at the site's root as under the game.
|
|
43
|
+
|
|
44
|
+
## Upgrading a studio
|
|
45
|
+
|
|
46
|
+
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade # the plan; changes nothing
|
|
50
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade --apply # after the person agrees
|
|
51
|
+
npm install && npm run build
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`upgrade` compares the studio with what this version's `new` would write for it: an AGENTS.md section, a README,
|
|
55
|
+
a `.gitignore` line, a D1 migration or a package.json script it lacks is added, and one it never changed since an
|
|
56
|
+
older template wrote it (`lib/template-history.json` knows every earlier version's text by a fingerprint) takes
|
|
57
|
+
the newer text. Anything the studio changed or wrote itself is kept as it is: the plan lists it, and `--diff`
|
|
58
|
+
shows how it differs from the template. `--apply` also pins this version in package.json and sets studio.json's
|
|
59
|
+
`homie.studio`. When the template's words change, `node scripts/studio-template-history.mjs` records them (a test
|
|
60
|
+
fails until it has).
|
|
38
61
|
|
|
39
62
|
## Rooms of up to 32
|
|
40
63
|
|
|
@@ -135,7 +158,8 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
135
158
|
| `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
|
|
136
159
|
| `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
|
|
137
160
|
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations, the directory claim; refuses resources it did not create. `storage add`: the optional R2 bucket. |
|
|
138
|
-
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
|
|
161
|
+
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. |
|
|
162
|
+
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
139
163
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
140
164
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
141
165
|
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* homie-studio media put <file> [--as <key>]
|
|
24
24
|
* homie-studio media list
|
|
25
25
|
* homie-studio status
|
|
26
|
+
* homie-studio upgrade [--apply] [--diff]
|
|
27
|
+
* (what this version's template adds to an existing studio: AGENTS.md
|
|
28
|
+
* sections, READMEs, .gitignore lines, the pin; changes nothing until
|
|
29
|
+
* --apply, and never a file or section the studio changed)
|
|
26
30
|
* homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
|
|
27
31
|
* homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
|
|
28
32
|
* homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
|
|
@@ -61,6 +65,7 @@ import { importPort, planPort } from '../lib/port.mjs';
|
|
|
61
65
|
import { portCheck } from '../lib/port-check.mjs';
|
|
62
66
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
63
67
|
import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
|
|
68
|
+
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
64
69
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
|
|
65
70
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
66
71
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
@@ -68,7 +73,7 @@ import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, star
|
|
|
68
73
|
|
|
69
74
|
const argv = process.argv.slice(2);
|
|
70
75
|
const flags = new Map();
|
|
71
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share'];
|
|
76
|
+
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff'];
|
|
72
77
|
const positional = [];
|
|
73
78
|
for (let i = 0; i < argv.length; i++) {
|
|
74
79
|
const a = argv[i];
|
|
@@ -198,6 +203,33 @@ function print(result) {
|
|
|
198
203
|
case 'look':
|
|
199
204
|
lines.push(`${result.ok ? 'Looks right' : 'Look again'}: ${result.rows.length} views of ${result.url}`, ...result.rows.map((r) => ` ${r.ok ? 'ok ' : 'FIX '} ${r.path} on ${({ computer: 'a computer', phone: 'a phone', sideways: 'a phone turned sideways' })[r.device] ?? r.device}${r.problems.length ? `: ${r.problems.join('; ')}` : ''}`), '', `Pictures (open them and look): ${result.shots}`);
|
|
200
205
|
break;
|
|
206
|
+
case 'upgrade': {
|
|
207
|
+
const show = (text, n = 14) => { const l = String(text ?? '').replace(/\n+$/, '').split('\n'); return [...l.slice(0, n).map((x) => ` ${x}`), ...(l.length > n ? [` … (${l.length - n} more lines; --json has all of it)`] : [])]; };
|
|
208
|
+
const mark = { 'add-file': '+', 'add-section': '+', 'add-lines': '+', scripts: '+', 'update-file': '~', 'update-section': '~', pin: '~', version: '~' };
|
|
209
|
+
const list = result.applied ? result.done : result.changes;
|
|
210
|
+
lines.push(result.applied
|
|
211
|
+
? `${result.studio} is on the @homie-rocks/studio ${result.to} template now (${list.length} change${list.length === 1 ? '' : 's'}).`
|
|
212
|
+
: list.length ? `${result.studio}: what the @homie-rocks/studio ${result.to} template adds (from ${result.from ?? 'an older version'}). Nothing is changed until --apply.` : `${result.studio} has everything the @homie-rocks/studio ${result.to} template writes.`);
|
|
213
|
+
if (list.length) lines.push('');
|
|
214
|
+
for (const c of list) {
|
|
215
|
+
lines.push(` ${mark[c.kind] ?? '·'} ${c.file.padEnd(18)} ${c.what}${c.kind === 'add-section' && (c.after || c.before) ? ` (${c.after ? `after "${c.after}"` : `before "${c.before}"`})` : ''}`);
|
|
216
|
+
if (result.applied) continue;
|
|
217
|
+
if (c.kind === 'add-section' || (c.kind === 'add-file' && c.file.endsWith('.md'))) lines.push(...show(c.text));
|
|
218
|
+
if (c.kind === 'update-section' || c.kind === 'update-file') lines.push(...lineDiff(c.from, c.text).map((x) => ` ${x}`));
|
|
219
|
+
}
|
|
220
|
+
for (const c of result.skipped ?? []) lines.push(` ! ${c.file.padEnd(18)} not applied: ${c.why}`);
|
|
221
|
+
if (result.kept?.length) {
|
|
222
|
+
lines.push('', 'Kept as this studio wrote them (never changed by upgrade; the template\'s text differs):');
|
|
223
|
+
for (const k of result.kept) {
|
|
224
|
+
lines.push(` = ${k.file.padEnd(18)} ${k.what}`);
|
|
225
|
+
if (flags.has('diff') && k.mine !== undefined) lines.push(...lineDiff(k.mine, k.template).map((x) => ` ${x}`));
|
|
226
|
+
}
|
|
227
|
+
if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
|
|
228
|
+
}
|
|
229
|
+
if (result.uncommitted && !result.applied && result.changes.length) lines.push('', `Note: ${result.uncommitted} file(s) have uncommitted changes; commit them first so the upgrade is a change of its own.`);
|
|
230
|
+
if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
201
233
|
case 'check':
|
|
202
234
|
lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
|
|
203
235
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
|
|
@@ -237,6 +269,10 @@ async function main() {
|
|
|
237
269
|
const studio = readStudio(root);
|
|
238
270
|
return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
|
|
239
271
|
}
|
|
272
|
+
if (cmd === 'upgrade') {
|
|
273
|
+
const plan = upgradePlan(root);
|
|
274
|
+
return flags.has('apply') || flags.has('yes') ? upgradeApply(root, plan) : plan;
|
|
275
|
+
}
|
|
240
276
|
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
241
277
|
if (cmd === 'dev') return dev(root);
|
|
242
278
|
if (cmd === 'check') {
|
package/lib/build.mjs
CHANGED
|
@@ -208,6 +208,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
208
208
|
},
|
|
209
209
|
// studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
|
|
210
210
|
...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
|
|
211
|
+
// studio.json `"rooms": { "share": false }`: the manifest names no rooms, so the hub shows none of this studio's.
|
|
212
|
+
...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
|
|
211
213
|
},
|
|
212
214
|
games: rows,
|
|
213
215
|
songs: media.songs,
|
package/lib/check.mjs
CHANGED
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
* `report` (optional; the open progress feed, lib/progress.mjs) hears each step as it goes green or red, gets a
|
|
9
9
|
* small picture of the computer's view, and can ask the check to stop: then the browsers close and the result
|
|
10
10
|
* says `stopped`. Without it the check is exactly what it was.
|
|
11
|
+
*
|
|
12
|
+
* UNDER LOAD (a busy computer, another project's browsers): Chrome gets 150 s to start, a page 90 s to open and a
|
|
13
|
+
* seat 90 s. A browser that stalls for 10 s is let go by the relay and comes back with its seat token; the round it
|
|
14
|
+
* was away for lists its body as a bot, so that round cannot prove anything and the check waits for the next one
|
|
15
|
+
* (up to `rounds` rounds). A person the game lists as a bot while it keeps their seat (an idle player driven by
|
|
16
|
+
* the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
|
|
17
|
+
* each finished round and whether that browser lost its connection, instead of only "no round finished".
|
|
11
18
|
*/
|
|
12
19
|
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
-
import { tmpdir } from 'node:os';
|
|
20
|
+
import { cpus, loadavg, tmpdir } from 'node:os';
|
|
14
21
|
import { join } from 'node:path';
|
|
15
22
|
|
|
16
23
|
const CHROMES = [
|
|
@@ -23,6 +30,9 @@ const CHROMES = [
|
|
|
23
30
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
24
31
|
|
|
25
32
|
/** The steps a progress feed shows for this check, in order. */
|
|
33
|
+
/** Chrome's own start, on a loaded computer (a cold start there took over a minute). */
|
|
34
|
+
export const LAUNCH_TIMEOUT_MS = 150_000;
|
|
35
|
+
|
|
26
36
|
export const CHECK_STEPS = [
|
|
27
37
|
['computer-seated', 'A computer gets a seat'],
|
|
28
38
|
['phone-seated', 'A phone gets a seat'],
|
|
@@ -32,7 +42,26 @@ export const CHECK_STEPS = [
|
|
|
32
42
|
|
|
33
43
|
class Stopped extends Error {}
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
/**
|
|
46
|
+
* WHICH FINISHED ROUND PROVES THE TWO BROWSERS PLAYED TOGETHER. `browsers`: each `{ kind, seats, seen }`, where
|
|
47
|
+
* `seats` is every seat it held (a Set) and `seen` the rounds it saw finish after both were seated (a Map from
|
|
48
|
+
* `n:endsAt` to `{ n, endsAt, results, ms }`). A round counts when both saw it and each has a row carrying one of
|
|
49
|
+
* its seats, whatever the row's `bot` says (a game may hand an idle person's body to its autopilot and still keep
|
|
50
|
+
* their seat on the row: that is still the person). A round seen by both without one of them is a miss, kept in
|
|
51
|
+
* `missed` with who was missing and the rows, so the check can say why. Returns the round that counts, or null.
|
|
52
|
+
*/
|
|
53
|
+
export function judgeRounds(browsers, missed = new Map()) {
|
|
54
|
+
const mine = new Set(browsers.flatMap((b) => [...b.seats]));
|
|
55
|
+
for (const [key, r] of browsers[0]?.seen ?? []) {
|
|
56
|
+
if (missed.has(key) || !browsers.every((b) => b.seen.has(key))) continue;
|
|
57
|
+
const absent = browsers.filter((b) => !(r.results ?? []).some((row) => b.seats.has(row?.seat))).map((b) => b.kind);
|
|
58
|
+
if (!absent.length) return { ...r, key, overMs: browsers.map((b) => b.seen.get(key).ms), people: r.results.filter((row) => !row?.bot || mine.has(row?.seat)).length };
|
|
59
|
+
missed.set(key, { n: r.n, missing: absent, rows: (r.results ?? []).slice(0, 12).map((row) => ({ seat: row?.seat ?? null, name: row?.name ?? null, bot: Boolean(row?.bot) })) });
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
36
65
|
if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
|
|
37
66
|
const chrome = CHROMES.find((p) => existsSync(p));
|
|
38
67
|
if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
|
|
@@ -49,6 +78,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
49
78
|
{ name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
|
|
50
79
|
];
|
|
51
80
|
const players = [];
|
|
81
|
+
// The playing loop runs until this is false: the round is settled, or the check ends in any way (finally).
|
|
82
|
+
let playingOn = true;
|
|
52
83
|
const step = (id, state, opts = {}) => { try { report?.check?.(id, state, { label: CHECK_STEPS.find(([k]) => k === id)?.[1], ...opts }); } catch { /* a feed never breaks a check */ } };
|
|
53
84
|
const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
|
|
54
85
|
// The computer's view, small (480 px wide), for the feed's preview.
|
|
@@ -69,7 +100,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
69
100
|
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
|
|
70
101
|
profiles.push(profile);
|
|
71
102
|
const browser = await puppeteer.launch({
|
|
72
|
-
executablePath: chrome, headless: true, userDataDir: profile,
|
|
103
|
+
executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
|
|
73
104
|
args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
74
105
|
});
|
|
75
106
|
browsers.push(browser);
|
|
@@ -78,13 +109,13 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
78
109
|
const ua = await browser.userAgent();
|
|
79
110
|
await page.setUserAgent(`${ua} ${userAgentTag}`);
|
|
80
111
|
const t0 = Date.now();
|
|
81
|
-
await page.goto(play, { waitUntil: 'domcontentloaded', timeout:
|
|
112
|
+
await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
|
82
113
|
players.push({ kind: kind.name, page, t0 });
|
|
83
114
|
log(`${kind.name} opened ${play}`);
|
|
84
115
|
}
|
|
85
116
|
// Seated: the shell knows its room and the game reported a seat.
|
|
86
117
|
const seated = await Promise.all(players.map(async (p) => {
|
|
87
|
-
const deadline = Date.now() +
|
|
118
|
+
const deadline = Date.now() + 90_000;
|
|
88
119
|
while (Date.now() < deadline) {
|
|
89
120
|
const s = await p.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, players: s.facts?.counts?.players ?? null } : null; }).catch(() => null);
|
|
90
121
|
if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
|
|
@@ -95,67 +126,94 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
95
126
|
halt();
|
|
96
127
|
await sleep(250);
|
|
97
128
|
}
|
|
98
|
-
step(`${p.kind}-seated`, 'fail', { note: 'no seat within
|
|
129
|
+
step(`${p.kind}-seated`, 'fail', { note: 'no seat within 90 s' });
|
|
99
130
|
return null;
|
|
100
131
|
}));
|
|
101
|
-
if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within
|
|
132
|
+
if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 90 s', seated };
|
|
102
133
|
const sameRoom = seated[0].room === seated[1].room;
|
|
103
134
|
log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
|
|
104
135
|
step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
|
|
105
136
|
if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
|
|
106
137
|
step('round', 'running');
|
|
107
|
-
// Play: the computer holds keys
|
|
138
|
+
// Play: the computer holds keys; the phone drags from the lower left (a stick) and swipes across the bottom
|
|
139
|
+
// middle (a wheel, a slider). A game that hands an untouched player to its autopilot, or lets an idle one go,
|
|
140
|
+
// sees a hand on both. It plays until the check is over, through as many rounds as that takes.
|
|
108
141
|
const playing = (async () => {
|
|
109
142
|
const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
|
|
110
|
-
|
|
143
|
+
const vp = kinds[1].viewport;
|
|
144
|
+
for (let i = 0; playingOn && i < 5000; i++) {
|
|
111
145
|
const k = keys[i % keys.length];
|
|
112
146
|
await players[0].page.keyboard.down(k).catch(() => {});
|
|
113
147
|
await sleep(600);
|
|
114
148
|
await players[0].page.keyboard.up(k).catch(() => {});
|
|
115
|
-
const
|
|
116
|
-
await players[1].page.touchscreen.touchStart(
|
|
117
|
-
await players[1].page.touchscreen.touchMove(
|
|
149
|
+
const [x, y, dx, dy] = i % 3 === 2 ? [vp.width * 0.5, vp.height * 0.86, i % 2 ? 70 : -70, 0] : [vp.width * 0.2, vp.height * 0.8, i % 2 ? 60 : -60, -40];
|
|
150
|
+
await players[1].page.touchscreen.touchStart(x, y).catch(() => {});
|
|
151
|
+
await players[1].page.touchscreen.touchMove(x + dx, y + dy).catch(() => {});
|
|
118
152
|
await sleep(300);
|
|
119
153
|
await players[1].page.touchscreen.touchEnd().catch(() => {});
|
|
120
|
-
if (
|
|
154
|
+
if (report?.stopped?.()) break;
|
|
121
155
|
}
|
|
122
156
|
})();
|
|
123
157
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
|
|
124
158
|
await sleep(1500);
|
|
125
159
|
await picture('Playing: computer and phone in one room');
|
|
126
|
-
// Only a round that finishes AFTER both browsers were seated, with BOTH of them in its results,
|
|
127
|
-
// idle room remembers its last round for a while, and that stale result proves nothing.
|
|
160
|
+
// Only a round that finishes AFTER both browsers were seated, seen by both, with BOTH of them in its results,
|
|
161
|
+
// counts: an idle room remembers its last round for a while, and that stale result proves nothing. A browser is
|
|
162
|
+
// in a round when a row carries a seat it held (a reconnect keeps its seat; a game may still mark an idle
|
|
163
|
+
// person's row as a bot, and that is still the person). A round finished without one of them is a miss: its
|
|
164
|
+
// cause is kept, and the check waits for the next round, up to `rounds` of them.
|
|
128
165
|
const bothSeated = Math.max(...players.map((p) => p.seatedAt));
|
|
129
|
-
const seats =
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
for (const p of players) { p.seats = new Set([p.seat]); p.reconnects = null; p.reconnects0 = null; p.closed = null; p.seen = new Map(); }
|
|
167
|
+
const t1 = Date.now();
|
|
168
|
+
const budget = roundTimeoutMs * Math.max(1, rounds);
|
|
169
|
+
const missed = new Map();
|
|
170
|
+
let hit = null;
|
|
171
|
+
while (!hit && Date.now() - t1 < budget && missed.size < Math.max(1, rounds)) {
|
|
172
|
+
for (const p of players) {
|
|
173
|
+
const s = await p.page.evaluate(() => {
|
|
174
|
+
const sh = window.__shell;
|
|
175
|
+
return sh ? { seat: sh.seat, room: sh.room, closed: sh.closed, reconnects: sh.stats?.reconnects ?? null, results: (sh.results ?? []).map((x) => ({ n: x.n, endsAt: x.endsAt, at: x.at, results: x.results })) } : null;
|
|
176
|
+
}).catch(() => null);
|
|
177
|
+
if (!s) continue;
|
|
178
|
+
if (Number.isInteger(s.seat)) p.seats.add(s.seat);
|
|
179
|
+
if (Number.isFinite(s.reconnects)) { p.reconnects0 ??= s.reconnects; p.reconnects = s.reconnects; }
|
|
180
|
+
if (s.closed) p.closed = s.closed;
|
|
181
|
+
for (const r of s.results) if (r.at > bothSeated && Array.isArray(r.results) && !p.seen.has(`${r.n}:${r.endsAt}`)) p.seen.set(`${r.n}:${r.endsAt}`, { ...r, room: s.room, ms: Date.now() - p.t0 });
|
|
141
182
|
}
|
|
142
|
-
|
|
143
|
-
|
|
183
|
+
const before = missed.size;
|
|
184
|
+
hit = judgeRounds(players, missed);
|
|
185
|
+
for (const m of [...missed.values()].slice(before)) log(`round ${m.n} finished without the ${m.missing.join(' and the ')}: waiting for the next one`);
|
|
186
|
+
if (hit) break;
|
|
187
|
+
halt();
|
|
188
|
+
await sleep(700);
|
|
189
|
+
}
|
|
190
|
+
playingOn = false;
|
|
144
191
|
await playing.catch(() => {});
|
|
145
192
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
193
|
+
const connection = players.map((p) => ({ browser: p.kind, seats: [...p.seats], reconnects: p.reconnects !== null && p.reconnects0 !== null ? p.reconnects - p.reconnects0 : null, closed: p.closed }));
|
|
194
|
+
const load = { perCore: +(loadavg()[0] / Math.max(1, cpus().length)).toFixed(2), average: +loadavg()[0].toFixed(1), cores: cpus().length };
|
|
195
|
+
if (!hit) {
|
|
196
|
+
const misses = [...missed.values()];
|
|
197
|
+
const dropped = connection.filter((c) => c.reconnects > 0).map((c) => `the ${c.browser} lost its connection ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}`);
|
|
198
|
+
const why = misses.length
|
|
199
|
+
? `${misses.length} round${misses.length === 1 ? '' : 's'} finished, and none had both browsers in it: ${misses.map((m) => `round ${m.n} left out the ${m.missing.join(' and the ')}`).join('; ')}.${dropped.length ? ` ${dropped.join(', ')} (a browser silent for 10 s is let go by the relay and its body plays as a bot until it is back).` : ' The game listed that seat\'s body as a bot with no seat: it may hand an idle player to a bot.'}${load.perCore > 1.5 ? ` This computer is busy (load ${load.average} on ${load.cores} cores): run the check again when it is quieter.` : ''}`
|
|
200
|
+
: `no round finished within ${Math.round(budget / 1000)} s of both browsers being seated${load.perCore > 1.5 ? ` (this computer is busy: load ${load.average} on ${load.cores} cores)` : ''}`;
|
|
201
|
+
step('round', 'fail', { note: misses.length ? `${misses.length} round(s) without both` : 'no round finished in time' });
|
|
202
|
+
return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
|
|
203
|
+
}
|
|
204
|
+
const humans = hit.people;
|
|
205
|
+
step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bots${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}` });
|
|
206
|
+
await picture(`Round ${hit.n} finished with both; the next one is on`);
|
|
151
207
|
return {
|
|
152
|
-
ok: sameRoom &&
|
|
208
|
+
ok: sameRoom && humans >= 2,
|
|
153
209
|
command: 'check',
|
|
154
210
|
play,
|
|
155
211
|
room: seated[0].room,
|
|
156
212
|
seats: seated.map((s, i) => ({ browser: kinds[i].name, seat: s.seat, role: s.role, seatedMs: s.ms })),
|
|
157
|
-
round: { n:
|
|
158
|
-
|
|
213
|
+
round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
|
|
214
|
+
...(missed.size ? { missed: [...missed.values()] } : {}),
|
|
215
|
+
connection,
|
|
216
|
+
why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
|
|
159
217
|
totalMs: Date.now() - started,
|
|
160
218
|
};
|
|
161
219
|
} catch (error) {
|
|
@@ -163,6 +221,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
163
221
|
for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
|
|
164
222
|
return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
|
|
165
223
|
} finally {
|
|
224
|
+
playingOn = false;
|
|
166
225
|
for (const b of browsers) await b.close().catch(() => {});
|
|
167
226
|
for (const p of profiles) rmSync(p, { recursive: true, force: true });
|
|
168
227
|
}
|
package/lib/look.mjs
CHANGED
|
@@ -36,8 +36,9 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
|
|
|
36
36
|
const base = String(url).replace(/\/+$/, '');
|
|
37
37
|
mkdirSync(shots, { recursive: true });
|
|
38
38
|
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
|
|
39
|
+
// 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
|
|
39
40
|
const browser = await puppeteer.launch({
|
|
40
|
-
executablePath: chrome, headless: true, userDataDir: profile,
|
|
41
|
+
executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
|
|
41
42
|
args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
42
43
|
});
|
|
43
44
|
const rows = [];
|
|
@@ -51,7 +52,7 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
|
|
|
51
52
|
await page.setViewport(LOOK_DEVICES[device]);
|
|
52
53
|
await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
|
|
53
54
|
const t0 = Date.now();
|
|
54
|
-
const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout:
|
|
55
|
+
const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 90_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
|
|
55
56
|
const ms = Date.now() - t0;
|
|
56
57
|
await sleep(900);
|
|
57
58
|
const fold = await page.evaluate(() => {
|
package/lib/media.mjs
CHANGED
|
@@ -50,6 +50,11 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
50
50
|
const entries = [];
|
|
51
51
|
const skipped = [];
|
|
52
52
|
const seen = new Set();
|
|
53
|
+
// The manifest's own picture (music: `cover`, videos: `poster`): an album cover for every song that has none of
|
|
54
|
+
// its own. A path in the studio (copied into the site) or an https:// address, like any file.
|
|
55
|
+
const artRole = kind === 'music' ? 'cover' : 'poster';
|
|
56
|
+
const shared = manifest[artRole];
|
|
57
|
+
const sharedFile = typeof shared === 'string' ? (/^https:\/\//.test(shared) ? { url: shared } : { path: shared }) : shared && typeof shared === 'object' ? shared : null;
|
|
53
58
|
for (const item of manifest.items) {
|
|
54
59
|
if (!item || typeof item !== 'object') continue;
|
|
55
60
|
const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
|
|
@@ -58,7 +63,9 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
58
63
|
seen.add(item.slug);
|
|
59
64
|
if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
|
|
60
65
|
const files = [];
|
|
61
|
-
|
|
66
|
+
const own = Array.isArray(item.files) ? item.files : [];
|
|
67
|
+
const list = sharedFile && !own.some((f) => f?.role === artRole) ? [...own, { ...sharedFile, role: artRole }] : own;
|
|
68
|
+
for (const f of list) {
|
|
62
69
|
if (!f || f.public === false) continue;
|
|
63
70
|
let src = null;
|
|
64
71
|
if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
|
package/lib/port-check.mjs
CHANGED
|
@@ -197,7 +197,8 @@ function descendants(pid) {
|
|
|
197
197
|
async function chrome(puppeteer, kind, exe) {
|
|
198
198
|
const dir = mkdtempSync(join(tmpdir(), 'homie-port-check-'));
|
|
199
199
|
const vp = kind === 'phone' ? { width: 412, height: 915 } : kind === 'tv' ? { width: 1280, height: 720 } : { width: 1280, height: 800 };
|
|
200
|
-
|
|
200
|
+
// 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
|
|
201
|
+
const browser = await puppeteer.launch({ executablePath: exe, headless: true, userDataDir: dir, timeout: 150_000, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
|
|
201
202
|
const page = (await browser.pages())[0] ?? await browser.newPage();
|
|
202
203
|
if (kind === 'phone') await page.emulate({ viewport: { ...vp, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true }, userAgent: `${ANDROID_UA} ${TAG}` });
|
|
203
204
|
else { await page.setViewport({ ...vp, deviceScaleFactor: 1 }); await page.setUserAgent(`${await browser.userAgent()} ${TAG}`); }
|
|
@@ -215,7 +216,7 @@ async function chrome(puppeteer, kind, exe) {
|
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
async function webkit(pw) {
|
|
218
|
-
const browser = await pw.webkit.launch({ headless: true });
|
|
219
|
+
const browser = await pw.webkit.launch({ headless: true, timeout: 150_000 });
|
|
219
220
|
const dev = pw.devices['iPhone 15'];
|
|
220
221
|
const ctx = await browser.newContext({ ...dev, userAgent: `${dev.userAgent} ${TAG}` });
|
|
221
222
|
const page = await ctx.newPage();
|
package/lib/scaffold.mjs
CHANGED
|
@@ -116,6 +116,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
116
116
|
\`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
|
|
117
117
|
(played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
|
|
118
118
|
nothing sent anywhere; house QA and \`check\` runs are not counted.
|
|
119
|
+
- \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
|
|
120
|
+
\`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
|
|
121
|
+
sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
|
|
122
|
+
touches a file or section this studio changed; \`--diff\` shows how those differ from the template's.
|
|
119
123
|
|
|
120
124
|
## The site
|
|
121
125
|
|
|
@@ -132,7 +136,11 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
132
136
|
(\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
|
|
133
137
|
- **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
|
|
134
138
|
page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
|
|
135
|
-
\`site/theme.css\` (\`site/README.md\`).
|
|
139
|
+
\`site/theme.css\` (\`site/README.md\`). A game whose picture is white or cream gets a light landing with
|
|
140
|
+
game.json \`"landing": { "scheme": "light" }\` (a dark tint would turn it grey).
|
|
141
|
+
- **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
|
|
142
|
+
a cover of its own shows the music manifest's \`cover\`, else its game's still.
|
|
143
|
+
- **Live rooms** are listed on homie.rocks too; studio.json \`"rooms": { "share": false }\` keeps them off it.
|
|
136
144
|
- Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
|
|
137
145
|
|
|
138
146
|
## Making games
|
|
@@ -144,6 +152,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
144
152
|
- Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
|
|
145
153
|
- Phones and computers: touch controls on phones only, keys on computers; keep the
|
|
146
154
|
centre of the screen clear during play.
|
|
155
|
+
- The play page's small room button (Invite, Big screen, the room code) sits top right. If the game's
|
|
156
|
+
scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
|
|
157
|
+
device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
|
|
158
|
+
a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
|
|
147
159
|
- Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
|
|
148
160
|
- A game's id is its URL (\`/<id>/\`); keep it once published.
|
|
149
161
|
|
package/lib/site.mjs
CHANGED
|
@@ -416,6 +416,12 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
416
416
|
if (texts.length) { mkdirSync(landDir, { recursive: true }); writeFileSync(join(landDir, 'credits.json'), `${JSON.stringify({ v: 1, texts })}\n`); }
|
|
417
417
|
|
|
418
418
|
const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
|
|
419
|
+
// The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
|
|
420
|
+
const theme = L.theme && typeof L.theme === 'object' ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(L.theme[k] ?? ''))).map((k) => [k, L.theme[k]])) : null;
|
|
421
|
+
if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
|
|
422
|
+
// landing.scheme: "light" for a white or cream game (its hero is tinted light and its words dark), "dark" for a
|
|
423
|
+
// dark game in a light studio. Anything else follows the studio's look.
|
|
424
|
+
if (L.scheme !== undefined && L.scheme !== 'light' && L.scheme !== 'dark') log(`warning: games/${g.id}/game.json landing.scheme is "light" or "dark"; left out`);
|
|
419
425
|
return {
|
|
420
426
|
pitch: str(L.pitch, 240) ?? str(credits.tagline, 240),
|
|
421
427
|
kicker: str(L.kicker, 120),
|
|
@@ -431,7 +437,8 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
431
437
|
music: forGame(songs).map((e) => e.slug),
|
|
432
438
|
source: g.share?.source !== false,
|
|
433
439
|
tv: g.screen?.tv !== false && L.tv !== false,
|
|
434
|
-
theme:
|
|
440
|
+
theme: theme && Object.keys(theme).length ? theme : null,
|
|
441
|
+
...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : {}),
|
|
435
442
|
};
|
|
436
443
|
}
|
|
437
444
|
|