@homie-rocks/studio 0.7.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 +56 -4
- package/bin/homie-studio.mjs +173 -5
- package/lib/build.mjs +2 -0
- package/lib/check.mjs +139 -34
- package/lib/look.mjs +3 -2
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +29 -6
- package/lib/progress.mjs +420 -0
- 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/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();
|
|
@@ -379,7 +380,19 @@ async function ownerTests(h, how, log) {
|
|
|
379
380
|
|
|
380
381
|
/* ------------------------------------------------------------------ the check */
|
|
381
382
|
|
|
382
|
-
|
|
383
|
+
/** What a progress feed calls each port check row. */
|
|
384
|
+
export const PORT_CHECK_LABELS = {
|
|
385
|
+
'owner-desk': 'Moves on a computer', 'owner-phone': 'Moves on a phone', 'owner-iphone': 'Moves on an iPhone (WebKit)',
|
|
386
|
+
audio: 'Sound starts on the first touch', sandbox: 'Runs in the sandbox', 'ui-cover': 'UI stays out of the middle',
|
|
387
|
+
round: 'Two browsers finish a round', 'host-kill': 'The host leaves, the round goes on', 'late-join': 'A late joiner takes a bot\'s seat',
|
|
388
|
+
tv: 'The big screen watches', errors: 'No errors in the game',
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* `report` (optional; the open progress feed, lib/progress.mjs) hears each row as it runs, passes or fails, gets a
|
|
393
|
+
* small picture after each screenshot, and can ask the check to stop between rows. Without it nothing changes.
|
|
394
|
+
*/
|
|
395
|
+
export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000, report = null }) {
|
|
383
396
|
if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
|
|
384
397
|
const base = String(url).replace(/\/+$/, '');
|
|
385
398
|
const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
|
|
@@ -393,13 +406,21 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
393
406
|
mkdirSync(out, { recursive: true });
|
|
394
407
|
if (root) { try { writeFileSync(join(root, 'games', game, '.port', '.gitignore'), '*\n'); } catch { /* fine */ } }
|
|
395
408
|
const rows = [];
|
|
396
|
-
const
|
|
409
|
+
const tell = (name, state, note) => { try { report?.check?.(name, state, { label: PORT_CHECK_LABELS[name] ?? name, ...(note ? { note } : {}) }); } catch { /* a feed never breaks a check */ } };
|
|
410
|
+
const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); tell(name, ok === null ? 'skip' : ok ? 'pass' : 'fail', detail?.why); };
|
|
397
411
|
const open_ = [];
|
|
398
412
|
const launch = async (kind) => { const h = await chrome(puppeteer, kind, exe); open_.push(h); return h; };
|
|
399
413
|
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) => {
|
|
414
|
+
const shot = async (h, name) => {
|
|
415
|
+
await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000);
|
|
416
|
+
if (report?.preview && h.vp) {
|
|
417
|
+
const b64 = await T(h.page.screenshot({ type: 'jpeg', quality: 60, clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 480 / h.vp.width) }, encoding: 'base64' }), 15_000, null);
|
|
418
|
+
if (b64) { try { report.preview({ url: `${base}/${game}/play`, image: `data:image/jpeg;base64,${b64}`, caption: PORT_CHECK_LABELS[name.replace(/-(?:before|after|playing|over|\d+)$/, '')] ?? name }); } catch { /* too big: no picture */ } }
|
|
419
|
+
}
|
|
420
|
+
};
|
|
401
421
|
const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
|
|
402
422
|
const errorsSeen = [];
|
|
423
|
+
let stoppedAt = null;
|
|
403
424
|
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
425
|
const started = Date.now();
|
|
405
426
|
// The site must answer before each row: a dev server that died mid-check made every later row fail as "no game
|
|
@@ -408,6 +429,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
408
429
|
try { const r = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(8000) }); return r.ok; } catch { return false; }
|
|
409
430
|
};
|
|
410
431
|
const gate = async (name) => {
|
|
432
|
+
if (report?.stopped?.()) { row(name, null, { why: 'stopped by the person' }); stoppedAt ??= name; return false; }
|
|
433
|
+
tell(name, 'running');
|
|
411
434
|
if (await alive()) return true;
|
|
412
435
|
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
436
|
return false;
|
|
@@ -631,7 +654,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
631
654
|
}
|
|
632
655
|
const failed = rows.filter((r) => r.ok === false);
|
|
633
656
|
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 };
|
|
657
|
+
const result = { ok: failed.length === 0 && rows.length > 0 && !stoppedAt, command: 'port check', game, url: base, out, totalMs: Date.now() - started, ...(stoppedAt ? { stopped: true, why: `stopped by the person (before ${stoppedAt})` } : {}), 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
658
|
writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
|
|
636
659
|
return result;
|
|
637
660
|
}
|
package/lib/progress.mjs
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROGRESS FEED — what a build is doing right now, for a person watching from anywhere (a terminal, or the
|
|
3
|
+
* Homie MCP's build progress widget in the Claude app on a phone).
|
|
4
|
+
*
|
|
5
|
+
* One build is one small JSON document (kind "homie-studio-progress", v 1), rewritten whole at every step:
|
|
6
|
+
*
|
|
7
|
+
* .studio/progress/<build>.json the feed
|
|
8
|
+
* .studio/progress/<build>.key the shared copy's write key (only when shared; never printed, git-ignored)
|
|
9
|
+
* .studio/progress/current the open build's id
|
|
10
|
+
*
|
|
11
|
+
* The stages depend on what is being made:
|
|
12
|
+
* a game plan → build → checks → deploy
|
|
13
|
+
* a song plan → render → lyric check → publish
|
|
14
|
+
* a video plan → shots → cut → publish
|
|
15
|
+
*
|
|
16
|
+
* `homie-studio progress start` opens a feed. While one is open, `build`, `check`, `port check` and `deploy` write
|
|
17
|
+
* their stage into it, their checks as each goes green or red, and a preview (a small picture of the game and the
|
|
18
|
+
* address to play it), and they stop at the next safe point when a stop was asked. The AI marks what only it
|
|
19
|
+
* knows (the plan, money spent, a song's lyric check, a video's shots) with the other `progress` commands.
|
|
20
|
+
* WITHOUT AN OPEN FEED NOTHING CHANGES: every command behaves exactly as before.
|
|
21
|
+
*
|
|
22
|
+
* SHARED (`progress start --share`): the directory named in studio.json (homie.rocks by default) keeps a copy for
|
|
23
|
+
* 24 hours, so the Homie MCP tool `build_progress { build }` can show it in the Claude app, and answers every
|
|
24
|
+
* write with whether the person pressed Stop there. It receives the feed and nothing else: never a key, a file
|
|
25
|
+
* path, code, or more than the feed's last few log lines. Only a loopback directory may be plain http.
|
|
26
|
+
*
|
|
27
|
+
* The document (every text is plain, bounded; pictures are small data: URLs):
|
|
28
|
+
* { v: 1, kind, build, what: game|song|video, id, title, studio,
|
|
29
|
+
* state: running|passed|failed|stopped, stage, startedAt, updatedAt, endedAt,
|
|
30
|
+
* stages: [{ id, label, state: pending|running|done|failed|skipped|stopped, startedAt, endedAt, note }],
|
|
31
|
+
* checks: [{ id, label, stage, state: pending|running|pass|fail|skip, ms, note }],
|
|
32
|
+
* preview: { url, image, caption, at } | null,
|
|
33
|
+
* spend: { unit: usd|credits, used, budget, items: [{ what, amount, at, receipt }] },
|
|
34
|
+
* stop: { requested, at, by },
|
|
35
|
+
* song?: { peaks: [0..1], lyrics: [{ line, sung: true|false|null }] },
|
|
36
|
+
* video?: { shots: [{ id, label, state, image }] },
|
|
37
|
+
* log: [{ at, text }], error, shared?: { build, directory } }
|
|
38
|
+
*/
|
|
39
|
+
import { randomBytes } from 'node:crypto';
|
|
40
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
41
|
+
import { extname, join } from 'node:path';
|
|
42
|
+
import { readStudio } from './studio.mjs';
|
|
43
|
+
|
|
44
|
+
export const PROGRESS_KIND = 'homie-studio-progress';
|
|
45
|
+
export const PROGRESS_DIR = join('.studio', 'progress');
|
|
46
|
+
export const STAGES = Object.freeze({
|
|
47
|
+
game: [['plan', 'Plan'], ['build', 'Build'], ['checks', 'Checks'], ['deploy', 'Deploy']],
|
|
48
|
+
song: [['plan', 'Plan'], ['render', 'Render'], ['check', 'Lyric check'], ['publish', 'Publish']],
|
|
49
|
+
video: [['plan', 'Plan'], ['shots', 'Shots'], ['cut', 'Cut'], ['publish', 'Publish']],
|
|
50
|
+
});
|
|
51
|
+
/** The same bounds the directory enforces on a shared feed. */
|
|
52
|
+
export const LIMITS = Object.freeze({
|
|
53
|
+
title: 120, note: 200, label: 80, logLines: 12, logLine: 160, checks: 24, shots: 24, items: 50, peaks: 240, lyrics: 80,
|
|
54
|
+
image: 96 * 1024, shotImage: 32 * 1024, doc: 256 * 1024,
|
|
55
|
+
});
|
|
56
|
+
const STAGE_STATES = new Set(['pending', 'running', 'done', 'failed', 'skipped', 'stopped']);
|
|
57
|
+
const CHECK_STATES = new Set(['pending', 'running', 'pass', 'fail', 'skip']);
|
|
58
|
+
const END_STATES = new Set(['passed', 'failed', 'stopped']);
|
|
59
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
60
|
+
const BUILD_ID = /^[a-z0-9][a-z0-9-]{5,63}$/;
|
|
61
|
+
const REMOTE_ID = /^hb_[a-f0-9]{32}$/;
|
|
62
|
+
const PUSH_GAP_MS = 1200;
|
|
63
|
+
const HEARTBEAT_MS = 5000;
|
|
64
|
+
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
|
|
65
|
+
|
|
66
|
+
const now = () => new Date().toISOString();
|
|
67
|
+
/** Plain text of at most `max` characters: no control characters, trimmed. */
|
|
68
|
+
export const plain = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
69
|
+
const dir = (root) => join(root, PROGRESS_DIR);
|
|
70
|
+
const docPath = (root, id) => join(dir(root), `${id}.json`);
|
|
71
|
+
const keyPath = (root, id) => join(dir(root), `${id}.key`);
|
|
72
|
+
const currentPath = (root) => join(dir(root), 'current');
|
|
73
|
+
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
74
|
+
|
|
75
|
+
function writeAtomic(file, text) {
|
|
76
|
+
const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
|
|
77
|
+
writeFileSync(tmp, text);
|
|
78
|
+
renameSync(tmp, file);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readFeed(root, id) {
|
|
82
|
+
if (!BUILD_ID.test(String(id ?? ''))) return null;
|
|
83
|
+
try { const d = JSON.parse(readFileSync(docPath(root, id), 'utf8')); return d?.kind === PROGRESS_KIND ? d : null; } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The open build's id, or null. */
|
|
87
|
+
export function currentId(root) {
|
|
88
|
+
try { const id = readFileSync(currentPath(root), 'utf8').trim(); return readFeed(root, id)?.state === 'running' ? id : null; } catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A picture for the feed: a data: URL as it is, or a png/jpg/webp file, at most `max` bytes. */
|
|
92
|
+
export function imageData(value, max = LIMITS.image) {
|
|
93
|
+
const raw = String(value ?? '');
|
|
94
|
+
if (!raw) return null;
|
|
95
|
+
if (raw.startsWith('data:')) {
|
|
96
|
+
if (!/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(raw)) throw new Error('a picture is a data:image/png, jpeg or webp base64 URL, or a .png/.jpg/.webp file');
|
|
97
|
+
if (raw.length > Math.ceil(max * 4 / 3) + 40) throw new Error(`the picture is over ${Math.round(max / 1024)} KB; use a smaller JPEG`);
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
100
|
+
const type = IMAGE_TYPES[extname(raw).toLowerCase()];
|
|
101
|
+
if (!type) throw new Error('a picture is a .png, .jpg or .webp file');
|
|
102
|
+
if (!existsSync(raw) || !statSync(raw).isFile()) throw new Error(`no picture at ${raw}`);
|
|
103
|
+
if (statSync(raw).size > max) throw new Error(`${raw} is over ${Math.round(max / 1024)} KB; use a smaller JPEG (about 480 px wide)`);
|
|
104
|
+
return `data:${type};base64,${readFileSync(raw).toString('base64')}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ------------------------------------------------------------------ the shared copy */
|
|
108
|
+
|
|
109
|
+
const pushers = new Map();
|
|
110
|
+
|
|
111
|
+
async function pushDoc(root, id) {
|
|
112
|
+
const doc = readFeed(root, id);
|
|
113
|
+
if (!doc?.shared?.build) return null;
|
|
114
|
+
let key = null;
|
|
115
|
+
try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
|
|
116
|
+
const { shared, ...body } = doc;
|
|
117
|
+
const res = await fetch(`${shared.directory}/api/studio/progress/${shared.build}`, {
|
|
118
|
+
method: 'PUT',
|
|
119
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
|
|
120
|
+
body: JSON.stringify({ ...body, build: shared.build }),
|
|
121
|
+
signal: AbortSignal.timeout(8000),
|
|
122
|
+
});
|
|
123
|
+
if (res.status === 404 || res.status === 410) {
|
|
124
|
+
// The shared copy ended (24 hours, or the directory dropped it): keep the local feed, stop sending.
|
|
125
|
+
mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; });
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const reply = await res.json().catch(() => null);
|
|
129
|
+
if (reply?.stop) {
|
|
130
|
+
mutateDoc(root, id, (d) => {
|
|
131
|
+
if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return reply;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function schedule(root, id, { soon = false } = {}) {
|
|
138
|
+
const doc = readFeed(root, id);
|
|
139
|
+
if (!doc?.shared?.build || doc.shared.ended) return;
|
|
140
|
+
const p = pushers.get(id) ?? { root, last: 0, timer: null, inflight: null, dirty: false, beat: null };
|
|
141
|
+
pushers.set(id, p);
|
|
142
|
+
p.dirty = true;
|
|
143
|
+
if (p.inflight) return;
|
|
144
|
+
const wait = soon ? 0 : Math.max(0, p.last + PUSH_GAP_MS - Date.now());
|
|
145
|
+
if (p.timer) { if (!soon) return; clearTimeout(p.timer); }
|
|
146
|
+
p.timer = setTimeout(() => { p.timer = null; send(id, p); }, wait);
|
|
147
|
+
p.timer.unref?.();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function send(id, p) {
|
|
151
|
+
p.dirty = false;
|
|
152
|
+
p.last = Date.now();
|
|
153
|
+
p.inflight = pushDoc(p.root, id).catch(() => null).finally(() => {
|
|
154
|
+
p.inflight = null;
|
|
155
|
+
if (p.dirty) schedule(p.root, id);
|
|
156
|
+
});
|
|
157
|
+
return p.inflight;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** While a stage runs, re-send the feed every few seconds: the widget sees it is alive, and hears Stop. */
|
|
161
|
+
function heartbeat(root, id, on) {
|
|
162
|
+
const p = pushers.get(id) ?? { root, last: 0, timer: null, inflight: null, dirty: false, beat: null };
|
|
163
|
+
pushers.set(id, p);
|
|
164
|
+
if (on && !p.beat) { p.beat = setInterval(() => { if (!p.inflight) send(id, p); }, HEARTBEAT_MS); p.beat.unref?.(); }
|
|
165
|
+
if (!on && p.beat) { clearInterval(p.beat); p.beat = null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Send whatever is waiting, now (a command calls this before it exits). Never throws, never waits past `ms`. */
|
|
169
|
+
export async function flushProgress(ms = 6000) {
|
|
170
|
+
const waits = [];
|
|
171
|
+
for (const [id, p] of pushers) {
|
|
172
|
+
if (p.beat) { clearInterval(p.beat); p.beat = null; }
|
|
173
|
+
if (p.timer) { clearTimeout(p.timer); p.timer = null; p.dirty = true; }
|
|
174
|
+
if (p.inflight) waits.push(p.inflight.then(() => (p.dirty ? send(id, p) : null)));
|
|
175
|
+
else if (p.dirty) waits.push(send(id, p));
|
|
176
|
+
}
|
|
177
|
+
if (!waits.length) return;
|
|
178
|
+
await Promise.race([Promise.allSettled(waits), new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); })]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ------------------------------------------------------------------ the document */
|
|
182
|
+
|
|
183
|
+
function pushLog(d, text) {
|
|
184
|
+
d.log = [...(d.log ?? []), { at: now(), text: plain(text, LIMITS.logLine) }].slice(-LIMITS.logLines);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function mutateDoc(root, id, fn) {
|
|
188
|
+
const d = readFeed(root, id);
|
|
189
|
+
if (!d) return null;
|
|
190
|
+
fn(d);
|
|
191
|
+
d.updatedAt = now();
|
|
192
|
+
let text = `${JSON.stringify(d, null, 1)}\n`;
|
|
193
|
+
// A feed never grows past its bound: the oldest shot pictures, then the preview picture, give way first.
|
|
194
|
+
while (text.length > LIMITS.doc && d.video?.shots?.some((s) => s.image)) { d.video.shots.find((s) => s.image).image = null; text = `${JSON.stringify(d, null, 1)}\n`; }
|
|
195
|
+
if (text.length > LIMITS.doc && d.preview?.image) { d.preview.image = null; text = `${JSON.stringify(d, null, 1)}\n`; }
|
|
196
|
+
writeAtomic(docPath(root, id), text);
|
|
197
|
+
return d;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One open build's feed. Every call re-reads the file, changes it and writes it whole, so two commands can share it. */
|
|
201
|
+
export class Feed {
|
|
202
|
+
constructor(root, id) { this.root = root; this.id = id; }
|
|
203
|
+
|
|
204
|
+
get doc() { return readFeed(this.root, this.id); }
|
|
205
|
+
|
|
206
|
+
change(fn, { soon = false } = {}) {
|
|
207
|
+
const d = mutateDoc(this.root, this.id, fn);
|
|
208
|
+
if (d) schedule(this.root, this.id, { soon });
|
|
209
|
+
return d;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A stage's state. Starting one ends the stage before it (done) and skips any never started. */
|
|
213
|
+
stage(stage, state, note) {
|
|
214
|
+
if (!STAGE_STATES.has(state)) throw new Error(`a stage is ${[...STAGE_STATES].join(', ')}`);
|
|
215
|
+
const d = this.change((doc) => {
|
|
216
|
+
const at = doc.stages.findIndex((s) => s.id === stage);
|
|
217
|
+
if (at < 0) throw new Error(`this ${doc.what} build has no stage "${stage}" (${doc.stages.map((s) => s.id).join(', ')})`);
|
|
218
|
+
const s = doc.stages[at];
|
|
219
|
+
if (state === 'running') {
|
|
220
|
+
for (const before of doc.stages.slice(0, at)) {
|
|
221
|
+
if (before.state === 'running') { before.state = 'done'; before.endedAt = now(); }
|
|
222
|
+
else if (before.state === 'pending') before.state = 'skipped';
|
|
223
|
+
}
|
|
224
|
+
s.startedAt = now(); s.endedAt = null;
|
|
225
|
+
doc.stage = stage;
|
|
226
|
+
} else if (state !== 'pending') s.endedAt = now();
|
|
227
|
+
s.state = state;
|
|
228
|
+
if (note !== undefined) s.note = plain(note, LIMITS.note);
|
|
229
|
+
if (state === 'failed' && note) doc.error = plain(note, LIMITS.note);
|
|
230
|
+
if (state === 'running' || state === 'done') doc.error = null;
|
|
231
|
+
pushLog(doc, `${s.label}: ${state}${note ? ` (${plain(note, 100)})` : ''}`);
|
|
232
|
+
}, { soon: true });
|
|
233
|
+
heartbeat(this.root, this.id, d?.stages?.some((s) => s.state === 'running') && d.state === 'running');
|
|
234
|
+
return d;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One check's state; a check is added the first time it is named. */
|
|
238
|
+
check(id, state, { label, note, ms, stage } = {}) {
|
|
239
|
+
if (!CHECK_STATES.has(state)) throw new Error(`a check is ${[...CHECK_STATES].join(', ')}`);
|
|
240
|
+
return this.change((doc) => {
|
|
241
|
+
const key = plain(id, 40);
|
|
242
|
+
let c = doc.checks.find((x) => x.id === key);
|
|
243
|
+
if (!c) {
|
|
244
|
+
if (doc.checks.length >= LIMITS.checks) return;
|
|
245
|
+
c = { id: key, label: plain(label ?? key, LIMITS.label), stage: stage ?? doc.stage ?? null, state: 'pending', ms: null, note: '' };
|
|
246
|
+
doc.checks.push(c);
|
|
247
|
+
}
|
|
248
|
+
c.state = state;
|
|
249
|
+
if (label) c.label = plain(label, LIMITS.label);
|
|
250
|
+
if (note !== undefined) c.note = plain(note, LIMITS.note);
|
|
251
|
+
if (Number.isFinite(ms)) c.ms = Math.round(ms);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Forget the checks of one stage (a stage run again starts its checks afresh). */
|
|
256
|
+
resetChecks(stage) { return this.change((doc) => { doc.checks = doc.checks.filter((c) => c.stage !== stage); }); }
|
|
257
|
+
|
|
258
|
+
preview({ url, image, caption } = {}) {
|
|
259
|
+
const picture = image ? imageData(image) : undefined;
|
|
260
|
+
return this.change((doc) => {
|
|
261
|
+
doc.preview = {
|
|
262
|
+
url: url ? String(url).slice(0, 300) : doc.preview?.url ?? null,
|
|
263
|
+
image: picture ?? doc.preview?.image ?? null,
|
|
264
|
+
caption: caption !== undefined ? plain(caption, LIMITS.label) : doc.preview?.caption ?? '',
|
|
265
|
+
at: now(),
|
|
266
|
+
};
|
|
267
|
+
}, { soon: true });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
spend(amount, what, { receipt, unit } = {}) {
|
|
271
|
+
const n = Number(amount);
|
|
272
|
+
if (!Number.isFinite(n) || n < 0 || n > 1e6) throw new Error('spend is a number of dollars (or credits) of 0 or more');
|
|
273
|
+
return this.change((doc) => {
|
|
274
|
+
if (unit && unit !== doc.spend.unit) throw new Error(`this build counts ${doc.spend.unit}, not ${unit}`);
|
|
275
|
+
doc.spend.used = Math.round((doc.spend.used + n) * 10000) / 10000;
|
|
276
|
+
doc.spend.items = [...doc.spend.items, { what: plain(what ?? 'spend', LIMITS.label), amount: n, at: now(), receipt: receipt ? plain(receipt, LIMITS.label) : null }].slice(-LIMITS.items);
|
|
277
|
+
pushLog(doc, `Spent ${doc.spend.unit === 'usd' ? `$${n.toFixed(2)}` : `${n} credits`} on ${plain(what ?? 'spend', 80)}`);
|
|
278
|
+
}, { soon: true });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
log(text) { return this.change((doc) => pushLog(doc, text)); }
|
|
282
|
+
|
|
283
|
+
/** A video's shot (it fills in with its picture as it renders). */
|
|
284
|
+
shot(id, state, { label, image } = {}) {
|
|
285
|
+
if (!CHECK_STATES.has(state)) throw new Error(`a shot is ${[...CHECK_STATES].join(', ')}`);
|
|
286
|
+
const picture = image ? imageData(image, LIMITS.shotImage) : undefined;
|
|
287
|
+
return this.change((doc) => {
|
|
288
|
+
doc.video ??= { shots: [] };
|
|
289
|
+
let s = doc.video.shots.find((x) => x.id === plain(id, 40));
|
|
290
|
+
if (!s) { if (doc.video.shots.length >= LIMITS.shots) return; s = { id: plain(id, 40), label: plain(label ?? id, LIMITS.label), state: 'pending', image: null }; doc.video.shots.push(s); }
|
|
291
|
+
s.state = state;
|
|
292
|
+
if (label) s.label = plain(label, LIMITS.label);
|
|
293
|
+
if (picture !== undefined) s.image = picture;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A song's waveform (peaks of 0..1) and lyric lines as the check hears them. */
|
|
298
|
+
song({ peaks, lyric, sung } = {}) {
|
|
299
|
+
return this.change((doc) => {
|
|
300
|
+
doc.song ??= { peaks: [], lyrics: [] };
|
|
301
|
+
if (Array.isArray(peaks)) doc.song.peaks = peaks.slice(0, LIMITS.peaks).map((p) => Math.max(0, Math.min(1, Math.round(Number(p) * 1000) / 1000 || 0)));
|
|
302
|
+
if (lyric) {
|
|
303
|
+
const line = plain(lyric, LIMITS.label);
|
|
304
|
+
const known = doc.song.lyrics.find((l) => l.line === line);
|
|
305
|
+
const value = sung === true || sung === false ? sung : null;
|
|
306
|
+
if (known) known.sung = value;
|
|
307
|
+
else if (doc.song.lyrics.length < LIMITS.lyrics) doc.song.lyrics.push({ line, sung: value });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
stopRequested() { return Boolean(this.doc?.stop?.requested); }
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Ask the shared copy now (one write, answered with the person's Stop). A stage calls this before it starts,
|
|
316
|
+
* so a Stop pressed in the Claude app while nothing was running still stops the next command before it opens a
|
|
317
|
+
* browser or deploys. A local feed, or a directory that does not answer, changes nothing.
|
|
318
|
+
*/
|
|
319
|
+
async sync() {
|
|
320
|
+
if (!this.doc?.shared?.build || this.doc.shared.ended) return;
|
|
321
|
+
await pushDoc(this.root, this.id).catch(() => null);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Ask the build to stop (the terminal's own Stop; the Claude app's arrives through the shared copy). */
|
|
325
|
+
stop(by = 'local') {
|
|
326
|
+
return this.change((doc) => { if (!doc.stop.requested) { doc.stop = { requested: true, at: now(), by }; pushLog(doc, 'Stop asked; stopping at the next safe point.'); } }, { soon: true });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The build is over: passed, failed or stopped. */
|
|
330
|
+
end(state, note) {
|
|
331
|
+
if (!END_STATES.has(state)) throw new Error(`a build ends ${[...END_STATES].join(', ')}`);
|
|
332
|
+
const d = this.change((doc) => {
|
|
333
|
+
for (const s of doc.stages) {
|
|
334
|
+
if (s.state === 'running') { s.state = state === 'passed' ? 'done' : state === 'stopped' ? 'stopped' : 'failed'; s.endedAt = now(); }
|
|
335
|
+
}
|
|
336
|
+
for (const c of doc.checks) if (c.state === 'running') c.state = state === 'passed' ? 'pass' : 'skip';
|
|
337
|
+
doc.state = state;
|
|
338
|
+
doc.endedAt = now();
|
|
339
|
+
if (note) { if (state === 'failed') doc.error = plain(note, LIMITS.note); pushLog(doc, note); }
|
|
340
|
+
pushLog(doc, state === 'passed' ? 'Done.' : state === 'stopped' ? 'Stopped.' : 'Failed.');
|
|
341
|
+
}, { soon: true });
|
|
342
|
+
heartbeat(this.root, this.id, false);
|
|
343
|
+
try { if (readFileSync(currentPath(this.root), 'utf8').trim() === this.id) rmSync(currentPath(this.root), { force: true }); } catch { /* none */ }
|
|
344
|
+
return d;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The open feed, or null (then a command reports nothing and behaves exactly as before). */
|
|
349
|
+
export function currentFeed(root) {
|
|
350
|
+
const id = root ? currentId(root) : null;
|
|
351
|
+
return id ? new Feed(root, id) : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The feed as the Claude app's widget and `progress show` see it (never the write key). */
|
|
355
|
+
export function publicFeed(doc) {
|
|
356
|
+
if (!doc) return null;
|
|
357
|
+
const { shared, ...rest } = doc;
|
|
358
|
+
return { ...rest, ...(shared ? { shared: { build: shared.build, directory: shared.directory, ...(shared.ended ? { ended: true } : {}) } } : {}) };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Open a feed for one build. `share` asks the studio's directory (studio.json `homie.directory`, or `directory`)
|
|
363
|
+
* to keep a copy for the Claude app's widget; if it cannot, the build goes on with the local feed only.
|
|
364
|
+
*/
|
|
365
|
+
export async function startProgress(root, { what = 'game', id, title, budget, unit, share = false, directory } = {}) {
|
|
366
|
+
if (!STAGES[what]) return { ok: false, command: 'progress start', why: `what is being made: ${Object.keys(STAGES).join(', ')}` };
|
|
367
|
+
if (id !== undefined && id !== null && !SLUG.test(String(id))) return { ok: false, command: 'progress start', why: 'the id is the game, song or video id (lowercase letters, digits and hyphens)' };
|
|
368
|
+
const studio = readStudio(root);
|
|
369
|
+
const open = currentId(root);
|
|
370
|
+
if (open) return { ok: false, command: 'progress start', why: `build ${open} is still open: end it first (homie-studio progress end passed|failed|stopped)`, build: open };
|
|
371
|
+
const b = budget === undefined || budget === null || budget === true ? null : Number(budget);
|
|
372
|
+
if (b !== null && (!Number.isFinite(b) || b < 0)) return { ok: false, command: 'progress start', why: 'the budget is a number (dollars, or credits with --unit credits)' };
|
|
373
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '').toLowerCase();
|
|
374
|
+
const build = `${stamp}-${randomBytes(3).toString('hex')}`;
|
|
375
|
+
const doc = {
|
|
376
|
+
v: 1, kind: PROGRESS_KIND, build, what, id: id ?? null,
|
|
377
|
+
title: plain(title || `${id ?? studio.name ?? 'Studio'}`, LIMITS.title),
|
|
378
|
+
studio: plain(studio.name ?? '', 60),
|
|
379
|
+
state: 'running', stage: STAGES[what][0][0], startedAt: now(), updatedAt: now(), endedAt: null,
|
|
380
|
+
stages: STAGES[what].map(([sid, label], i) => ({ id: sid, label, state: i === 0 ? 'running' : 'pending', startedAt: i === 0 ? now() : null, endedAt: null, note: '' })),
|
|
381
|
+
checks: [], preview: null,
|
|
382
|
+
spend: { unit: unit === 'credits' ? 'credits' : 'usd', used: 0, budget: b, items: [] },
|
|
383
|
+
stop: { requested: false, at: null, by: null },
|
|
384
|
+
log: [{ at: now(), text: `Started: ${plain(title || id || what, 100)}` }],
|
|
385
|
+
error: null,
|
|
386
|
+
};
|
|
387
|
+
mkdirSync(dir(root), { recursive: true });
|
|
388
|
+
writeAtomic(docPath(root, build), `${JSON.stringify(doc, null, 1)}\n`);
|
|
389
|
+
writeAtomic(currentPath(root), `${build}\n`);
|
|
390
|
+
let shared = null;
|
|
391
|
+
let why = null;
|
|
392
|
+
if (share) {
|
|
393
|
+
const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
394
|
+
if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
|
|
395
|
+
else {
|
|
396
|
+
try {
|
|
397
|
+
const res = await fetch(`${at}/api/studio/progress`, {
|
|
398
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
|
|
399
|
+
body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }), signal: AbortSignal.timeout(10_000),
|
|
400
|
+
});
|
|
401
|
+
const body = await res.json().catch(() => null);
|
|
402
|
+
if (res.ok && body?.ok && REMOTE_ID.test(String(body.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body.key))) {
|
|
403
|
+
writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
|
|
404
|
+
try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
|
|
405
|
+
shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
|
|
406
|
+
mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
|
|
407
|
+
schedule(root, build, { soon: true });
|
|
408
|
+
heartbeat(root, build, true);
|
|
409
|
+
} else why = body?.message ?? `${at} answered ${res.status}`;
|
|
410
|
+
} catch (error) { why = `${at} did not answer (${error?.message ?? error})`; }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
ok: true, command: 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: doc.title,
|
|
415
|
+
stages: doc.stages.map((s) => s.id), budget: doc.spend.budget, unit: doc.spend.unit,
|
|
416
|
+
shared: shared ? { build: shared.build, directory: shared.directory, expiresAt: shared.expiresAt } : null,
|
|
417
|
+
...(share && !shared ? { sharedWhy: `not shared: ${why}; the feed is local only` } : {}),
|
|
418
|
+
widget: shared ? `In the Claude app: call the Homie MCP tool build_progress with { "build": "${shared.build}" }` : null,
|
|
419
|
+
};
|
|
420
|
+
}
|
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
|
|