@homie-rocks/studio 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/lib/shoot.mjs
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio shoot <game> --url <site>`: pictures of a game on a clock this command moves, and a two-client seat
|
|
3
|
+
* smoke check. For a 3D game on a machine with no GPU (a cloud session, CI), where the software renderer draws two
|
|
4
|
+
* frames a second and anything timed by the wall clock is fiction: here every frame is exactly one step of the
|
|
5
|
+
* game's own clock, however long the computer took to draw it.
|
|
6
|
+
*
|
|
7
|
+
* homie-studio shoot <game> --url <site> [--frames 60] [--fps 30] [--device computer|phone] [--out <dir>]
|
|
8
|
+
* [--hold <KeyboardEvent.code>] [--no-smoke] [--timeout <seconds>]
|
|
9
|
+
*
|
|
10
|
+
* What it does, in order, all bounded (`--timeout`, default 180 s; it stops with what it has and says `partial`):
|
|
11
|
+
*
|
|
12
|
+
* 1. SMOKE (unless --no-smoke): two clients, a computer and a phone, open the play page in one private room
|
|
13
|
+
* (`?room=shoot-…`, so no stranger and no earlier round is in it). PASS when both get a seat in that same room,
|
|
14
|
+
* both sockets are connected (neither fell back to playing alone offline) and the two seats differ. It does not
|
|
15
|
+
* wait for a round to finish: that is `homie-studio check`.
|
|
16
|
+
* 2. FRAMES: the first client waits for its loading cover to lift (reported as `ready`), then its clocks are frozen
|
|
17
|
+
* and stepped: requestAnimationFrame, performance.now, Date, setTimeout, setInterval and CSS/Web animations all
|
|
18
|
+
* advance by exactly 1000 / fps ms per frame, in the page and in the game's frame. Each step is one picture:
|
|
19
|
+
* `<out>/frame-0001.png` … and `<out>/shoot.json` (per frame: the virtual time, the round phase and the body's
|
|
20
|
+
* position when the game exposes a port probe, the renderer's draw calls and triangles when it exposes those).
|
|
21
|
+
*
|
|
22
|
+
* WHAT A FROZEN CLOCK DOES NOT COVER, said in the result (`limits`): the room is still a real socket, so the other
|
|
23
|
+
* players and the round's server clock arrive in real time between steps; Web Audio's own clock and <video> are not
|
|
24
|
+
* stepped; a worker's timers are not stepped. A game that derives its motion from `net.now()` (server time) moves
|
|
25
|
+
* by the real time that passed, not by the step. The frames are evidence of what was drawn, on this renderer; they
|
|
26
|
+
* are never a frame rate.
|
|
27
|
+
*
|
|
28
|
+
* `--preview` (no --url): THE FRAMES HALF WITHOUT THE WHOLE SITE. It starts the light preview server itself
|
|
29
|
+
* (lib/preview.mjs: the one built game's files on 127.0.0.1, no Wrangler, no rooms), opens the game's own page there
|
|
30
|
+
* and shoots it. The game plays alone, offline, with its bots, which is what pictures of the game itself want. The
|
|
31
|
+
* smoke is NOT RUN (there is no room to meet in: that half needs `dev`), and there is no loading cover to wait for.
|
|
32
|
+
* The result says which build the frames are of (`build`: its hash, the one `build` printed) and that the page
|
|
33
|
+
* really loaded that build's hashed bundle (`bundle`: lib/build.mjs bundleOf, never a guess at assets/main.js).
|
|
34
|
+
*
|
|
35
|
+
* Reuses the two-browser launch of lib/check.mjs's kind (lib/chrome.mjs flags, fresh profiles, the house QA user
|
|
36
|
+
* agent). The video skill's recorder (record-page.mjs) records in real time off the compositor and has no virtual
|
|
37
|
+
* clock, so there was nothing to import from it.
|
|
38
|
+
*/
|
|
39
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { tmpdir } from 'node:os';
|
|
41
|
+
import { join } from 'node:path';
|
|
42
|
+
import { bundleOf, gameDigest } from './build.mjs';
|
|
43
|
+
import { arrivalReadiness, LAUNCH_TIMEOUT_MS } from './check.mjs';
|
|
44
|
+
import { SOFTWARE_GL, chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
45
|
+
import { reachSite, siteRefusal } from './net.mjs';
|
|
46
|
+
import { previewServer } from './preview.mjs';
|
|
47
|
+
|
|
48
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
/** A bounded wait whose timer is cleared: nothing here may hold the command open. */
|
|
50
|
+
const T = (p, ms, v = null) => { let t = null; return Promise.race([Promise.resolve(p).catch(() => v), new Promise((r) => { t = setTimeout(() => r(v), ms); })]).finally(() => clearTimeout(t)); };
|
|
51
|
+
|
|
52
|
+
export const SHOOT_DEVICES = {
|
|
53
|
+
computer: { width: 1280, height: 720, deviceScaleFactor: 1 },
|
|
54
|
+
phone: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* THE VIRTUAL CLOCK, injected into every frame before any of its scripts run. Until `freeze()` it changes nothing a
|
|
59
|
+
* page can see: timers and animation frames run on the real clock, through this script's own queue. After
|
|
60
|
+
* `freeze()` time stands still, and `step(ms)` moves it: due timers fire in time order, then every queued animation
|
|
61
|
+
* frame callback runs once with the new time. `window.__homieClock` = { freeze(), step(ms), now(), frozen, stats() }.
|
|
62
|
+
*
|
|
63
|
+
* One queue with two drivers (the real clock, then the steps), so a loop that was started before the freeze is the
|
|
64
|
+
* same loop after it: nothing keeps running on the wall clock behind the frozen page.
|
|
65
|
+
*/
|
|
66
|
+
export const VIRTUAL_CLOCK = `(() => {
|
|
67
|
+
if (window.__homieClock) return;
|
|
68
|
+
const R = {
|
|
69
|
+
raf: window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null,
|
|
70
|
+
st: window.setTimeout.bind(window), si: window.setInterval.bind(window),
|
|
71
|
+
perf: performance.now.bind(performance), D: Date,
|
|
72
|
+
};
|
|
73
|
+
let frozen = false; let vPerf = 0; let vDate = 0; let seq = 0; let steps = 0;
|
|
74
|
+
const rafs = new Map(); const timers = new Map();
|
|
75
|
+
const perfNow = () => (frozen ? vPerf : R.perf());
|
|
76
|
+
const dateNow = () => (frozen ? vDate : R.D.now());
|
|
77
|
+
const report = (e) => { R.st(() => { throw e; }, 0); };
|
|
78
|
+
const runRafs = (t) => { const run = [...rafs.values()]; rafs.clear(); for (const cb of run) { try { cb(t); } catch (e) { report(e); } } };
|
|
79
|
+
const runTimers = (t) => {
|
|
80
|
+
for (let guard = 0; guard < 5000; guard++) {
|
|
81
|
+
let next = null;
|
|
82
|
+
for (const x of timers.values()) if (x.at <= t && (!next || x.at < next.at || (x.at === next.at && x.id < next.id))) next = x;
|
|
83
|
+
if (!next) return;
|
|
84
|
+
if (next.every !== null) next.at += Math.max(1, next.every); else timers.delete(next.id);
|
|
85
|
+
try { next.fn.apply(window, next.args); } catch (e) { report(e); }
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
window.requestAnimationFrame = (cb) => { const id = ++seq; rafs.set(id, cb); return id; };
|
|
89
|
+
window.cancelAnimationFrame = (id) => { rafs.delete(id); };
|
|
90
|
+
const add = (fn, ms, args, every) => { if (typeof fn !== 'function') return 0; const id = ++seq; const d = Math.max(0, Number(ms) || 0); timers.set(id, { id, fn, args, at: perfNow() + d, every: every ? d : null }); return id; };
|
|
91
|
+
window.setTimeout = (fn, ms, ...args) => add(fn, ms, args, false);
|
|
92
|
+
window.setInterval = (fn, ms, ...args) => add(fn, ms, args, true);
|
|
93
|
+
window.clearTimeout = (id) => { timers.delete(id); };
|
|
94
|
+
window.clearInterval = (id) => { timers.delete(id); };
|
|
95
|
+
performance.now = perfNow;
|
|
96
|
+
window.Date = new Proxy(R.D, {
|
|
97
|
+
construct(t, a) { return a.length ? new t(...a) : new t(dateNow()); },
|
|
98
|
+
apply(t) { return new t(dateNow()).toString(); },
|
|
99
|
+
get(t, k) { return k === 'now' ? dateNow : Reflect.get(t, k); },
|
|
100
|
+
});
|
|
101
|
+
// The real clock drives the queue until the freeze.
|
|
102
|
+
const frame = (t) => { if (frozen) return; runRafs(t); R.raf(frame); };
|
|
103
|
+
if (R.raf) R.raf(frame);
|
|
104
|
+
R.si(() => { if (!frozen) runTimers(R.perf()); }, 4);
|
|
105
|
+
const anims = new Set();
|
|
106
|
+
const stepAnims = (ms) => {
|
|
107
|
+
let list = []; try { list = document.getAnimations ? document.getAnimations() : []; } catch (e) { list = []; }
|
|
108
|
+
for (const a of list) {
|
|
109
|
+
try {
|
|
110
|
+
if (!anims.has(a)) { anims.add(a); a.pause(); }
|
|
111
|
+
if (ms > 0 && a.currentTime !== null) a.currentTime = Number(a.currentTime) + ms;
|
|
112
|
+
} catch (e) { /* an animation that cannot be driven stays where it is */ }
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
window.__homieClock = {
|
|
116
|
+
get frozen() { return frozen; },
|
|
117
|
+
now: perfNow,
|
|
118
|
+
freeze() { if (frozen) return vPerf; /* a whole millisecond (never earlier than the last real reading): stepped time is then base + n steps exactly, with no float dust between a step asked for and the time handed out */ vPerf = Math.ceil(R.perf()); vDate = R.D.now(); frozen = true; stepAnims(0); return vPerf; },
|
|
119
|
+
step(ms) {
|
|
120
|
+
if (!frozen) this.freeze();
|
|
121
|
+
const d = Math.max(0, Number(ms) || 0);
|
|
122
|
+
vPerf += d; vDate += d; steps += 1;
|
|
123
|
+
runTimers(vPerf); runRafs(vPerf); stepAnims(d);
|
|
124
|
+
return vPerf;
|
|
125
|
+
},
|
|
126
|
+
stats() { return { frozen, steps, rafs: rafs.size, timers: timers.size, now: perfNow() }; },
|
|
127
|
+
};
|
|
128
|
+
})();`;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* THE SMOKE VERDICT, from what each client's page said. `clients`: [{ kind, seated: { room, seat, role } | null,
|
|
132
|
+
* net: { connected, offline } | null }]. PASS only when both are seated in the same room on different seats with
|
|
133
|
+
* neither offline; a client whose connection state the page does not expose is said to be unknown, not assumed fine.
|
|
134
|
+
*/
|
|
135
|
+
export function judgeSmoke(clients) {
|
|
136
|
+
const missing = clients.filter((c) => !c.seated).map((c) => c.kind);
|
|
137
|
+
if (missing.length) return { ok: false, verdict: 'FAIL', why: `the ${missing.join(' and the ')} never got a seat` };
|
|
138
|
+
const rooms = [...new Set(clients.map((c) => c.seated.room))];
|
|
139
|
+
if (rooms.length > 1) return { ok: false, verdict: 'FAIL', why: `the clients landed in different rooms (${rooms.join(', ')})` };
|
|
140
|
+
const offline = clients.filter((c) => c.net?.offline === true || c.net?.connected === false).map((c) => c.kind);
|
|
141
|
+
if (offline.length) return { ok: false, verdict: 'FAIL', why: `the ${offline.join(' and the ')} has a seat on its own page but is playing alone offline (no room connection): two clients did not meet` };
|
|
142
|
+
const seats = clients.map((c) => c.seated.seat);
|
|
143
|
+
if (new Set(seats).size !== seats.length) return { ok: false, verdict: 'FAIL', why: `both clients report seat ${seats[0]}: they are not two players in one room` };
|
|
144
|
+
const unknown = clients.filter((c) => !c.net).map((c) => c.kind);
|
|
145
|
+
return { ok: true, verdict: 'PASS', room: rooms[0], seats, ...(unknown.length ? { note: `the ${unknown.join(' and the ')} exposes no connection state (window.__homieNet): seats and the room were measured, the socket was not` } : {}) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The game's own frame: inside the play page it is the /__game/ frame; on the preview server the page IS the game. */
|
|
149
|
+
const gameFrameOf = (page) => page.frames().find((f) => /\/__game\//.test(f.url())) ?? (page.__homiePreview ? page.mainFrame() : null);
|
|
150
|
+
|
|
151
|
+
/** Open one client on `url` with the virtual clock in every frame. */
|
|
152
|
+
async function open(puppeteer, chrome, device, url, profiles, browsers, { preview = false } = {}) {
|
|
153
|
+
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-shoot-'));
|
|
154
|
+
profiles.push(profile);
|
|
155
|
+
const vp = SHOOT_DEVICES[device];
|
|
156
|
+
const browser = await puppeteer.launch({
|
|
157
|
+
executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
|
|
158
|
+
args: [...chromeArgs(), '--mute-audio', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check', `--window-size=${vp.width},${vp.height}`, '--hide-scrollbars', '--force-color-profile=srgb'],
|
|
159
|
+
});
|
|
160
|
+
browsers.push(browser);
|
|
161
|
+
const page = await browser.newPage();
|
|
162
|
+
await page.setViewport(vp);
|
|
163
|
+
// The house's QA tag: a studio's own stats never count these visits.
|
|
164
|
+
await page.setUserAgent(`${await browser.userAgent()} homie-studio-check homie-studio-shoot`);
|
|
165
|
+
const errors = [];
|
|
166
|
+
page.on('pageerror', (e) => { if (errors.length < 20) errors.push(String(e?.message ?? e).slice(0, 240)); });
|
|
167
|
+
await page.evaluateOnNewDocument(VIRTUAL_CLOCK);
|
|
168
|
+
// Every script the page really loaded, by path: the preview path checks the build's own bundle is among them.
|
|
169
|
+
const loaded = new Set();
|
|
170
|
+
page.on('response', (r) => { try { if (r.ok()) loaded.add(new URL(r.url()).pathname); } catch { /* not a URL */ } });
|
|
171
|
+
if (preview) page.__homiePreview = true;
|
|
172
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
|
173
|
+
return { kind: device, page, errors, loaded, t0: Date.now() };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const seatOf = (c) => T(c.page.evaluate(() => { const s = window.__shell; return s && s.room && s.seat !== null && s.seat !== undefined && s.stats?.role ? { room: s.room, seat: s.seat, role: s.stats.role } : null; }), 5000);
|
|
177
|
+
const netOf = async (c) => { const f = gameFrameOf(c.page); return f ? T(f.evaluate(() => { const n = window.__homieNet; return n ? { connected: Boolean(n.connected), offline: Boolean(n.offline) } : null; }), 5000) : null; };
|
|
178
|
+
|
|
179
|
+
/** What the game exposes about one frame (all optional): the round, the body, the renderer's counters. */
|
|
180
|
+
const frameFacts = (c) => { const f = gameFrameOf(c.page); return f ? T(f.evaluate(() => {
|
|
181
|
+
const p = window.__homiePort; if (!p) return null;
|
|
182
|
+
let i = null; try { i = p.info(); } catch (e) { i = null; }
|
|
183
|
+
const r = p.rows(p.now() - 400); const last = r.length ? r[r.length - 1] : null;
|
|
184
|
+
const n = (v) => (Number.isFinite(v) ? v : null);
|
|
185
|
+
return { phase: i?.round?.phase ?? null, round: i?.round?.n ?? null, leftMs: n(i?.round?.leftMs), x: last ? n(last[1]) : null, y: last ? n(last[2]) : null, busy: last ? Boolean(last[8]) : null, drawCalls: n(Number(i?.extra?.drawCalls)), triangles: n(Number(i?.extra?.triangles)) };
|
|
186
|
+
}), 5000) : null; };
|
|
187
|
+
|
|
188
|
+
export async function shoot({ url, game, frames = 60, fps = 30, device = 'computer', out, smoke = true, hold = null, timeoutMs = 180_000, log = () => {}, preview = false, root = null }) {
|
|
189
|
+
if ((!url && !preview) || !game || (preview && !root)) return { ok: false, command: 'shoot', why: 'usage: homie-studio shoot <game> --url <site> | --preview [--frames 60] [--fps 30] [--device computer|phone] [--out <dir>] [--hold <key>] [--no-smoke]' };
|
|
190
|
+
if (!SHOOT_DEVICES[device]) return { ok: false, command: 'shoot', why: `unknown device ${device} (computer, phone)` };
|
|
191
|
+
if (!(Number.isInteger(frames) && frames >= 1 && frames <= 1800)) return { ok: false, command: 'shoot', why: '--frames 1..1800' };
|
|
192
|
+
if (!(fps >= 1 && fps <= 120)) return { ok: false, command: 'shoot', why: '--fps 1..120' };
|
|
193
|
+
const chrome = findChrome();
|
|
194
|
+
if (!chrome) return { ok: false, command: 'shoot', why: noChrome() };
|
|
195
|
+
let puppeteer;
|
|
196
|
+
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'shoot', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
197
|
+
// --preview: this command serves the one built game itself (no Wrangler, no rooms) and shoots that page.
|
|
198
|
+
let served = null;
|
|
199
|
+
if (preview) {
|
|
200
|
+
served = await previewServer(root, game, { port: 0 });
|
|
201
|
+
if (!served.ok) return { ...served, command: 'shoot' };
|
|
202
|
+
log(`preview: serving the build of ${game} at ${served.url} (no rooms: the game plays alone, offline)`);
|
|
203
|
+
}
|
|
204
|
+
const base = preview ? served.url.replace(/\/+$/, '') : String(url).replace(/\/+$/, '');
|
|
205
|
+
const room = preview ? null : `shoot-${Date.now().toString(36)}-${Math.floor(Math.random() * 46656).toString(36)}`;
|
|
206
|
+
const play = preview ? served.url : `${base}/${game}/play?room=${room}`;
|
|
207
|
+
// One preflight, in the words every command uses (lib/net.mjs siteRefusal). Nothing listening on this computer
|
|
208
|
+
// stops now; anything else this process was told (a name it cannot look up, an error answer) does not, because
|
|
209
|
+
// the browser may still open the site, and is what the command reports if the browser cannot either.
|
|
210
|
+
const cannotAsk = preview ? null : siteRefusal(await reachSite(base, { path: `/${game}/play`, timeout: 15_000 }), { command: 'shoot', play: `${base}/${game}/play` });
|
|
211
|
+
if (cannotAsk?.preflight === 'local') return cannotAsk;
|
|
212
|
+
mkdirSync(out, { recursive: true });
|
|
213
|
+
const started = Date.now();
|
|
214
|
+
const deadline = started + timeoutMs;
|
|
215
|
+
const left = () => deadline - Date.now();
|
|
216
|
+
const profiles = []; const browsers = [];
|
|
217
|
+
const result = { ok: false, command: 'shoot', game, play, room, out, device, fps, stepMs: +(1000 / fps).toFixed(4), asked: frames, ...(preview ? { preview: true } : {}) };
|
|
218
|
+
// Which build these frames are of, when this command can see the build (a studio's own site/dist): the build's
|
|
219
|
+
// hash (the one `build` printed and the live site answers with) and its real bundle, by bundleOf.
|
|
220
|
+
const builtDir = root ? join(root, 'site', 'dist', 'games', String(game)) : null;
|
|
221
|
+
const bundle = builtDir ? bundleOf(builtDir) : null;
|
|
222
|
+
if (builtDir && bundle) result.build = { hash: gameDigest(builtDir), bundle };
|
|
223
|
+
try {
|
|
224
|
+
let first;
|
|
225
|
+
try { first = await open(puppeteer, chrome, device, play, profiles, browsers, { preview }); } catch (error) {
|
|
226
|
+
if (cannotAsk) return { ...cannotAsk, play, why: `${cannotAsk.why} (The browser could not open it either: ${String(error?.message ?? error).split('\n')[0].slice(0, 120)}.)` };
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
/* ---- the two-client seat smoke */
|
|
230
|
+
if (preview) {
|
|
231
|
+
result.smoke = { verdict: 'NOT RUN', ok: null, why: 'the preview server has no rooms (the game plays alone, offline): two clients meeting in one room needs the whole site (homie-studio dev, then --url)' };
|
|
232
|
+
// No play page, so no seat and no loading cover to wait for: the page's own load, then a moment to draw.
|
|
233
|
+
await T(first.page.waitForFunction(() => document.readyState === 'complete', { timeout: Math.min(30_000, Math.max(1000, left())) }), 31_000);
|
|
234
|
+
await sleep(1200);
|
|
235
|
+
// The page must have loaded THIS build's bundle (index.html names it; nothing here guesses at assets/main.js).
|
|
236
|
+
if (bundle) {
|
|
237
|
+
result.build.loaded = first.loaded.has(`/${bundle}`);
|
|
238
|
+
if (!result.build.loaded) result.note = `the page did not load the build's bundle (${bundle}): these frames may not be of build ${result.build.hash}`;
|
|
239
|
+
}
|
|
240
|
+
} else if (smoke) {
|
|
241
|
+
const second = await open(puppeteer, chrome, device === 'phone' ? 'computer' : 'phone', play, profiles, browsers);
|
|
242
|
+
const clients = [first, second];
|
|
243
|
+
const until = Math.min(deadline, Date.now() + 90_000);
|
|
244
|
+
while (Date.now() < until) {
|
|
245
|
+
for (const c of clients) c.seated ??= await seatOf(c);
|
|
246
|
+
if (clients.every((c) => c.seated)) break;
|
|
247
|
+
await sleep(250);
|
|
248
|
+
}
|
|
249
|
+
// A moment for a client that seated itself offline to be told so by the room (or to connect).
|
|
250
|
+
if (clients.every((c) => c.seated)) await sleep(1500);
|
|
251
|
+
for (const c of clients) c.net = c.seated ? await netOf(c) : null;
|
|
252
|
+
const j = judgeSmoke(clients.map((c) => ({ kind: c.kind, seated: c.seated ?? null, net: c.net })));
|
|
253
|
+
result.smoke = { ...j, clients: clients.map((c) => ({ client: c.kind, seated: c.seated ?? null, seatedMs: c.seated ? Date.now() - c.t0 : null, connection: c.net ?? 'not exposed', errors: c.errors.slice(0, 5) })) };
|
|
254
|
+
log(`smoke: ${j.verdict}${j.why ? ` (${j.why})` : ''}`);
|
|
255
|
+
// The second client stays in the room while the frames are shot: the pictures are of a two-player room.
|
|
256
|
+
} else {
|
|
257
|
+
result.smoke = { verdict: 'NOT RUN', ok: null, why: '--no-smoke' };
|
|
258
|
+
const until = Math.min(deadline, Date.now() + 90_000);
|
|
259
|
+
while (Date.now() < until && !(first.seated ??= await seatOf(first))) await sleep(250);
|
|
260
|
+
}
|
|
261
|
+
/* ---- ready: the loading cover gone (reported; the frames are shot either way, and say which they are) */
|
|
262
|
+
let arrival = null; const tReady = Date.now();
|
|
263
|
+
while (left() > 0 && Date.now() - tReady < 35_000) {
|
|
264
|
+
arrival = await T(first.page.evaluate(() => { const a = window.__shell?.arrival; return a ? { phase: a.phase, step: a.step, liftedMs: a.liftedMs, by: a.by } : null; }), 5000);
|
|
265
|
+
if (!arrival || arrival.phase === 'done') break;
|
|
266
|
+
await sleep(250);
|
|
267
|
+
}
|
|
268
|
+
result.ready = preview ? { ready: null, by: null, liftedMs: null, waitedMs: Date.now() - tReady, why: 'the preview page is the game alone, with no play page and so no loading cover to lift: the frames start once the page had loaded' } : arrivalReadiness(arrival, { waitedMs: Date.now() - tReady });
|
|
269
|
+
const gf = gameFrameOf(first.page);
|
|
270
|
+
result.renderer = gf ? await T(gf.evaluate(() => { try { const c = document.createElement('canvas'); const gl = c.getContext('webgl2') || c.getContext('webgl'); const d = gl && gl.getExtension('WEBGL_debug_renderer_info'); return gl ? String(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL'; } catch (e) { return null; } }), 8000) : null;
|
|
271
|
+
result.software = Boolean(result.renderer && SOFTWARE_GL.test(result.renderer));
|
|
272
|
+
/* ---- frames on the virtual clock */
|
|
273
|
+
const all = () => first.page.frames();
|
|
274
|
+
const inAll = (fn, arg) => Promise.all(all().map((f) => T(f.evaluate(fn, arg), 20_000)));
|
|
275
|
+
const frozen = await inAll(() => (window.__homieClock ? (window.__homieClock.freeze(), true) : false));
|
|
276
|
+
if (!frozen.some(Boolean)) { result.why = 'the virtual clock is not in the page (it was opened before the script could be injected)'; return result; }
|
|
277
|
+
if (hold) await first.page.keyboard.down(hold).catch(() => {});
|
|
278
|
+
const rows = [];
|
|
279
|
+
for (let n = 1; n <= frames; n++) {
|
|
280
|
+
if (left() < 3000) { result.partial = `stopped at frame ${n - 1} of ${frames}: the ${Math.round(timeoutMs / 1000)} s bound was reached (a slow renderer; ask for fewer frames or a longer --timeout)`; break; }
|
|
281
|
+
const t = Date.now();
|
|
282
|
+
// Every frame of the page steps together: the shell and the game's frame share one moment.
|
|
283
|
+
const nows = await inAll((ms) => (window.__homieClock ? window.__homieClock.step(ms) : null), 1000 / fps);
|
|
284
|
+
const file = `frame-${String(n).padStart(4, '0')}.png`;
|
|
285
|
+
const png = await T(first.page.screenshot({ path: join(out, file), type: 'png', captureBeyondViewport: false }), 30_000);
|
|
286
|
+
if (!png) { result.partial = `stopped at frame ${n - 1} of ${frames}: a screenshot did not come back in 30 s`; break; }
|
|
287
|
+
rows.push({ n, file, virtualMs: +(n * (1000 / fps)).toFixed(3), clockMs: nows.find((v) => Number.isFinite(v)) ?? null, realMs: Date.now() - t, ...(await frameFacts(first) ?? {}) });
|
|
288
|
+
}
|
|
289
|
+
if (hold) await first.page.keyboard.up(hold).catch(() => {});
|
|
290
|
+
result.frames = rows.length;
|
|
291
|
+
result.realSeconds = +((Date.now() - started) / 1000).toFixed(1);
|
|
292
|
+
result.virtualSeconds = +((rows.length * 1000) / fps / 1000).toFixed(3);
|
|
293
|
+
result.errors = first.errors.slice(0, 10);
|
|
294
|
+
result.limits = 'Stepped: requestAnimationFrame, performance.now, Date, setTimeout, setInterval, CSS and Web animations, in the page and the game\'s frame. NOT stepped: the room\'s socket and its server clock (other players and the round arrive in real time), Web Audio\'s clock, <video>, workers. These frames show what was drawn; they are not a frame rate.';
|
|
295
|
+
const probed = rows.some((r) => r.phase !== undefined);
|
|
296
|
+
if (!probed) result.note = [result.note, 'the game exposes no port probe (exposePort): the frames carry no round phase or position'].filter(Boolean).join('; ');
|
|
297
|
+
writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
|
|
298
|
+
const smokeOk = result.smoke.ok !== false;
|
|
299
|
+
result.ok = rows.length === frames && smokeOk;
|
|
300
|
+
if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : result.partial;
|
|
301
|
+
return result;
|
|
302
|
+
} finally {
|
|
303
|
+
if (served?.server) { served.server.closeAllConnections?.(); served.server.close(); }
|
|
304
|
+
for (const b of browsers) { await T(b.close(), 8000); try { b.process()?.kill('SIGKILL'); } catch { /* gone */ } }
|
|
305
|
+
for (const p of profiles) rmSync(p, { recursive: true, force: true });
|
|
306
|
+
}
|
|
307
|
+
}
|
package/lib/site.mjs
CHANGED
|
@@ -304,7 +304,7 @@ const HERO_NAMES = { wide: ['wide.mp4', 'wide.webm'], tall: ['tall.mp4', 'tall.w
|
|
|
304
304
|
|
|
305
305
|
/**
|
|
306
306
|
* Everything a game's landing shows, from the game's own files: game.json (`landing` block, name, blurb, players,
|
|
307
|
-
* round length, cover
|
|
307
|
+
* round length, cover), credits.json, hero/ footage, and the studio's trailer and music for it. Files the
|
|
308
308
|
* landing shows are copied into the game's built folder under _landing/ (the cover beside the build when the
|
|
309
309
|
* build left it out), so the site serves them. Nothing is invented: a landing without footage uses the cover.
|
|
310
310
|
*/
|
|
@@ -466,7 +466,6 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
466
466
|
trailer: trailer ? trailer.slug : null,
|
|
467
467
|
videos: trailers.map((e) => e.slug),
|
|
468
468
|
music: forGame(songs).map((e) => e.slug),
|
|
469
|
-
source: g.share?.source !== false,
|
|
470
469
|
tv: g.screen?.tv !== false && L.tv !== false,
|
|
471
470
|
theme: theme && Object.keys(theme).length ? theme : null,
|
|
472
471
|
...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : ownScheme ? { scheme: ownScheme, schemeFrom: 'style' } : {}),
|
package/lib/stage.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A BUILD IS MADE, AND HOW IT BECOMES site/dist.
|
|
3
|
+
*
|
|
4
|
+
* `homie-studio build` used to empty site/dist and write into it. Two things went wrong with that: a build that
|
|
5
|
+
* failed halfway left a site with no catalogue (and a deploy right after would have shipped it), and anything
|
|
6
|
+
* running from site/dist (a static server started in site/dist/games/<id>, a capture or perf script loading the
|
|
7
|
+
* built game, `homie-studio preview`) lost its files, or was left running in a folder that no longer existed.
|
|
8
|
+
*
|
|
9
|
+
* So a build is made in a folder of its own (.studio/build/<pid>-<n>/, git-ignored, on the same disk), and only a
|
|
10
|
+
* build that finished is put in place, by `swapIn`:
|
|
11
|
+
*
|
|
12
|
+
* - site/dist and its folders are never removed and made again: the folder a server was started in is still
|
|
13
|
+
* the folder the next build fills (a folder goes only when the new build has nothing left in it);
|
|
14
|
+
* - a file whose bytes did not change is not touched (its date stays, so a watcher sees only what changed);
|
|
15
|
+
* - a file that did change is moved in under its own name in one step (a rename: a reader gets the old file or
|
|
16
|
+
* the new one, never half of one);
|
|
17
|
+
* - the order is what a page needs first: scripts, pictures and sounds, then pages, then the catalogue
|
|
18
|
+
* (games.json) last, and only then is what the new build no longer has taken away. A page is therefore never
|
|
19
|
+
* in place before the hashed bundle it names.
|
|
20
|
+
*
|
|
21
|
+
* A build that throws never reaches `swapIn`: site/dist is exactly what it was.
|
|
22
|
+
*/
|
|
23
|
+
import { constants, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
let opened = 0;
|
|
27
|
+
|
|
28
|
+
/** Is this process still running? (A build folder whose process is gone was left by a build that was killed.) */
|
|
29
|
+
function alive(pid) {
|
|
30
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
31
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A new, empty folder to build in. With `from` (a one-game build), it starts as a copy of the site as it is, so
|
|
36
|
+
* the rest of the site is still there when it is put back (the copy is a clone where the disk can make one, which
|
|
37
|
+
* costs nothing however big the media is). Folders left by builds that were killed are removed here.
|
|
38
|
+
*/
|
|
39
|
+
export function openStage(root, { from = null } = {}) {
|
|
40
|
+
const base = join(root, '.studio', 'build');
|
|
41
|
+
mkdirSync(base, { recursive: true });
|
|
42
|
+
for (const name of readdirSync(base)) {
|
|
43
|
+
const pid = Number(/^(\d+)-\d+$/.exec(name)?.[1]);
|
|
44
|
+
if (pid !== process.pid && !alive(pid)) rmSync(join(base, name), { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
const stage = join(base, `${process.pid}-${++opened}`);
|
|
47
|
+
rmSync(stage, { recursive: true, force: true });
|
|
48
|
+
if (from && existsSync(from)) cpSync(from, stage, { recursive: true, mode: constants.COPYFILE_FICLONE });
|
|
49
|
+
else mkdirSync(stage, { recursive: true });
|
|
50
|
+
return stage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every file under a folder, as paths with forward slashes. */
|
|
54
|
+
function filesOf(dir, rel = '', out = []) {
|
|
55
|
+
for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
56
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
57
|
+
if (e.isDirectory()) filesOf(dir, r, out); else out.push(r);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sameBytes(a, b) {
|
|
63
|
+
const sa = statSync(a); const sb = statSync(b);
|
|
64
|
+
if (!sb.isFile() || sa.size !== sb.size) return false;
|
|
65
|
+
return readFileSync(a).equals(readFileSync(b));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What goes in first (0), then the pages (1), then the catalogue (2): see the top of this file. */
|
|
69
|
+
const rank = (rel) => (rel === 'games.json' ? 2 : /\.(html?|json)$/i.test(rel) ? 1 : 0);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Put a finished build in place (see the top of this file). Returns what it did: how many files it wrote, left as
|
|
73
|
+
* they were and took away. The build folder is used up (its changed files are moved out of it).
|
|
74
|
+
*/
|
|
75
|
+
export function swapIn(stage, dist) {
|
|
76
|
+
mkdirSync(dist, { recursive: true });
|
|
77
|
+
const next = filesOf(stage).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
78
|
+
const res = { written: 0, same: 0, removed: 0 };
|
|
79
|
+
for (const rel of next) {
|
|
80
|
+
const from = join(stage, rel);
|
|
81
|
+
const to = join(dist, rel);
|
|
82
|
+
let there = null;
|
|
83
|
+
try { there = lstatSync(to); } catch { there = null; }
|
|
84
|
+
// A folder where the new build has a file: the folder goes (nothing of the new build is in it).
|
|
85
|
+
if (there?.isDirectory()) { rmSync(to, { recursive: true, force: true }); there = null; }
|
|
86
|
+
if (there && sameBytes(from, to)) { res.same += 1; continue; }
|
|
87
|
+
// A file where the new build has a folder, anywhere up the path.
|
|
88
|
+
for (let up = dirname(to); up.length > dist.length; up = dirname(up)) {
|
|
89
|
+
let st = null;
|
|
90
|
+
try { st = lstatSync(up); } catch { continue; }
|
|
91
|
+
if (!st.isDirectory()) rmSync(up, { force: true });
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
95
|
+
try { renameSync(from, to); } catch (error) {
|
|
96
|
+
// Another disk (a .studio that is a mount of its own): copy beside it, then the same one-step rename. A file
|
|
97
|
+
// something holds open on a system that will not rename over it: written over in place instead.
|
|
98
|
+
if (error?.code === 'EXDEV') {
|
|
99
|
+
const beside = `${to}.${process.pid}.new`;
|
|
100
|
+
cpSync(from, beside);
|
|
101
|
+
renameSync(beside, to);
|
|
102
|
+
} else if (error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES') cpSync(from, to, { force: true });
|
|
103
|
+
else throw error;
|
|
104
|
+
}
|
|
105
|
+
res.written += 1;
|
|
106
|
+
}
|
|
107
|
+
const keep = new Set(next);
|
|
108
|
+
for (const rel of filesOf(dist)) {
|
|
109
|
+
if (keep.has(rel)) continue;
|
|
110
|
+
rmSync(join(dist, rel), { force: true });
|
|
111
|
+
res.removed += 1;
|
|
112
|
+
// A folder the new build left empty goes too, but never site/dist itself.
|
|
113
|
+
for (let up = dirname(join(dist, rel)); up.length > dist.length; up = dirname(up)) {
|
|
114
|
+
try { rmdirSync(up); } catch { break; }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return res;
|
|
118
|
+
}
|
package/lib/studio.mjs
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
|
|
9
8
|
|
|
10
9
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
10
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -267,68 +266,3 @@ export function addNeeds(root, needs) {
|
|
|
267
266
|
return { needs: Object.fromEntries(want), needsAdded: added, needsHeld: held, installNeeded: added.length > 0 };
|
|
268
267
|
}
|
|
269
268
|
|
|
270
|
-
/**
|
|
271
|
-
* `homie-studio game remix <source.json url> --id <new id>`: a shared game brought in as this studio's own. Its
|
|
272
|
-
* game.json `remixOf` credits the original ("Remix of <game> by <studio>", with a link back to its page), and its
|
|
273
|
-
* landing and credits show it. A game whose owner's licence says no remix is refused (worker/license.mjs).
|
|
274
|
-
*/
|
|
275
|
-
export async function remixGame(root, source, id, { name } = {}) {
|
|
276
|
-
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('give the new game an id: --id <lowercase-id>');
|
|
277
|
-
const dest = join(root, 'games', id);
|
|
278
|
-
if (existsSync(dest)) throw new Error(`games/${id} already exists`);
|
|
279
|
-
let url;
|
|
280
|
-
try { url = new URL(source); } catch { throw new Error('usage: homie-studio game remix <https://studio.site/games/<id>/source.json> --id <new id>'); }
|
|
281
|
-
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost'].includes(url.hostname)) throw new Error('a remix source is an https:// address');
|
|
282
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(20_000) });
|
|
283
|
-
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
284
|
-
const body = await res.json();
|
|
285
|
-
if (body?.kind !== 'homie-game-source' || !body.files || typeof body.files !== 'object') throw new Error('that address is not a shared Homie game source');
|
|
286
|
-
// Who made it and what they allow: the source's credit and licence (a studio before 0.14.4 sends neither, so the
|
|
287
|
-
// default licence, and the page from the address).
|
|
288
|
-
const credit = body.credit && typeof body.credit === 'object' ? body.credit : {};
|
|
289
|
-
const license = licenseOf(body.license);
|
|
290
|
-
if (!remixAllowed(license)) {
|
|
291
|
-
const who = remixRow({ name: credit.game ?? body.id, studio: credit.studio });
|
|
292
|
-
throw new Error(`${who?.name ?? 'That game'}${who?.studio ? ` by ${who.studio}` : ''} is not open to remixing: its owner's licence says no remix. Make a game of your own like it instead.`);
|
|
293
|
-
}
|
|
294
|
-
const wrote = [];
|
|
295
|
-
for (const [rel, text] of Object.entries(body.files)) {
|
|
296
|
-
if (typeof text !== 'string' || rel.includes('..') || rel.startsWith('/') || !/^[A-Za-z0-9._/-]+$/.test(rel)) continue;
|
|
297
|
-
const path = join(dest, rel);
|
|
298
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
299
|
-
writeFileSync(path, text);
|
|
300
|
-
wrote.push(rel);
|
|
301
|
-
}
|
|
302
|
-
if (!existsSync(join(dest, 'game.json'))) throw new Error('the shared source has no game.json');
|
|
303
|
-
const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
|
|
304
|
-
const from = meta.id;
|
|
305
|
-
// The credit, in the remix's own game.json: the original's name, studio, page and licence, the line its landing and
|
|
306
|
-
// credits show, and, when the original was itself a remix, what that was a remix of.
|
|
307
|
-
const row = remixRow({ name: credit.game ?? meta.name ?? from, studio: credit.studio, page: httpsPage(credit.page) ?? pageOfSource(url) });
|
|
308
|
-
const before = remixRow(meta.remixOf);
|
|
309
|
-
meta.remixOf = {
|
|
310
|
-
credit: remixCredit(row), name: row?.name ?? null, studio: row?.studio ?? null, page: row?.page ?? null,
|
|
311
|
-
source: String(url), id: from, license, ...(before ? { of: before } : {}),
|
|
312
|
-
};
|
|
313
|
-
meta.id = id;
|
|
314
|
-
if (name) meta.name = String(name).slice(0, 60);
|
|
315
|
-
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
316
|
-
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
317
|
-
if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
318
|
-
if (from) renameTakeSaves(dest, from, id);
|
|
319
|
-
// Its models and pictures (0.22.0): fetched from the original site and checked by SHA-256 where their licence lets a
|
|
320
|
-
// remix carry them; a grey placeholder of the same size where it does not (lib/remix-assets.mjs).
|
|
321
|
-
let assets = null;
|
|
322
|
-
if (existsSync(join(dest, 'assets', 'manifest.json'))) {
|
|
323
|
-
const { fetchRemixAssets } = await import('./remix-assets.mjs');
|
|
324
|
-
assets = await fetchRemixAssets(root, id, url, { credit: { studio: credit.studio ?? null, game: credit.game ?? meta.name ?? from } });
|
|
325
|
-
}
|
|
326
|
-
// What its code imports beyond the toolkit (a 3D game: three.js). A remix source is someone else's text, so only the
|
|
327
|
-
// libraries the toolkit's own starters use are added; anything else is named for the person to decide.
|
|
328
|
-
const asked = meta.needs && typeof meta.needs === 'object' && !Array.isArray(meta.needs) ? meta.needs : {};
|
|
329
|
-
const refusedNeeds = Object.keys(asked).filter((n) => !REMIX_NEEDS.has(n));
|
|
330
|
-
const needs = addNeeds(root, Object.fromEntries(Object.entries(asked).filter(([n]) => REMIX_NEEDS.has(n))));
|
|
331
|
-
return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote, ...(assets ? { assets } : {}), ...needs, ...(refusedNeeds.length ? { needsNotAdded: refusedNeeds } : {}) };
|
|
332
|
-
}
|
|
333
|
-
/** The libraries a remix may add to a studio's package.json by itself (the ones the toolkit's starters use). */
|
|
334
|
-
const REMIX_NEEDS = new Set(['three']);
|
package/lib/style-board.mjs
CHANGED
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* assetsLineup(root, id) every made asset at true scale on a 1 m grid under the game's light (front and
|
|
11
11
|
* three-quarter), silhouettes black on white at 64 px tall, and flags: over budget, palette
|
|
12
12
|
* drift (CIEDE2000 from the locked palette), an unreadable silhouette, another library family,
|
|
13
|
-
* stale. .studio/art/<id>/lineup-*.jpg and lineup.json.
|
|
13
|
+
* stale. .studio/art/<id>/lineup-*.jpg and lineup.json. Three lineups, because they
|
|
14
|
+
* answer different questions: the INVENTORY (every recorded model, the folder), the CAST
|
|
15
|
+
* (what is in play: characters, creatures, props) and the ENVIRONMENT (the scenery). An
|
|
16
|
+
* asset marked `usage: "unused"` is in the inventory only (lineupScopes).
|
|
14
17
|
*/
|
|
15
18
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
19
|
import { extname, join, relative } from 'node:path';
|
|
17
|
-
import { readManifest } from './asset-manifest.mjs';
|
|
20
|
+
import { readManifest, usageOf } from './asset-manifest.mjs';
|
|
18
21
|
import { assetsCheck } from './asset-check.mjs';
|
|
19
22
|
import { mix, nearest } from './colour.mjs';
|
|
20
23
|
import { directionsFor, initDecisions, labelOf, lightColours, readDecisions, staleAssets, styleTokens, writeDecisions, addLatestLine } from './decisions.mjs';
|
|
@@ -157,7 +160,22 @@ export async function addGolden(root, id, image, { from = null } = {}) {
|
|
|
157
160
|
|
|
158
161
|
const DRIFT = 18; // mean CIEDE2000 (area-weighted) from the palette above which an asset is flagged
|
|
159
162
|
|
|
160
|
-
export
|
|
163
|
+
export const LINEUP_SCOPES = Object.freeze(['inventory', 'cast', 'environment']);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Which recorded models stand in which lineup: { inventory, cast, environment, unused } (asset ids), with each
|
|
167
|
+
* asset's usage and whether its record says it or its kind does. A manifest with no `usage` anywhere sorts by kind
|
|
168
|
+
* (characters, creatures and props are the cast; kits and environment pieces the scenery) and marks nothing unused, so
|
|
169
|
+
* an older game's lineup is what it was, plus the two narrower pictures.
|
|
170
|
+
*/
|
|
171
|
+
export function lineupScopes(manifest) {
|
|
172
|
+
const rows = manifest.assets.filter((a) => (a.files ?? []).some((f) => f.role === 'model')).map((a) => ({ id: a.id, usage: usageOf(a), explicit: Boolean(a.usage) }));
|
|
173
|
+
const ids = (keep) => rows.filter((r) => keep(r.usage)).map((r) => r.id);
|
|
174
|
+
return { rows, inventory: rows.map((r) => r.id), cast: ids((u) => u === 'cast' || u === 'prop'), environment: ids((u) => u === 'environment'), unused: ids((u) => u === 'unused') };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function assetsLineup(root, id, { log = () => {}, scope = null } = {}) {
|
|
178
|
+
if (scope !== null && !LINEUP_SCOPES.includes(scope)) return { ok: false, command: 'assets lineup', id, why: `--scope is one of ${LINEUP_SCOPES.join(', ')} (none: all three)` };
|
|
161
179
|
const manifest = readManifest(root, id);
|
|
162
180
|
const doc = readDecisions(root, id);
|
|
163
181
|
const tokens = styleTokens(doc) ?? { palette: { bg: '#20242c', ink: '#f2f2f2', accent: '#ffcf5a', accent2: '#7dffb0', danger: '#ff5d5d', good: '#5fdc8b', gold: '#f2c14e', ramp: [] } };
|
|
@@ -177,9 +195,31 @@ export async function assetsLineup(root, id, { log = () => {} } = {}) {
|
|
|
177
195
|
if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
|
|
178
196
|
const dir = join(root, '.studio', 'art', id);
|
|
179
197
|
mkdirSync(dir, { recursive: true });
|
|
180
|
-
const
|
|
198
|
+
const scopes = lineupScopes(manifest);
|
|
199
|
+
const present = new Set(models.map((m) => m.a.id));
|
|
200
|
+
// The inventory is always drawn (its rows carry every model's size and silhouette). The cast and the environment
|
|
201
|
+
// get a picture of their own when they are a different, non-empty set: the same models twice is one picture.
|
|
202
|
+
const narrower = {};
|
|
203
|
+
const shot = await withRenderer(async (r) => {
|
|
204
|
+
const all = await r.lineup(models.map((m) => m.input), tokens);
|
|
205
|
+
for (const name of ['cast', 'environment']) {
|
|
206
|
+
if (scope && scope !== name) continue;
|
|
207
|
+
const ids = scopes[name].filter((x) => present.has(x));
|
|
208
|
+
if (!ids.length) { narrower[name] = { ids, images: null }; continue; }
|
|
209
|
+
if (ids.length === models.length) { narrower[name] = { ids, images: null, same: true }; continue; }
|
|
210
|
+
narrower[name] = { ids, shot: await r.lineup(models.filter((m) => ids.includes(m.a.id)).map((m) => m.input), tokens) };
|
|
211
|
+
}
|
|
212
|
+
return { renderer: r.renderer, ...all };
|
|
213
|
+
}, { log });
|
|
181
214
|
const images = { front: join(dir, 'lineup-front.jpg'), quarter: join(dir, 'lineup-quarter.jpg'), silhouettes: join(dir, 'lineup-silhouettes.png') };
|
|
182
215
|
saveDataUrl(shot.front, images.front); saveDataUrl(shot.quarter, images.quarter); saveDataUrl(shot.silhouettes, images.silhouettes);
|
|
216
|
+
const lineups = { inventory: { ids: models.map((m) => m.a.id), images: Object.fromEntries(Object.entries(images).map(([k, v]) => [k, relative(root, v)])) } };
|
|
217
|
+
for (const [name, l] of Object.entries(narrower)) {
|
|
218
|
+
if (!l.shot) { lineups[name] = { ids: l.ids, images: null, ...(l.same ? { same: true } : {}) }; continue; }
|
|
219
|
+
const files = { front: join(dir, `lineup-${name}-front.jpg`), quarter: join(dir, `lineup-${name}-quarter.jpg`), silhouettes: join(dir, `lineup-${name}-silhouettes.png`) };
|
|
220
|
+
saveDataUrl(l.shot.front, files.front); saveDataUrl(l.shot.quarter, files.quarter); saveDataUrl(l.shot.silhouettes, files.silhouettes);
|
|
221
|
+
lineups[name] = { ids: l.ids, images: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, relative(root, v)])) };
|
|
222
|
+
}
|
|
183
223
|
const palette = [...PALETTE_KEYS.map((k) => tokens.palette[k]), ...(tokens.palette.ramp ?? [])].filter(Boolean);
|
|
184
224
|
const family = doc?.decisions?.['cast.family']?.value ?? null;
|
|
185
225
|
const check = await assetsCheck(root, id, { validate: false, write: true }).catch(() => null);
|
|
@@ -206,9 +246,12 @@ export async function assetsLineup(root, id, { log = () => {} } = {}) {
|
|
|
206
246
|
if (c && !c.ok) flags.push(`over budget: ${c.problems[0]}`);
|
|
207
247
|
if (family && m.a.route === 'library' && m.a.from?.pack && !String(m.a.from.pack).startsWith(family)) flags.push(`another library family (${m.a.from.pack}; the game's is ${family}): mixing families shows`);
|
|
208
248
|
if (stale.some((s) => s.id === m.a.id)) flags.push('stale: made under an older decision');
|
|
209
|
-
|
|
249
|
+
const use = scopes.rows.find((x) => x.id === m.a.id);
|
|
250
|
+
rows.push({ id: m.a.id, label: m.input.label, kind: m.a.kind, route: m.a.route, usage: use?.usage ?? null, usageExplicit: Boolean(use?.explicit), size: row?.size ?? null, silhouette: row?.silhouette ?? null, drift, flags });
|
|
210
251
|
}
|
|
211
|
-
|
|
252
|
+
// `flagged` counts what the game draws: a flag on a file marked unused is about the folder, said apart.
|
|
253
|
+
const inPlay = rows.filter((r) => r.usage !== 'unused');
|
|
254
|
+
const result = { ok: true, command: 'assets lineup', id, renderer: shot.renderer, images: lineups.inventory.images, lineups, unused: rows.filter((r) => r.usage === 'unused').map((r) => r.id), rows, palette: palette.slice(0, 7), family, at: new Date().toISOString(), flagged: inPlay.filter((r) => r.flags.length).length, flaggedUnused: rows.filter((r) => r.usage === 'unused' && r.flags.length).length };
|
|
212
255
|
writeFileSync(join(dir, 'lineup.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
213
256
|
return result;
|
|
214
257
|
}
|