@homie-rocks/studio 0.37.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/bin/homie-studio.mjs +7 -6
- package/lib/cloudflare.mjs +28 -3
- package/lib/dev.mjs +1 -1
- package/lib/parts-build.mjs +23 -4
- package/lib/parts-cli.mjs +54 -3
- package/lib/parts-purchase.mjs +174 -0
- package/lib/parts-store.mjs +50 -19
- package/lib/parts-tools.mjs +15 -12
- package/lib/parts-upload.mjs +43 -0
- package/lib/parts.mjs +49 -5
- package/lib/perf.mjs +17 -10
- package/lib/port-check.mjs +77 -18
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/scaffold.mjs +25 -5
- package/lib/shoot.mjs +12 -6
- package/lib/shop-links.mjs +15 -6
- package/lib/shop.mjs +91 -21
- package/lib/stripe-connect.mjs +2 -2
- package/lib/template-history.json +85 -0
- package/netplay/NETPLAY.md +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/port/probe.ts +2 -1
- package/rules/core.ts +3 -1
- package/rules/host.ts +3 -1
- package/rules/pack.ts +30 -3
- package/rules/view.ts +232 -40
- package/starters/coin-dash/lab.json +17 -0
- package/starters/coin-dash/src/rules.ts +3 -3
- package/starters/gem-rush/game.json +19 -4
- package/starters/gem-rush/map/main.json +50 -0
- package/starters/gem-rush/src/move.ts +23 -0
- package/starters/gem-rush/src/rules.ts +118 -0
- package/starters/gem-rush/src/view.ts +429 -0
- package/starters/gem-rush/tunables.json +98 -11
- package/worker/extensions.mjs +13 -0
- package/worker/index.mjs +3 -2
- package/worker/machine-payments.mjs +72 -0
- package/worker/office.mjs +4 -1
- package/worker/parts-protected.mjs +111 -0
- package/worker/parts-purchase-catalogue.mjs +34 -0
- package/worker/parts-resource.mjs +32 -0
- package/worker/parts-sale.mjs +59 -0
- package/worker/parts.mjs +24 -11
- package/worker/payment-capabilities.mjs +70 -0
- package/worker/payment-dry-exchange.mjs +30 -0
- package/worker/payment-manual-refund.mjs +40 -0
- package/worker/payment-policy.mjs +40 -0
- package/worker/payment-recovery.mjs +163 -0
- package/worker/payment-test-exchange.mjs +19 -0
- package/worker/purchase-core.mjs +140 -0
- package/worker/purchase-crypto.mjs +17 -0
- package/worker/purchase-delivery.mjs +108 -0
- package/worker/purchase-discovery.mjs +26 -0
- package/worker/purchase-hosted-readiness.mjs +16 -0
- package/worker/purchase-keys.mjs +20 -0
- package/worker/purchase-links.mjs +9 -0
- package/worker/purchase-machine.mjs +279 -0
- package/worker/purchase-mcp.mjs +42 -0
- package/worker/purchase-office-routes.mjs +17 -0
- package/worker/purchase-office-script.mjs +38 -0
- package/worker/purchase-office.mjs +35 -0
- package/worker/purchase-owner.mjs +243 -0
- package/worker/purchase-pricing.mjs +8 -0
- package/worker/purchase-problem.mjs +4 -0
- package/worker/purchase-reconciliation.mjs +582 -0
- package/worker/purchase-response.mjs +82 -0
- package/worker/purchase-routes.mjs +611 -0
- package/worker/purchase-schema.mjs +50 -0
- package/worker/purchase-store.mjs +148 -0
- package/worker/purchases.mjs +6 -0
- package/worker/resource-kinds.mjs +20 -0
- package/worker/selling/index.mjs +8 -0
- package/worker/selling/runtime.mjs +42 -0
- package/worker/settlement-journal.mjs +138 -0
- package/worker/shop-page.mjs +7 -4
- package/worker/shop-refunds.mjs +22 -0
- package/worker/shop-rules.mjs +1 -1
- package/worker/shop.mjs +18 -18
- package/worker/stripe.mjs +36 -3
- package/worker/version.mjs +1 -1
- package/starters/gem-rush/src/main.ts +0 -1087
package/lib/parts-store.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { paidAccess, savePurchaseBackup, offlinePurchase, purchaseFacts } from './parts-purchase.mjs';
|
|
2
|
+
import { priceWords } from "../worker/purchase-pricing.mjs";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* FINDING, ADDING AND SHARING PARTS (parts/PARTS.md): what the four chat tools do (lib/parts-tools.mjs).
|
|
3
6
|
*
|
|
@@ -37,13 +40,13 @@ export function baseOf(host) {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/** Every request this file makes. Bytes back, capped; a plain sentence when it fails. */
|
|
40
|
-
async function get(url, { fetch: f = globalThis.fetch, max = LIMITS.fileBytes, what = 'the file' } = {}) {
|
|
43
|
+
async function get(url, { fetch: f = globalThis.fetch, max = LIMITS.fileBytes, what = 'the file', authorization } = {}) {
|
|
41
44
|
let res;
|
|
42
|
-
try { res = await f(url, { redirect: 'follow', headers: { accept: '*/*' } }); } catch (error) { throw new Error(`${new URL(url).host} could not be reached (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)})`); }
|
|
45
|
+
try { res = await f(url, { redirect: authorization ? 'error' : 'follow', headers: { accept: '*/*', ...(authorization ? { authorization: `Bearer ${authorization}` } : {}) }, signal: AbortSignal.timeout(30000) }); } catch (error) { throw new Error(`${new URL(url).host} could not be reached (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)})`); }
|
|
43
46
|
if (!res.ok) throw new Error(`${url} answered ${res.status}: ${what} is not there`);
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
47
|
+
const reader = res.body?.getReader(); const chunks = []; let size = 0;
|
|
48
|
+
if (reader) for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > max) { await reader.cancel(); throw new Error(`${url} exceeds the size ${what} may be (${max} bytes)`); } chunks.push(value); }
|
|
49
|
+
return Buffer.concat(chunks);
|
|
47
50
|
}
|
|
48
51
|
async function getJson(url, opts) {
|
|
49
52
|
const bytes = await get(url, { max: 4 * 1024 * 1024, ...opts });
|
|
@@ -55,12 +58,22 @@ async function getJson(url, opts) {
|
|
|
55
58
|
* Nothing is returned unless every file's SHA-256 and size are what the part.json says, so nothing unverified can
|
|
56
59
|
* be written by a caller.
|
|
57
60
|
*/
|
|
58
|
-
export async function fetchPart(refText, { fetch } = {}) {
|
|
61
|
+
export async function fetchPart(refText, { fetch, onPaid } = {}) {
|
|
59
62
|
const ref = parseRef(refText);
|
|
60
63
|
if (!ref) throw new Error(`"${String(refText).slice(0, 80)}" does not name a part: "<studio site>/<part id>", such as "owls.example/pickup-field" (and "@1.2.0" for one version)`);
|
|
61
64
|
const base = baseOf(ref.host);
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
let catalog = `${base}/parts/catalog.json`;
|
|
66
|
+
try {
|
|
67
|
+
const discovery = await getJson(`${base}/.well-known/api-catalog`, { fetch, what: 'the studio API catalogue' });
|
|
68
|
+
const candidate = discovery.linkset?.flatMap((item) => item.item ?? []).find((item) => item.type === 'application/json' && new URL(item.href).origin === base);
|
|
69
|
+
if (candidate) catalog = candidate.href;
|
|
70
|
+
} catch { /* Older studios expose the resource catalogue directly. */ }
|
|
71
|
+
const index = await getJson(catalog, { fetch, what: 'the studio list of shared parts' }).catch(() => getJson(`${base}/.well-known/homie-parts.json`, { fetch, what: 'the older studio catalogue' })).catch((error) => { throw new Error(`${ref.host} shares no parts that can be read: ${error.message}`); });
|
|
72
|
+
let entry = (Array.isArray(index?.parts) ? index.parts : []).find((p) => p?.id === ref.id);
|
|
73
|
+
if (!entry && ref.version) {
|
|
74
|
+
const retired = await getJson(`${base}/parts/${ref.id}/${ref.version}/part.json`, { fetch, max: LIMITS.json, what: 'the previously sold release' });
|
|
75
|
+
if (retired.sale) entry = { ...retired, versions: [ref.version] };
|
|
76
|
+
}
|
|
64
77
|
if (!entry) {
|
|
65
78
|
const names = (Array.isArray(index?.parts) ? index.parts : []).map((p) => p?.id).filter(Boolean);
|
|
66
79
|
throw new Error(`${ref.host} does not share a part "${ref.id}"${names.length ? ` (it shares: ${names.slice(0, 12).join(', ')})` : ' (it shares none)'}. A part its studio keeps private is not there to add.`);
|
|
@@ -75,16 +88,19 @@ export async function fetchPart(refText, { fetch } = {}) {
|
|
|
75
88
|
if (part.id !== ref.id || part.version !== version) throw new Error(`${at}part.json says it is ${part.id} ${part.version}, not ${ref.id} ${version}`);
|
|
76
89
|
if (part.share !== true) throw new Error(`${ref.ref} is not shared`);
|
|
77
90
|
if (!Array.isArray(part.files) || !part.files.length) throw new Error(`${ref.ref} ${version} lists no files`);
|
|
91
|
+
const access = part.sale ? (onPaid ? await onPaid(ref, part, entry.releases?.find((r) => r.version === version)?.offer ?? part.sale) : null) : null;
|
|
92
|
+
if (part.sale && !access) throw new Error(`This is a paid part: ${priceWords(part.sale)}. Use part_add to review its price first.`);
|
|
78
93
|
const files = new Map();
|
|
79
94
|
let total = 0;
|
|
80
95
|
for (const f of part.files) {
|
|
81
|
-
const
|
|
96
|
+
const delivered = access?.files?.find((item) => item.path === f.path);
|
|
97
|
+
const bytes = delivered ? Buffer.from(delivered.data, 'base64') : await get(`${at}${f.path.split('/').map(encodeURIComponent).join('/')}`, { fetch, max: Math.min(LIMITS.fileBytes, f.bytes), what: f.path, authorization: access?.token });
|
|
82
98
|
if (bytes.length !== f.bytes || sha256(bytes) !== f.sha256) throw new Error(`${ref.ref} ${version}: ${f.path} is not the file its part.json describes (its SHA-256 or size differs). Nothing was written; tell its studio.`);
|
|
83
99
|
total += bytes.length;
|
|
84
100
|
if (total > LIMITS.totalBytes) throw new Error(`${ref.ref} is over ${Math.round(LIMITS.totalBytes / 1048576)} MB`);
|
|
85
101
|
files.set(f.path, bytes);
|
|
86
102
|
}
|
|
87
|
-
return { ref, part, files, entry, base, version, versions };
|
|
103
|
+
return { ref, part, files, entry, base, version, versions, access };
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
/* ------------------------------------------------------------------ packages: npm's */
|
|
@@ -124,10 +140,10 @@ export function ensurePackages(root, wants, { npm = runNpm, install = false, say
|
|
|
124
140
|
if (ls.status === 0) { rows.push({ ...w, state: 'ok' }); continue; }
|
|
125
141
|
const pinned = pinnedIn(root, w.name);
|
|
126
142
|
if (pinned) { rows.push({ ...w, state: 'pinned', has: pinned, why: `${w.by} is built on ${w.name} ${w.range}; this studio's package.json has ${w.name} at ${pinned}, which npm says does not fit. Nothing was changed: move the studio to a version in that range if the part is worth it, or use a part written against ${pinned}.` }); continue; }
|
|
127
|
-
const command = `npm install --save-exact ${spec}`;
|
|
143
|
+
const command = `npm install --ignore-scripts --save-exact ${spec}`;
|
|
128
144
|
if (!install) { rows.push({ ...w, state: 'missing', command, why: `${w.by} is built on ${w.name} ${w.range}, which this studio does not have.` }); continue; }
|
|
129
145
|
say(`Installing ${spec} from npm: ${w.by} is built on it.`);
|
|
130
|
-
const r = npm(['install', '--save-exact', spec], { cwd: root });
|
|
146
|
+
const r = npm(['install', '--ignore-scripts', '--save-exact', spec], { cwd: root });
|
|
131
147
|
const said = `${r.stdout}${r.stderr}`.trim();
|
|
132
148
|
rows.push(r.status === 0 ? { ...w, state: 'installed', said } : { ...w, state: 'failed', said, command, why: `npm could not install ${spec} for ${w.by}. npm said:\n${said}` });
|
|
133
149
|
}
|
|
@@ -153,7 +169,7 @@ export function partItems(root, { game = null, refs = null } = {}) {
|
|
|
153
169
|
const dir = vendorDir(root, ref);
|
|
154
170
|
let part = null;
|
|
155
171
|
try { part = readPart(dir); } catch { part = null; }
|
|
156
|
-
items.push({ ref, part, vendored: true, changed: part ? verifyFiles(dir, { files: e.files }, { skipTuning: true }).changed : [] });
|
|
172
|
+
items.push({ ref, part, vendored: true, purchase: e.purchase ? { ...(e.purchases?.[game] ?? e.purchase), ...purchaseFacts(root, ref, game) } : null, changed: part ? verifyFiles(dir, { files: e.files }, { skipTuning: true }).changed : [] });
|
|
157
173
|
}
|
|
158
174
|
for (const o of listOwnParts(root)) if (refs ? refs.includes(o.id) : !game) items.push({ ref: o.id, part: o.part, vendored: false });
|
|
159
175
|
return items;
|
|
@@ -174,12 +190,19 @@ const diffFiles = (was, now) => {
|
|
|
174
190
|
* told to (`overwrite`); only then write. tuning.json is the studio's from the first add and is never replaced: a
|
|
175
191
|
* new version's defaults land beside it as tuning.upstream.json.
|
|
176
192
|
*/
|
|
177
|
-
export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {} } = {}) {
|
|
193
|
+
export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {}, approve = null, quantity = 1, offline = false, wallet, walletPay } = {}) {
|
|
178
194
|
const command = 'parts add';
|
|
179
195
|
const fail = (why, extra = {}) => ({ ok: false, command, why, ...extra });
|
|
180
196
|
if (game && !existsSync(experienceFile(root, game))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
|
|
181
197
|
let got;
|
|
182
|
-
try {
|
|
198
|
+
try {
|
|
199
|
+
got = offline ? await offlinePurchase(root, refText, game) : await fetchPart(refText, { fetch, onPaid: (ref, part, offer) => paidAccess(root, ref, part, { game, quantity, approve, fetch, offer, wallet, walletPay }) });
|
|
200
|
+
if (got.access) {
|
|
201
|
+
savePurchaseBackup(root, got, got.access, game);
|
|
202
|
+
const c = got.access.claims;
|
|
203
|
+
got.purchase = { version: c.version, mode: c.mode, scope: c.terms.scope, game: c.game, quantity: c.quantity, paidUntil: c.paidUntil, onExpiry: c.onExpiry, onRefund: c.onRefund, status: 'paid', checkedAt: new Date().toISOString() };
|
|
204
|
+
}
|
|
205
|
+
} catch (error) { return fail(error.message, error.purchase ? { purchase: error.purchase } : {}); }
|
|
183
206
|
const { ref, part, files, version, base, entry } = got;
|
|
184
207
|
const lock = readOrigins(root);
|
|
185
208
|
const was = lock.parts[ref.ref] ?? null;
|
|
@@ -205,6 +228,7 @@ export async function addPart(root, refText, { game = null, fetch, npm = runNpm,
|
|
|
205
228
|
const games = [...new Set([...(was?.games ?? []), ...(game ? [game] : [])])].sort();
|
|
206
229
|
const from = isObj(entry.from) ? { game: line(entry.from.game, 40) || null, name: line(entry.from.name, 80) || null, studio: line(entry.from.studio, 80) || null, page: /^https:\/\//.test(String(entry.from.page ?? '')) ? String(entry.from.page).slice(0, 300) : null } : isObj(part.from) ? { game: part.from.game ?? null, studio: line(part.from.studio, 80) || null } : null;
|
|
207
230
|
lock.parts[ref.ref] = {
|
|
231
|
+
...(got.purchase ? { purchase: got.purchase, purchases: { ...(was?.purchases ?? {}), ...(got.purchase.scope === 'game' ? { [game]: got.purchase } : {}) } } : {}),
|
|
208
232
|
version, url: `${base}/parts/${ref.id}/${version}/`, license: part.license ?? null, attribution: part.attribution ?? '', name: part.name, kind: part.kind,
|
|
209
233
|
...(from ? { from } : {}), page: `${base}/parts/${ref.id}/`, studio: line(got.entry?.studio?.name, 80) || null,
|
|
210
234
|
files: part.files.map(({ path, sha256: h, bytes }) => ({ path, sha256: h, bytes })), games, addedAt: was?.addedAt ?? new Date().toISOString(), ...(was && !same ? { updatedAt: new Date().toISOString() } : {}),
|
|
@@ -224,7 +248,7 @@ export async function addPart(root, refText, { game = null, fetch, npm = runNpm,
|
|
|
224
248
|
license: lic?.id ?? null, asks: lic?.asks ?? null, attribution: part.attribution ?? '', ...(from ? { from } : {}),
|
|
225
249
|
files: part.files.length, bytes: part.files.reduce((n, f) => n + f.bytes, 0), verified: true,
|
|
226
250
|
entry: part.entry ?? null, import: part.entry ? `@parts/${ref.ref}` : null, game, credits,
|
|
227
|
-
packages, needs, licences,
|
|
251
|
+
packages, needs, licences, ...(got.purchase ? { purchase: got.purchase } : {}),
|
|
228
252
|
ready: !blocked.length && !packages.some((p) => p.state === 'missing') && !needs.length && !licences.some((c) => c.level === 'conflict'),
|
|
229
253
|
};
|
|
230
254
|
}
|
|
@@ -289,8 +313,8 @@ export function sharePart(root, id, on = true) {
|
|
|
289
313
|
const row = (p, extra) => ({
|
|
290
314
|
id: p.id, name: p.name, kind: p.kind, version: p.version, summary: line(p.summary), license: p.license || null, tags: Array.isArray(p.tags) ? p.tags.slice(0, 12) : [],
|
|
291
315
|
...(Array.isArray(p.uses) ? { uses: p.uses } : {}),
|
|
292
|
-
from: isObj(p.from) ? { ...(p.from.app ? { app: p.from.app, open: p.from.open ?? null } : {}), ...(p.from.music ? { music: p.from.music } : {}), ...(p.from.video ? { video: p.from.video } : {}), game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
|
|
293
|
-
cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
|
|
316
|
+
from: isObj(p.from) ? { ...(p.from.kind ? { kind: p.from.kind, id: p.from.id ?? null } : {}), ...(p.from.app ? { app: p.from.app, open: p.from.open ?? null } : {}), ...(p.from.music ? { music: p.from.music } : {}), ...(p.from.video ? { video: p.from.video } : {}), game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
|
|
317
|
+
sale: p.sale ?? null, price: priceWords(p.sale), purchaseRequired: Boolean(p.sale), licenseTerms: p.licenseTerms ?? null, quality: p.quality ?? null, checkedAt: p.checkedAt ?? null, available: p.available ?? null, cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
|
|
294
318
|
});
|
|
295
319
|
function matches(p, { words, kind, tag, license, builds }) {
|
|
296
320
|
if (kind && p.kind !== kind) return false;
|
|
@@ -332,12 +356,19 @@ export async function findParts(root, query = '', { kind = null, tag = null, lic
|
|
|
332
356
|
const have = new Set(here.filter((h) => h.where === 'here').map((h) => h.ref));
|
|
333
357
|
found = index.parts.filter((p) => isObj(p) && typeof p.id === 'string' && (typeof p.host === 'string' || typeof p.add === 'string')).map((p) => {
|
|
334
358
|
const ref = parseRef(typeof p.add === 'string' ? p.add : `${p.host}/${p.id}`)?.ref;
|
|
335
|
-
return ref ? row(p, { where: 'hub', ref, add: ref, studio: line(p.studio?.name, 80) || null, page: /^https:\/\//.test(String(p.page ?? '')) ? p.page : null, here: have.has(ref), say: `Add it: part_add { "part": "${ref}" }.` }) : null;
|
|
359
|
+
return ref ? row(p, { where: 'hub', ref, add: ref, studio: line(p.studio?.name, 80) || null, page: /^https:\/\//.test(String(p.page ?? '')) ? p.page : null, here: have.has(ref), say: `${p.sale ? `Paid: ${priceWords(p.sale)}. Ask for approval of the exact quote before requesting payment. ` : 'Free. '}Add it: part_add { "part": "${ref}" }.` }) : null;
|
|
336
360
|
}).filter(Boolean);
|
|
337
361
|
hubState = { ok: true, url, parts: found.length };
|
|
338
362
|
} catch (error) {
|
|
339
363
|
hubState = { ok: false, url, why: `The catalogue of shared parts could not be read just now (${error.message}). This is NOT "no parts exist": only this studio's own parts were searched. Try again, or add a part straight from a studio you know (part_add with "<studio site>/<part id>").` };
|
|
340
364
|
}
|
|
341
365
|
const results = [...here.filter((p) => matches(p, filter)), ...found.filter((p) => !p.here && matches(p, filter))];
|
|
366
|
+
for (const p of results) {
|
|
367
|
+
p.relevance = words.reduce((n, w) => n + (String(p.name ?? '').toLowerCase().includes(w) ? 3 : 0) + (String(p.id).includes(w) ? 2 : 0) + (String(p.summary ?? '').toLowerCase().includes(w) ? 1 : 0), 0);
|
|
368
|
+
p.availability = p.available === false ? 'unavailable' : p.where === 'hub' && (!Number.isFinite(Date.parse(p.checkedAt)) || Date.parse(p.checkedAt) > Date.now() + 300000 || Date.now() - Date.parse(p.checkedAt) > 86400000) ? 'stale: verify with seller' : 'recently checked';
|
|
369
|
+
p.qualityMeaning = 'Seller-attested purchases are not independent payment verification; quality is informational and does not change ranking.';
|
|
370
|
+
}
|
|
371
|
+
results.sort((a, b) => b.relevance - a.relevance || (a.where === 'hub') - (b.where === 'hub') || Boolean(a.sale) - Boolean(b.sale) || String(a.ref ?? a.id).localeCompare(String(b.ref ?? b.id)));
|
|
372
|
+
for (const p of results) p.ranking = 'Text relevance first (name 3, id 2, summary 1 per matching word); ties: local first, then free before paid, then reference. Quality is informational.';
|
|
342
373
|
return { ok: true, command: 'parts find', query: words.join(' '), filter: { kind, tag, license, builds }, hub: hubState, results, searched: { hub: found.length, here: here.length } };
|
|
343
374
|
}
|
package/lib/parts-tools.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { purchasePortal } from './parts-purchase.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* GAME PARTS AS CHAT TOOLS (parts/PARTS.md): parts_find, part_add, part_new and part_share, named and worded like
|
|
3
4
|
* the asset tools beside them (assets_find, asset_add). Four tools are the whole surface: a newer version of a part
|
|
@@ -11,7 +12,7 @@ import { addPart, checkParts, findParts, sharePart } from './parts-store.mjs';
|
|
|
11
12
|
import { partsLines } from './parts-cli.mjs';
|
|
12
13
|
|
|
13
14
|
/** What the planning and game-making tools say about parts, in one place so a test can hold them to it. */
|
|
14
|
-
export const PARTS_FIRST = 'Before writing any system from scratch (camera, movement, bots, pickups, effects, sound, UI, environment), look for what exists, two different things: (1) the @homie-rocks/* packages on npm have the general mechanism (camera, input, audio, effects): use them; (2) `parts find` (the parts_find tool) searches the pieces of
|
|
15
|
+
export const PARTS_FIRST = 'Before writing any system from scratch (camera, movement, bots, pickups, effects, sound, UI, environment), look for what exists, two different things: (1) the @homie-rocks/* packages on npm have the general mechanism (camera, input, audio, effects): use them; (2) `parts find` (the parts_find tool) searches the pieces of games, apps, music and video other studios shared: a creature from one game, a level from another, a bot brain from a third, each brought in with part_add. Record in the project notes or CODEX what came from where: which packages, which parts and the work and studio each came from, and what was written here and why. After building a reusable piece, offer the person to make it a part (part_new lifts it out of the game) and, only if they ask, to share it (part_share).';
|
|
15
16
|
|
|
16
17
|
/** A fix-it line that names the plumbing, said as the chat tool that does the same. */
|
|
17
18
|
export const inChat = (t) => String(t).replace(/homie-studio parts add (\S+?)(?:@(\S+))?(?: --game (\S+))?(?=[\s).,;]|$)/g, (m, ref, v, g) => `part_add { "part": "${ref}${v ? `@${v}` : ''}"${g ? `, "game": "${g}"` : ''} }`);
|
|
@@ -24,36 +25,38 @@ export function partsToolDefs(ctx, h) {
|
|
|
24
25
|
{
|
|
25
26
|
name: 'parts_find', title: 'Find game parts',
|
|
26
27
|
description: 'Search the pieces of real games that studios shared (a creature, a level generator, a chase camera, a bot brain, a pickup mechanic, an audio pack), and this studio\'s own parts. The first move when planning or building a game: run it for each system the game needs before writing one from scratch, so a new game can be made with parts from several games. Each result has its licence, the game and studio it came from, its costs, what it builds on and what part_add takes. If the catalogue cannot be reached it says so (that is not "nothing exists"). Homie\'s @homie-rocks/* packages are not parts and are not listed here: they come from npm.',
|
|
27
|
-
inputSchema: { type: 'object', properties: { query: str('Words for what the game needs: "chase camera", "pickups", "cave"'), kind: { type: 'string',
|
|
28
|
+
inputSchema: { type: 'object', properties: { query: str('Words for what the game needs: "chase camera", "pickups", "cave"'), kind: { type: 'string', description: 'Only this kind of part' }, tag: str('Only parts with this tag'), license: str('Only this licence (an SPDX identifier, e.g. CC0-1.0)'), builds: str('Only parts that build on this: a package ("@homie-rocks/camera") or a skeleton ("humanoid-v1")'), ...STUDIO_ARG } },
|
|
28
29
|
annotations: { title: 'Find game parts', ...RO, openWorldHint: true },
|
|
29
30
|
run: async (a) => {
|
|
30
31
|
const root = ctx.root(a.studio, { need: false });
|
|
31
32
|
const r = await findParts(root, String(a.query ?? ''), { kind: a.kind ?? null, tag: a.tag ?? null, license: a.license ?? null, builds: a.builds ?? null, fetch: net().fetch, hub: ctx.directory ?? null });
|
|
32
|
-
return ok([text(r), '', r.results.length ? 'Tell the person what you found and which
|
|
33
|
+
return ok([text(r), '', r.results.length ? 'Tell the person what you found and which work and studio each piece is from, with your pick; part_add brings one in. Write in the project notes or CODEX which parts it uses and what you wrote yourself and why.' : 'Nothing fits: write it, and say so in the project notes or CODEX. If it turns out reusable, offer to make it a part (part_new).'].join('\n'), { kind: 'parts', ...r });
|
|
33
34
|
},
|
|
34
35
|
},
|
|
35
36
|
{
|
|
36
|
-
name: 'part_add', title: 'Add a
|
|
37
|
-
description: 'Bring a part another studio shared into this studio, for a game: one exact version is fetched (the latest unless the part names one, "<studio site>/<part id>@1.2.0"), every file is checked against its SHA-256, and it is copied into parts/ where the studio owns and tunes it. The game credits it (its licence and who made it, on the game\'s credits page), and the packages it builds on are installed by npm. For a part that is already here it brings the newer version: what changed is shown, the studio\'s tuning is kept, and a file the studio edited is never replaced unless overwrite is true (ask the person first). The result says plainly when the licences of the game\'s parts cannot be combined, and gives the one import line.',
|
|
38
|
-
inputSchema: { type: 'object', properties: { part: str('"<studio site>/<part id>", as parts_find gives it; "@<version>" for one version'), game: str('The game that will use it (its credits get the line)'), overwrite: { type: 'boolean', description: 'For a newer version: also replace files this studio edited. Only after the person agreed' }, ...STUDIO_ARG }, required: ['part'] },
|
|
39
|
-
annotations: { title: 'Add a
|
|
37
|
+
name: 'part_add', title: 'Add a studio part',
|
|
38
|
+
description: 'Bring a part another studio shared into this studio, for a game or app, music or video: one exact version is fetched (the latest unless the part names one, "<studio site>/<part id>@1.2.0"), every file is checked against its SHA-256, and it is copied into parts/ where the studio owns and tunes it. The game credits it (its licence and who made it, on the game\'s credits page), and the packages it builds on are installed by npm. For a part that is already here it brings the newer version: what changed is shown, the studio\'s tuning is kept, and a file the studio edited is never replaced unless overwrite is true (ask the person first). The result says plainly when the licences of the game\'s parts cannot be combined, and gives the one import line.',
|
|
39
|
+
inputSchema: { type: 'object', properties: { part: str('"<studio site>/<part id>", as parts_find gives it; "@<version>" for one version'), game: str('The game or app that will use it (its credits get the line)'), approve: str('The exact quote hash returned by part_add, ONLY with owner or wallet authority covering its exact price, currency, quantity, recurrence and terms'), wallet: { type: 'string', enum: ['link-cli', 'tempo', 'purl'], description: 'The person’s installed wallet client; its authority and limits govern payment' }, quantity: { type: 'integer', minimum: 1, description: 'Quantity under the seller’s licence terms' }, offline: { type: 'boolean', description: 'Restore the verified private purchase backup without the seller; revocation status is only last known' }, manage: { type: 'boolean', description: 'Return the purchase Stripe portal link to manage or cancel a subscription' }, overwrite: { type: 'boolean', description: 'For a newer version: also replace files this studio edited. Only after the person agreed' }, ...STUDIO_ARG }, required: ['part'] },
|
|
40
|
+
annotations: { title: 'Add a studio part', ...RW, openWorldHint: true },
|
|
40
41
|
run: async (a) => {
|
|
41
42
|
const root = ctx.root(a.studio);
|
|
43
|
+
if (a.manage) { const r = await purchasePortal(root, String(a.part ?? ''), { game: a.game ?? null, ...net() }); return ok(r.message, { kind: 'part', ...r }); }
|
|
42
44
|
const said = [];
|
|
43
|
-
const r = await addPart(root, String(a.part ?? ''), { game: a.game ? String(a.game) : null, overwrite: a.overwrite === true, say: (l) => said.push(l), ...net() });
|
|
45
|
+
const r = await addPart(root, String(a.part ?? ''), { game: a.game ? String(a.game) : null, overwrite: a.overwrite === true, approve: a.approve ?? null, quantity: a.quantity ?? 1, offline: a.offline === true, wallet: a.wallet, say: (l) => said.push(l), ...net() });
|
|
46
|
+
if (!r.ok && r.purchase) return ok(r.why, { kind: 'part', ...r, awaitingPurchase: true });
|
|
44
47
|
if (!r.ok) return fail(`Not added: ${inChat(r.why)}`, { kind: 'part', ...r });
|
|
45
48
|
const next = [];
|
|
46
49
|
if (r.import) next.push(`In the game: import … from '${r.import}', then build. Its part.json says what it takes ("contract") and its tuning.json holds the values to tune.`);
|
|
47
50
|
for (const n of r.needs) next.push(`It needs another part, ${n.ref}: part_add { "part": "${n.ref}"${a.game ? `, "game": "${a.game}"` : ''} }.`);
|
|
48
51
|
if (r.licences.some((c) => c.level === 'conflict')) next.push('The licences cannot be combined (above): tell the person plainly before building on it.');
|
|
49
52
|
if (r.packages.some((p) => p.state === 'pinned' || p.state === 'failed')) next.push('A package it builds on is not in place (above): the part will not build until it is.');
|
|
50
|
-
return ok([...said, text(r), ...next, 'Write in the
|
|
53
|
+
return ok([...said, text(r), ...next, 'Write in the project notes or CODEX that it uses this part and which work and studio it came from.'].join('\n'), { kind: 'part', ...r });
|
|
51
54
|
},
|
|
52
55
|
},
|
|
53
56
|
{
|
|
54
57
|
name: 'part_new', title: 'Make a shared part',
|
|
55
|
-
description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game
|
|
56
|
-
inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string',
|
|
58
|
+
description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game or app id) and files (paths inside that experience: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
|
|
59
|
+
inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game or app to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ or apps/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
|
|
57
60
|
annotations: { title: 'Make a shared part', ...RW },
|
|
58
61
|
run: async (a) => {
|
|
59
62
|
const root = ctx.root(a.studio);
|
|
@@ -64,7 +67,7 @@ export function partsToolDefs(ctx, h) {
|
|
|
64
67
|
},
|
|
65
68
|
{
|
|
66
69
|
name: 'part_share', title: 'Share a part',
|
|
67
|
-
description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
|
|
70
|
+
description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and optional sale and licenseTerms fields in part.json for a paid licence, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
|
|
68
71
|
inputSchema: { type: 'object', properties: { id: str('The part\'s id'), share: { type: 'boolean', description: 'true: share it; false: stop sharing it; left out: only check what stands in the way' }, license: str('The licence the person picked, an SPDX identifier (set before sharing)'), attribution: str('Who a game using it should credit'), ...STUDIO_ARG }, required: ['id'] },
|
|
69
72
|
annotations: { title: 'Share a part', ...RW },
|
|
70
73
|
run: async (a) => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Private R2 releases. Upload and verify before a deploy exposes the public offer. */
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { listOwnParts, packedDir, packedVersions, publicPart, readPart, sha256, verifyFiles } from './parts.mjs';
|
|
6
|
+
import { objectKey, releaseKey } from "../worker/parts-sale.mjs";
|
|
7
|
+
|
|
8
|
+
export function paidReleases(root) {
|
|
9
|
+
return listOwnParts(root).flatMap(({ id }) => packedVersions(root, id).map((version) => {
|
|
10
|
+
const dir = packedDir(root, id, version); const part = readPart(dir);
|
|
11
|
+
return part.sale ? { dir, part: publicPart({ ...part, share: true }) } : null;
|
|
12
|
+
}).filter(Boolean));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The uploader uses the same Wrangler execution and read-back hashing as mediaMove. */
|
|
16
|
+
export async function uploadPaidParts(root, { bucket, put, hash, log = () => {} }) {
|
|
17
|
+
const releases = paidReleases(root);
|
|
18
|
+
if (!releases.length) return { releases: 0, files: 0 };
|
|
19
|
+
if (!bucket) throw new Error('Paid parts need private R2 storage. Set up storage with homie-studio storage add before deploying. Never enable a public bucket domain.');
|
|
20
|
+
const temp = mkdtempSync(join(tmpdir(), 'homie-parts-upload-')); const seen = new Set();
|
|
21
|
+
try {
|
|
22
|
+
const upload = async (key, path, expected) => {
|
|
23
|
+
const before = await hash(`${bucket}/${key}`);
|
|
24
|
+
if (before.code === 0 && before.sha256 === expected.sha256 && before.bytes === expected.bytes) return;
|
|
25
|
+
if (before.code === 0 && key.startsWith('paid-parts/releases/')) throw new Error(`${key} already exists with different terms. A release is immutable`);
|
|
26
|
+
const sent = await put(`${bucket}/${key}`, path);
|
|
27
|
+
if (sent.code !== 0) throw new Error(`Paid part upload failed for ${key}. Nothing may be offered until storage is verified`);
|
|
28
|
+
const got = await hash(`${bucket}/${key}`);
|
|
29
|
+
if (got.code !== 0 || got.sha256 !== expected.sha256 || got.bytes !== expected.bytes) throw new Error(`Paid part upload failed integrity verification for ${key}`);
|
|
30
|
+
};
|
|
31
|
+
for (const { dir, part } of releases) {
|
|
32
|
+
if (!verifyFiles(dir, part).ok) throw new Error(`Packed part ${part.id} ${part.version} no longer matches its files`);
|
|
33
|
+
for (const f of part.files) {
|
|
34
|
+
if (seen.has(f.sha256)) continue;
|
|
35
|
+
await upload(objectKey(f.sha256), join(dir, f.path), f); seen.add(f.sha256);
|
|
36
|
+
}
|
|
37
|
+
const bytes = Buffer.from(JSON.stringify(part)); const file = join(temp, 'part.json'); writeFileSync(file, bytes);
|
|
38
|
+
await upload(releaseKey(part.id, part.version), file, { sha256: sha256(bytes), bytes: bytes.length });
|
|
39
|
+
log(`verified private part ${part.id} ${part.version} in R2`);
|
|
40
|
+
}
|
|
41
|
+
return { releases: releases.length, files: seen.size };
|
|
42
|
+
} finally { rmSync(temp, { recursive: true, force: true }); }
|
|
43
|
+
}
|
package/lib/parts.mjs
CHANGED
|
@@ -20,12 +20,17 @@ import { experienceDir, experienceFile } from './studio.mjs';
|
|
|
20
20
|
* NOTHING HERE IS A PACKAGE MANAGER: packages are npm's (lib/parts-store.mjs hands them to npm), and a part is copied
|
|
21
21
|
* in and then the studio's own. There are no version ranges, no resolver and no lockfile of Homie's.
|
|
22
22
|
*/
|
|
23
|
+
import { canonicalJson } from '../worker/referrals.mjs';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const parseSpdx = (value) => require('spdx-expression-parse')(value);
|
|
27
|
+
import { saleProblems } from "../worker/parts-sale.mjs";
|
|
23
28
|
import { createHash } from 'node:crypto';
|
|
24
29
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
25
30
|
import { dirname, join, relative } from 'node:path';
|
|
26
31
|
|
|
27
32
|
export const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
28
|
-
export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template']);
|
|
33
|
+
export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template', 'app', 'music', 'video']);
|
|
29
34
|
export const NETPLAY_KINDS = Object.freeze(['host-authoritative', 'replicated', 'local']);
|
|
30
35
|
export const PROVENANCE = Object.freeze(['original', 'generated', 'imported']);
|
|
31
36
|
export const PIVOTS = Object.freeze(['feet', 'centre', 'base', 'origin']);
|
|
@@ -107,7 +112,10 @@ export function licenseOfPart(value) {
|
|
|
107
112
|
const id = typeof value === 'string' ? value.trim() : '';
|
|
108
113
|
// Not an identifier at all; a word people write instead of one (cc0, proprietary); or a real
|
|
109
114
|
// identifier in the wrong case (SPDX is case-sensitive, and "mit" must not pass as some unknown licence).
|
|
110
|
-
if (!id
|
|
115
|
+
if (!id) return null;
|
|
116
|
+
if (!SPDX_SHAPE.test(id)) {
|
|
117
|
+
try { parseSpdx(id); return { id, class: 'unrecognised', credit: true, asks: 'read this SPDX expression and its licence texts; no alternative was selected', known: false }; } catch { return null; }
|
|
118
|
+
}
|
|
111
119
|
const cased = Object.values(CLASSES).flatMap((c) => c.ids).find((k) => k.toLowerCase() === id.toLowerCase());
|
|
112
120
|
if (cased ? cased !== id : Object.hasOwn(NOT_SPDX, id.toLowerCase())) return null;
|
|
113
121
|
for (const [name, c] of Object.entries(CLASSES)) if (c.ids.includes(id)) return { id, class: name, credit: c.credit, asks: c.asks, known: true };
|
|
@@ -231,7 +239,7 @@ export function checkPart(part, { id = null } = {}) {
|
|
|
231
239
|
if (!PART_ID.test(String(part.id ?? ''))) bad('id', `"${String(part.id ?? '').slice(0, 60)}" is not a part id`, 'lowercase letters, digits and hyphens, starting with a letter or digit (chase-camera)');
|
|
232
240
|
else if (id && part.id !== id) bad('id', `the folder is parts/${id} but part.json says "${part.id}"`, 'name them the same');
|
|
233
241
|
if (!clean(part.name, 80)) bad('name', 'it has no name', 'a few words a person would search for ("Third-person chase camera")');
|
|
234
|
-
if (!
|
|
242
|
+
if (!(typeof part.kind === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(part.kind))) bad('kind', part.kind === undefined ? 'it has no kind' : `"${String(part.kind).slice(0, 40)}" is not a kind of part`, 'a lowercase label using letters, digits and hyphens');
|
|
235
243
|
if (!isVersion(part.version)) bad('version', part.version === undefined ? 'it has no version' : `"${String(part.version).slice(0, 40)}" is not a version`, 'three numbers: 1.0.0');
|
|
236
244
|
// A part being written has no summary yet; one that is shared must (shareProblems).
|
|
237
245
|
if (!clean(part.summary)) warn('summary', 'it has no summary', 'one sentence: what it does and what it needs from a game');
|
|
@@ -239,6 +247,8 @@ export function checkPart(part, { id = null } = {}) {
|
|
|
239
247
|
if (part.share !== undefined && typeof part.share !== 'boolean') bad('share', 'it is neither true nor false', 'false keeps the part private (the default); sharing the part sets it true');
|
|
240
248
|
if (part.license !== undefined && part.license !== null && part.license !== '' && !licenseOfPart(part.license)) bad('license', 'it is not a licence a stranger can rely on', licenceHint(part.license));
|
|
241
249
|
if (part.attribution !== undefined && typeof part.attribution !== 'string') bad('attribution', 'it is not text', 'the line a game using the part shows in its credits, or ""');
|
|
250
|
+
for (const problem of saleProblems(part)) bad('sale', problem);
|
|
251
|
+
if (part.licenseTerms !== undefined && !safePath(part.licenseTerms)) bad('licenseTerms', 'must be a file inside the part');
|
|
242
252
|
if (part.tags !== undefined) {
|
|
243
253
|
if (!Array.isArray(part.tags) || part.tags.some((t) => typeof t !== 'string' || !/^[a-z0-9][a-z0-9-]{0,29}$/.test(t))) bad('tags', 'a tag is not a lowercase word', 'up to 12 words such as "camera", "3d", "top-down"');
|
|
244
254
|
else if (part.tags.length > 12) warn('tags', 'more than 12 tags; the first 12 are used', null);
|
|
@@ -444,6 +454,24 @@ export function packPart(root, id, { bump = null } = {}) {
|
|
|
444
454
|
const dir = partDir(root, id);
|
|
445
455
|
let part = readPart(dir);
|
|
446
456
|
if (bump) { part.version = bumpVersion(part.version, bump); writePart(dir, part); }
|
|
457
|
+
// REUSE places custom licence texts under LICENSES and annotates binary files.
|
|
458
|
+
if (part.sale && part.licenseTerms && safePath(part.licenseTerms) && existsSync(join(dir, part.licenseTerms)) && !existsSync(packedDir(root, id, part.version))) {
|
|
459
|
+
const refs = [...new Set(String(part.license).match(/LicenseRef-[A-Za-z0-9.-]+/g) ?? [])];
|
|
460
|
+
if (refs.length > 1 && refs.some((ref) => !existsSync(join(dir, 'LICENSES', `${ref}.txt`)))) return { ok: false, command: 'parts pack', id, why: 'An expression with multiple custom licences needs each full text under LICENSES before packing' };
|
|
461
|
+
for (const ref of refs) {
|
|
462
|
+
const target = join(dir, 'LICENSES', `${ref}.txt`);
|
|
463
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
464
|
+
if (!existsSync(target)) cpSync(join(dir, part.licenseTerms), target);
|
|
465
|
+
}
|
|
466
|
+
if (!existsSync(join(dir, 'REUSE.toml'))) writeFileSync(join(dir, 'REUSE.toml'), `version = 1
|
|
467
|
+
|
|
468
|
+
[[annotations]]
|
|
469
|
+
path = ["**"]
|
|
470
|
+
precedence = "aggregate"
|
|
471
|
+
SPDX-FileCopyrightText = ${JSON.stringify(part.attribution || 'The selling studio')}
|
|
472
|
+
SPDX-License-Identifier = ${JSON.stringify(part.license)}
|
|
473
|
+
`);
|
|
474
|
+
}
|
|
447
475
|
part = writeHashes(dir);
|
|
448
476
|
const shape = checkPart(part, { id });
|
|
449
477
|
if (!shape.ok) return { ok: false, command: 'parts pack', id, why: `parts/${id} is not a part yet: ${shape.problems.filter((p) => p.level === 'refuse').map((p) => `${p.field}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).slice(0, 4).join('; ')}`, problems: shape.problems };
|
|
@@ -451,6 +479,9 @@ export function packPart(root, id, { bump = null } = {}) {
|
|
|
451
479
|
if (existsSync(out)) {
|
|
452
480
|
const was = readPart(out);
|
|
453
481
|
const same = JSON.stringify((was.files ?? []).map((f) => [f.path, f.sha256])) === JSON.stringify(part.files.map((f) => [f.path, f.sha256]));
|
|
482
|
+
if ((was.sale || part.sale) && canonicalJson(publicPart({ ...was, share: true })) !== canonicalJson(publicPart({ ...part, share: true }))) return { ok: false, command: 'parts pack', id, why: 'A paid release is immutable, including its description, price and licence. Give the change a new version.' };
|
|
483
|
+
const legalSame = JSON.stringify({ license: was.license, licenseTerms: was.licenseTerms, sale: was.sale }) === JSON.stringify({ license: part.license, licenseTerms: part.licenseTerms, sale: part.sale });
|
|
484
|
+
if (!legalSame) return { ok: false, command: 'parts pack', id, why: 'A packed version cannot change its price or licence terms. Give it a new version.' };
|
|
454
485
|
if (!same) return { ok: false, command: 'parts pack', id, version: part.version, why: `parts/${id} ${part.version} is already packed with different files, and a shared version never changes (another studio may have copied it). Give the change a new version: raise "version" in parts/${id}/part.json, then share it again.` };
|
|
455
486
|
// The same bytes: only the description may have moved (a summary, `share`), so the frozen copy takes it.
|
|
456
487
|
writePart(out, part);
|
|
@@ -566,7 +597,7 @@ function liftPlan(root, game, paths) {
|
|
|
566
597
|
*/
|
|
567
598
|
export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [], uses = [] } = {}) {
|
|
568
599
|
if (!PART_ID.test(String(id ?? ''))) return { ok: false, command: 'parts new', why: `"${id ?? ''}" is not a part id: lowercase letters, digits and hyphens (chase-camera)` };
|
|
569
|
-
if (!
|
|
600
|
+
if (!(typeof kind === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(kind))) return { ok: false, command: 'parts new', why: `"${kind}" is not a lowercase kind label` };
|
|
570
601
|
if (!Array.isArray(uses) || uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u))) return { ok: false, command: 'parts new', why: 'invalid intended uses' };
|
|
571
602
|
const dir = partDir(root, id);
|
|
572
603
|
if (existsSync(dir)) return { ok: false, command: 'parts new', why: `parts/${id} is there already` };
|
|
@@ -653,6 +684,19 @@ export function licenceIssues(items, { game = null } = {}) {
|
|
|
653
684
|
const add = (level, parts, problem, fix = null) => out.push({ level, parts, problem, fix });
|
|
654
685
|
const sa = new Map();
|
|
655
686
|
for (const x of items.filter((y) => isObj(y.part))) {
|
|
687
|
+
if (x.vendored && x.part.sale) {
|
|
688
|
+
const s = x.part.sale; const p = x.purchase;
|
|
689
|
+
if (!p) add('conflict', [x.ref], 'No purchase evidence is recorded for this paid part', 'add it through part_add');
|
|
690
|
+
else {
|
|
691
|
+
if (p.version !== x.part.version) add('conflict', [x.ref], 'This game has no recorded grant for the installed paid release', 'call part_add for this game and version to check its update rights');
|
|
692
|
+
if (p.scope === 'game' && game && p.game !== game.id) add('conflict', [x.ref], `This licence covers game ${p.game}, not ${game.id}`, 'buy a licence for this game');
|
|
693
|
+
if (p.paidUntil && p.paidUntil <= Date.now() && s.onExpiry === 'terminate') add('conflict', [x.ref], 'The subscription licence expired', 'renew through the seller');
|
|
694
|
+
if (['refunded', 'lost'].includes(p.status) && s.onRefund === 'terminate') add('conflict', [x.ref], 'This purchase was revoked under its refund terms', 'settle the licence before publishing');
|
|
695
|
+
add('note', [x.ref], `${p.mode} purchase; ${p.scope} scope${p.scope === 'seat' ? `, ${p.quantity} seats` : ''}; last checked ${p.checkedAt ?? 'unknown'}. Offline evidence cannot prove current refund status.`);
|
|
696
|
+
}
|
|
697
|
+
if (game?.sells && !s.commercialUse) add('conflict', [x.ref], 'This paid licence excludes commercial use', 'obtain commercial rights before selling');
|
|
698
|
+
add('note', [x.ref], `Read ${x.part.licenseTerms}: source ${s.source ? 'included' : 'not included'}, transfer ${s.transferable ? 'permitted under the written terms' : 'requires a new licence'}. Publishing a game does not grant standalone part redistribution rights.`);
|
|
699
|
+
}
|
|
656
700
|
const lic = licenseOfPart(x.part.license);
|
|
657
701
|
if (!lic) { if (x.vendored) add('conflict', [x.ref], 'it names no licence, so nothing says a game may use it', 'ask its studio, or use another part'); continue; }
|
|
658
702
|
if (lic.class === 'unrecognised') add('warn', [x.ref], `${lic.id} is not a licence the toolkit can reason about`, 'read it with the person before the game goes online');
|
|
@@ -677,7 +721,7 @@ export function creditLine(ref, entry) {
|
|
|
677
721
|
url: entry.page ?? null,
|
|
678
722
|
licence: lic?.id ?? null,
|
|
679
723
|
licenceUrl: lic?.known ? `https://spdx.org/licenses/${lic.id}.html` : null,
|
|
680
|
-
note: entry.from?.name || entry.from?.game ? `A part of ${entry.from.name ?? entry.from.game}, from ${ref.split('/')[0]}` : `A
|
|
724
|
+
note: entry.from?.name || entry.from?.game ? `A part of ${entry.from.name ?? entry.from.game}, from ${ref.split('/')[0]}` : `A studio part from ${ref.split('/')[0]}`,
|
|
681
725
|
part: ref,
|
|
682
726
|
};
|
|
683
727
|
}
|
package/lib/perf.mjs
CHANGED
|
@@ -194,6 +194,17 @@ export function renderCostOf(samples) {
|
|
|
194
194
|
return { samples: Math.max(calls.length, tris.length), drawCalls: sum(calls), triangles: sum(tris) };
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/** Presence in the last drawn frame is independent of its frame rate. Runs measure frame timing separately. */
|
|
198
|
+
export function gameReadiness() {
|
|
199
|
+
const p = window.__homiePort;
|
|
200
|
+
let self = null;
|
|
201
|
+
if (p && p.view !== 'board') {
|
|
202
|
+
const rows = p.rows(); const last = rows[rows.length - 1];
|
|
203
|
+
self = Boolean(last && Number.isFinite(last[1]) && Number.isFinite(last[2]));
|
|
204
|
+
}
|
|
205
|
+
return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
|
|
206
|
+
}
|
|
207
|
+
|
|
197
208
|
/** Open one browser of a run on the play page, instrumented, and wait until it is playable. */
|
|
198
209
|
async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
|
|
199
210
|
const dev = PERF_DEVICES[device];
|
|
@@ -243,12 +254,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
|
|
|
243
254
|
shell = await T(page.evaluate(() => { const s = window.__shell; const a = s?.arrival; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, arrival: a ? { phase: a.phase, lookMs: a.lookMs, liftedMs: a.liftedMs, by: a.by, mode: a.mode, explicitMs: a.explicitMs, lateMs: a.lateMs } : null } : null; }), 5000);
|
|
244
255
|
const now = Date.now();
|
|
245
256
|
if (seatedAt === null && shell?.room && Number.isInteger(shell.seat) && shell.role) seatedAt = now;
|
|
246
|
-
const g = await inGame(h,
|
|
247
|
-
const p = window.__homiePort;
|
|
248
|
-
let self = null;
|
|
249
|
-
if (p && p.view !== 'board') { const r = p.rows(p.now() - 400); const last = r[r.length - 1]; self = last ? Number.isFinite(last[1]) : false; }
|
|
250
|
-
return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
|
|
251
|
-
});
|
|
257
|
+
const g = await inGame(h, gameReadiness);
|
|
252
258
|
// The play page's arrival card (0.26.0) covers the game until the game says it is playable: not playable before.
|
|
253
259
|
const lifted = !shell?.arrival || shell.arrival.phase === 'done';
|
|
254
260
|
if (seatedAt !== null && g?.first !== null && g?.first !== undefined && lifted) {
|
|
@@ -651,7 +657,7 @@ function arrivalWords(summary, device, role, readyMs) {
|
|
|
651
657
|
function headlineOf(summary) {
|
|
652
658
|
const out = [];
|
|
653
659
|
for (const device of summary.devices) {
|
|
654
|
-
for (const role of ['host', 'replica']) {
|
|
660
|
+
for (const role of ['host', 'replica', 'replica-2']) {
|
|
655
661
|
const m = (k) => summary.metrics[`${device}.${role}.${k}`]?.median;
|
|
656
662
|
if (m('frame.p50') === undefined) continue;
|
|
657
663
|
out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; first look at ${m('load.look') ?? '?'} ms, playable (control-ready) at ${m('load.playable') ?? '?'} ms${arrivalWords(summary, device, role, m('load.ready'))}; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB; fetched before playable (measured) ${m('load.prePlayKb') ?? '?'} KB; renderer ${m('render.calls') !== undefined ? `${m('render.calls')} draw calls, ${m('render.triangles') ?? '?'} triangles (measured while playing)` : 'cost not exposed by the game (not measured)'}`);
|
|
@@ -749,8 +755,8 @@ export function readRuns(dir) {
|
|
|
749
755
|
/** The metrics guarded by default: frame time and main-thread work of each role, time to playable and heap. */
|
|
750
756
|
export function defaultGuards(devices, goal) {
|
|
751
757
|
const g = [];
|
|
752
|
-
for (const d of devices) for (const role of ['host', 'replica']) g.push(`${d}.${role}.frame.p95`, `${d}.${role}.busy`, `${d}.${role}.load.playable`);
|
|
753
|
-
for (const d of devices) g.push(`${d}.
|
|
758
|
+
for (const d of devices) for (const role of ['host', 'replica', 'replica-2']) g.push(`${d}.${role}.frame.p95`, `${d}.${role}.busy`, `${d}.${role}.load.playable`);
|
|
759
|
+
for (const d of devices) for (const role of ['host', 'replica', 'replica-2']) g.push(`${d}.${role}.heap`, `${d}.${role}.net.kbOut`);
|
|
754
760
|
return g.filter((k) => k !== goal);
|
|
755
761
|
}
|
|
756
762
|
|
|
@@ -758,10 +764,11 @@ export function defaultGuards(devices, goal) {
|
|
|
758
764
|
* `homie-studio perf compare <before> <after>`: the goal metric and the guards, before against after, from the runs
|
|
759
765
|
* in two folders. A run that was blocked, taken on a busy computer or profiled is left out (and counted).
|
|
760
766
|
*/
|
|
761
|
-
export function perfCompare(beforeDir, afterDir, { goal =
|
|
767
|
+
export function perfCompare(beforeDir, afterDir, { goal = null, guards = null, also = [], min = 0.03, write = true } = {}) {
|
|
762
768
|
const A = readRuns(beforeDir);
|
|
763
769
|
const B = readRuns(afterDir);
|
|
764
770
|
if (!A.runs.length || !B.runs.length) return { ok: false, command: 'perf compare', why: `no runs in ${!A.runs.length ? beforeDir : afterDir} (folders that homie-studio perf wrote)` };
|
|
771
|
+
goal ??= A.runs.every((r) => r.room?.hosted === 'server') ? DEFAULT_GOAL.replace('.host.', '.replica.') : DEFAULT_GOAL;
|
|
765
772
|
const usable = (r) => !r.blocked && !r.loaded;
|
|
766
773
|
const left = { before: A.runs.filter((r) => !usable(r)).length, after: B.runs.filter((r) => !usable(r)).length };
|
|
767
774
|
const flat = (runs, sizes) => {
|