@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
package/lib/parts.mjs ADDED
@@ -0,0 +1,693 @@
1
+ /**
2
+ * GAME PARTS: THE FORMAT AND ITS CHECKER (parts/PARTS.md is the design; this file holds it to its word).
3
+ *
4
+ * Games build on each other by sharing parts: a piece of a game its studio chooses to share (a creature, a level, a
5
+ * bot brain), in whatever form suits the piece: code, assets, data, a JSON description, a tuned config, or a mix. It
6
+ * is `parts/<id>/part.json` and the files beside it. A part is a piece of a game, never the whole game.
7
+ *
8
+ * checkPart() the shape of a part.json, each problem in plain words with its fix
9
+ * hashDir() every file of a part with its SHA-256 and size (node:crypto): written by the tool, never by hand
10
+ * newPart() a part lifted out of one of the studio's games (the game still builds and plays the same)
11
+ * fileRights() whose each file is: its own record, a game's asset record of the same bytes, or the part's provenance
12
+ * shareProblems() why a part cannot be shared (no SPDX licence, credit owed and none named, a file of unknown rights)
13
+ * packPart() this version's exact bytes frozen in parts/_packed/<id>/<version>/: a shared version never changes
14
+ * sharedPartsOf() THE one answer to "what does this studio share": the site, the well-known file and the hub read it
15
+ * licenceIssues() the licences of the parts a game uses, and where they cannot be combined
16
+ *
17
+ * PRIVATE UNTIL SHARED: `share` defaults to false and is each part's own switch; nothing else decides it.
18
+ * NOTHING HERE IS A PACKAGE MANAGER: packages are npm's (lib/parts-store.mjs hands them to npm), and a part is copied
19
+ * in and then the studio's own. There are no version ranges, no resolver and no lockfile of Homie's.
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
23
+ import { dirname, join, relative } from 'node:path';
24
+
25
+ export const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
26
+ export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece']);
27
+ export const NETPLAY_KINDS = Object.freeze(['host-authoritative', 'replicated', 'local']);
28
+ export const PROVENANCE = Object.freeze(['original', 'generated', 'imported']);
29
+ export const PIVOTS = Object.freeze(['feet', 'centre', 'base', 'origin']);
30
+ export const PARTS_DIR = 'parts';
31
+ export const PACKED_DIR = '_packed';
32
+ export const VENDOR_DIR = '_vendor';
33
+ /** Where each brought-in part came from: a record, not a lock (nothing is resolved against it). */
34
+ export const ORIGINS_FILE = 'origins.json';
35
+ /** The one file of a part that belongs to whoever uses it: an update never overwrites it. */
36
+ export const TUNING_FILE = 'tuning.json';
37
+ export const TUNING_UPSTREAM = 'tuning.upstream.json';
38
+ /** Workers serves no asset over 25 MiB, so no part file may be; the rest keeps a part something a phone can fetch. */
39
+ export const LIMITS = Object.freeze({ fileBytes: 25 * 1024 * 1024, totalBytes: 100 * 1024 * 1024, files: 400, json: 256 * 1024 });
40
+
41
+ const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : '');
42
+ const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
43
+ export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
44
+
45
+ /* ------------------------------------------------------------------ versions and ranges */
46
+
47
+ const VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,40})?$/;
48
+ export const isVersion = (v) => VERSION.test(String(v ?? ''));
49
+ const nums = (v) => { const m = VERSION.exec(String(v ?? '')); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; };
50
+ /** -1, 0 or 1; a version that is not one sorts below every version. A pre-release tag is not ordered (1.0.0-a = 1.0.0). */
51
+ export function compareVersions(a, b) {
52
+ const x = nums(a) ?? [-1, -1, -1]; const y = nums(b) ?? [-1, -1, -1];
53
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
54
+ return 0;
55
+ }
56
+ export function bumpVersion(v, level = 'patch') {
57
+ const n = nums(v);
58
+ if (!n) throw new Error(`"${v}" is not a version (1.0.0)`);
59
+ return level === 'major' ? `${n[0] + 1}.0.0` : level === 'minor' ? `${n[0]}.${n[1] + 1}.0` : `${n[0]}.${n[1]}.${n[2] + 1}`;
60
+ }
61
+
62
+ /* ------------------------------------------------------------------ licences */
63
+
64
+ /**
65
+ * The SPDX identifiers the toolkit can reason about, by what each asks of a game that uses the part. Anything else
66
+ * well-formed is accepted and reported as one a person has to read: it never passes silently as compatible.
67
+ */
68
+ const CLASSES = {
69
+ 'public-domain': { ids: ['CC0-1.0', 'Unlicense', '0BSD'], credit: false, asks: 'nothing' },
70
+ permissive: { ids: ['MIT', 'Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'ISC', 'Zlib', 'CC-BY-4.0', 'CC-BY-3.0', 'OFL-1.1'], credit: true, asks: 'credit' },
71
+ 'weak-copyleft': { ids: ['MPL-2.0', 'LGPL-3.0-only', 'LGPL-3.0-or-later'], credit: true, asks: 'credit, and changes to the part\'s own files stay open' },
72
+ 'share-alike': { ids: ['CC-BY-SA-4.0', 'CC-BY-SA-3.0', 'GPL-3.0-only', 'GPL-3.0-or-later', 'AGPL-3.0-only', 'AGPL-3.0-or-later'], credit: true, asks: 'credit, and the combined game is offered under the same terms' },
73
+ 'non-commercial': { ids: ['CC-BY-NC-4.0', 'CC-BY-NC-SA-4.0'], credit: true, asks: 'credit, and no game that sells anything' },
74
+ 'no-derivatives': { ids: ['CC-BY-ND-4.0', 'CC-BY-NC-ND-4.0'], credit: true, asks: 'credit, and the part is used unchanged' },
75
+ };
76
+ const SPDX_SHAPE = /^(?:LicenseRef-[A-Za-z0-9.-]{1,60}|[A-Za-z0-9][A-Za-z0-9.+-]{0,63})$/;
77
+ /** Words people write that are not SPDX identifiers, with the one they most likely mean. */
78
+ const NOT_SPDX = { cc0: 'CC0-1.0', 'cc-by': 'CC-BY-4.0', 'cc-by-4.0': 'CC-BY-4.0', apache: 'Apache-2.0', 'apache 2.0': 'Apache-2.0', bsd: 'BSD-3-Clause', gpl: 'GPL-3.0-only', own: null, proprietary: null, 'all rights reserved': null, none: null };
79
+ /** { id, class, credit, asks, known } for an SPDX identifier; null when the value is not one. */
80
+ export function licenseOfPart(value) {
81
+ const id = typeof value === 'string' ? value.trim() : '';
82
+ // Not an identifier at all; a word people write instead of one (cc0, proprietary); or a real
83
+ // identifier in the wrong case (SPDX is case-sensitive, and "mit" must not pass as some unknown licence).
84
+ if (!id || !SPDX_SHAPE.test(id)) return null;
85
+ const cased = Object.values(CLASSES).flatMap((c) => c.ids).find((k) => k.toLowerCase() === id.toLowerCase());
86
+ if (cased ? cased !== id : Object.hasOwn(NOT_SPDX, id.toLowerCase())) return null;
87
+ 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 };
88
+ // NC and ND are read off the identifier even for a version the table does not list (CC-BY-NC-3.0).
89
+ const guess = /-NC(-|$)/.test(id) ? 'non-commercial' : /-ND(-|$)/.test(id) ? 'no-derivatives' : null;
90
+ return { id, class: guess ?? 'unrecognised', credit: true, asks: guess ? CLASSES[guess].asks : 'something the toolkit cannot reason about: read the licence yourself', known: false };
91
+ }
92
+ /** What to say about a licence value that is not an SPDX identifier. */
93
+ function licenceHint(value) {
94
+ const v = String(value ?? '').trim();
95
+ if (!v) return 'name one: an SPDX identifier such as CC0-1.0, CC-BY-4.0, MIT or Apache-2.0';
96
+ const meant = NOT_SPDX[v.toLowerCase()];
97
+ const cased = Object.values(CLASSES).flatMap((c) => c.ids).find((k) => k.toLowerCase() === v.toLowerCase());
98
+ if (cased) return `SPDX identifiers are case-sensitive: write "${cased}"`;
99
+ if (meant) return `"${v}" is not an SPDX identifier: write "${meant}"`;
100
+ if (Object.hasOwn(NOT_SPDX, v.toLowerCase())) return `"${v}" tells a stranger nothing they can rely on; a shared part needs an SPDX identifier (CC0-1.0, CC-BY-4.0, MIT, Apache-2.0, …)`;
101
+ return `"${v.slice(0, 40)}" is not an SPDX identifier (CC0-1.0, CC-BY-4.0, MIT, Apache-2.0, …)`;
102
+ }
103
+
104
+ /* ------------------------------------------------------------------ files and hashes */
105
+
106
+ /** A file's path inside a part: forward slashes, no dot segments, nothing a file system could read as elsewhere. */
107
+ export function safePath(p) {
108
+ const s = String(p ?? '');
109
+ if (!s || s.length > 200 || s.startsWith('/') || s.includes('\\') || s.includes('\0')) return false;
110
+ const segs = s.split('/');
111
+ return segs.length <= 8 && segs.every((x) => /^[A-Za-z0-9_][A-Za-z0-9._ @+-]{0,79}$/.test(x) && x !== '..' && !x.endsWith('.'));
112
+ }
113
+ /** Never part of a part: its own description, the upstream tuning an update leaves, anything hidden, dependencies. */
114
+ const skipName = (name, rel) => name.startsWith('.') || name === 'node_modules' || (!rel && (name === 'part.json' || name === TUNING_UPSTREAM));
115
+
116
+ /** Every file under a part's folder as [{ path, sha256, bytes }], sorted. Throws on a link or a path that is not safe. */
117
+ export function hashDir(dir) {
118
+ const out = [];
119
+ const walk = (rel) => {
120
+ for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
121
+ if (skipName(e.name, rel)) continue;
122
+ const path = rel ? `${rel}/${e.name}` : e.name;
123
+ if (e.isSymbolicLink()) throw new Error(`${path} is a link; a part holds real files only`);
124
+ if (e.isDirectory()) walk(path);
125
+ else if (e.isFile()) {
126
+ if (!safePath(path)) throw new Error(`"${path}" is not a file name a part can carry (letters, digits, . _ - + @ and spaces; no leading dot)`);
127
+ const bytes = readFileSync(join(dir, path));
128
+ out.push({ path, sha256: sha256(bytes), bytes: bytes.length });
129
+ }
130
+ }
131
+ };
132
+ walk('');
133
+ return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
134
+ }
135
+
136
+ /**
137
+ * The files on disk against the part's `files`: { ok, changed, missing, extra }. `skipTuning`: a brought-in part's
138
+ * tuning.json is its user's to change, so it is not compared.
139
+ */
140
+ export function verifyFiles(dir, part, { skipTuning = false } = {}) {
141
+ const have = new Map(hashDir(dir).map((f) => [f.path, f]));
142
+ const want = new Map((Array.isArray(part.files) ? part.files : []).map((f) => [f.path, f]));
143
+ const changed = []; const missing = []; const extra = [];
144
+ for (const [path, f] of want) {
145
+ if (skipTuning && path === TUNING_FILE) continue;
146
+ const h = have.get(path);
147
+ if (!h) missing.push(path);
148
+ else if (h.sha256 !== f.sha256 || h.bytes !== f.bytes) changed.push(path);
149
+ }
150
+ for (const path of have.keys()) if (!want.has(path) && !(skipTuning && path === TUNING_FILE)) extra.push(path);
151
+ return { ok: !changed.length && !missing.length && !extra.length, changed, missing, extra };
152
+ }
153
+
154
+ /* ------------------------------------------------------------------ reading and writing */
155
+
156
+ export const partsRoot = (root) => join(root, PARTS_DIR);
157
+ export const partDir = (root, id) => join(root, PARTS_DIR, id);
158
+
159
+ export function readPart(dir) {
160
+ const file = join(dir, 'part.json');
161
+ if (!existsSync(file)) throw new Error(`no part.json in ${dir}`);
162
+ let part;
163
+ try { part = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`${file} is not JSON: ${error.message}`); }
164
+ if (!isObj(part)) throw new Error(`${file} is not a part (an object)`);
165
+ return part;
166
+ }
167
+ export function writePart(dir, part) {
168
+ mkdirSync(dir, { recursive: true });
169
+ const file = join(dir, 'part.json');
170
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(part, null, 2)}\n`);
171
+ renameSync(`${file}.tmp`, file);
172
+ return part;
173
+ }
174
+
175
+ /** The studio's own parts: parts/<id>/part.json (folders starting with _ are the tool's: packed versions, brought-in parts). */
176
+ export function listOwnParts(root) {
177
+ const dir = partsRoot(root);
178
+ if (!existsSync(dir)) return [];
179
+ const out = [];
180
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
181
+ if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.') || !existsSync(join(dir, e.name, 'part.json'))) continue;
182
+ try { out.push({ id: e.name, dir: join(dir, e.name), part: readPart(join(dir, e.name)) }); } catch (error) { out.push({ id: e.name, dir: join(dir, e.name), part: null, error: error.message }); }
183
+ }
184
+ return out.sort((a, b) => a.id.localeCompare(b.id));
185
+ }
186
+ /** The packed versions of one of the studio's parts, oldest first. */
187
+ export function packedVersions(root, id) {
188
+ const dir = join(partsRoot(root), PACKED_DIR, id);
189
+ if (!existsSync(dir)) return [];
190
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && isVersion(e.name) && existsSync(join(dir, e.name, 'part.json'))).map((e) => e.name).sort(compareVersions);
191
+ }
192
+ export const packedDir = (root, id, version) => join(partsRoot(root), PACKED_DIR, id, version);
193
+
194
+ /* ------------------------------------------------------------------ the shape */
195
+
196
+ /**
197
+ * A part.json against the format: { ok, problems: [{ level: 'refuse' | 'warn', field, problem, fix }] }. `refuse`
198
+ * is something no tool will work with; `warn` is worth a person's look. Whether it can be SHARED is shareProblems().
199
+ */
200
+ export function checkPart(part, { id = null } = {}) {
201
+ const problems = [];
202
+ const bad = (field, problem, fix = null) => problems.push({ level: 'refuse', field, problem, fix });
203
+ const warn = (field, problem, fix = null) => problems.push({ level: 'warn', field, problem, fix });
204
+ if (!isObj(part)) return { ok: false, problems: [{ level: 'refuse', field: 'part.json', problem: 'it is not an object', fix: 'start one with part_new' }] };
205
+ 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)');
206
+ else if (id && part.id !== id) bad('id', `the folder is parts/${id} but part.json says "${part.id}"`, 'name them the same');
207
+ if (!clean(part.name, 80)) bad('name', 'it has no name', 'a few words a person would search for ("Third-person chase camera")');
208
+ if (!PART_KINDS.includes(part.kind)) bad('kind', part.kind === undefined ? 'it has no kind' : `"${String(part.kind).slice(0, 40)}" is not a kind of part`, `one of: ${PART_KINDS.join(', ')}`);
209
+ 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');
210
+ // A part being written has no summary yet; one that is shared must (shareProblems).
211
+ if (!clean(part.summary)) warn('summary', 'it has no summary', 'one sentence: what it does and what it needs from a game');
212
+ else if (String(part.summary).length > 200) warn('summary', 'it is over 200 characters; lists show the first 200', 'one sentence');
213
+ 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');
214
+ 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));
215
+ 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 ""');
216
+ if (part.tags !== undefined) {
217
+ 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"');
218
+ else if (part.tags.length > 12) warn('tags', 'more than 12 tags; the first 12 are used', null);
219
+ }
220
+ if (part.entry !== undefined && part.entry !== null && (!safePath(part.entry) || !/\.(ts|tsx|js|mjs|jsx)$/.test(part.entry))) bad('entry', `"${String(part.entry).slice(0, 80)}" is not a module inside the part`, 'a path such as src/index.ts');
221
+ if (part.files !== undefined) {
222
+ if (!Array.isArray(part.files)) bad('files', 'it is not a list', 'never written by hand: checking the part rewrites it');
223
+ else {
224
+ if (part.files.length > LIMITS.files) bad('files', `${part.files.length} files; a part holds at most ${LIMITS.files}`, 'split it into parts that require each other');
225
+ const seen = new Set(); let total = 0;
226
+ for (const f of part.files) {
227
+ if (!isObj(f) || !safePath(f.path)) { bad('files', `"${String(f?.path ?? '').slice(0, 80)}" is not a path inside the part`, 'no "..", no leading slash or dot'); continue; }
228
+ if (seen.has(f.path)) bad('files', `${f.path} is listed twice`, 'checking the part rewrites the list');
229
+ seen.add(f.path);
230
+ if (!/^[a-f0-9]{64}$/.test(String(f.sha256 ?? '')) || !Number.isInteger(f.bytes) || f.bytes < 0) bad('files', `${f.path} has no SHA-256 or size`, 'checking the part writes both');
231
+ else if (f.bytes > LIMITS.fileBytes) bad('files', `${f.path} is ${Math.round(f.bytes / 1048576)} MB; the site serves no file over 25 MB`, 'make it smaller or leave it out');
232
+ total += Number(f.bytes) || 0;
233
+ if (f.rights !== undefined && f.rights !== 'unknown' && !(isObj(f.rights) && licenseOfPart(f.rights.license))) bad('files', `${f.path} "rights" is neither "unknown" nor { "license": "<SPDX>" }`, licenceHint(f.rights?.license));
234
+ }
235
+ if (total > LIMITS.totalBytes) bad('files', `the part is ${Math.round(total / 1048576)} MB; at most 100 MB`, 'split it');
236
+ if (part.entry && !seen.has(part.entry)) bad('entry', `${part.entry} is not one of the part's files`, 'check the path; checking the part rewrites its file list');
237
+ }
238
+ }
239
+ if (part.preview !== undefined) {
240
+ if (!isObj(part.preview)) bad('preview', 'it is not an object', '{ "page": "preview/index.html", "image": "preview/cover.jpg" }');
241
+ else {
242
+ if (part.preview.page != null && (!safePath(part.preview.page) || !/\.html?$/.test(part.preview.page))) bad('preview.page', 'it is not an HTML file inside the part', 'preview/index.html');
243
+ if (part.preview.image != null && (!safePath(part.preview.image) || !/\.(png|jpe?g|webp|gif|svg)$/i.test(part.preview.image))) bad('preview.image', 'it is not a picture inside the part', 'preview/cover.jpg');
244
+ const listed = new Set((Array.isArray(part.files) ? part.files : []).map((f) => f?.path));
245
+ for (const k of ['page', 'image']) if (part.preview[k] && Array.isArray(part.files) && safePath(part.preview[k]) && !listed.has(part.preview[k])) bad(`preview.${k}`, `${part.preview[k]} is not one of the part's files`, 'check the path; checking the part rewrites its file list');
246
+ }
247
+ }
248
+ if (part.requires !== undefined) {
249
+ if (!isObj(part.requires)) bad('requires', 'it is not an object', '{ "packages": { "@homie-rocks/camera": "^0.2.0" }, "parts": ["other-studio.example/some-part"] }');
250
+ else {
251
+ const pk = part.requires.packages;
252
+ if (pk !== undefined) {
253
+ if (!isObj(pk)) bad('requires.packages', 'it is not an object of package name to what npm should install', '{ "@homie-rocks/camera": "^0.2.0" }');
254
+ else for (const [name, range] of Object.entries(pk)) {
255
+ if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name)) bad('requires.packages', `"${name.slice(0, 60)}" is not a package's name`, '"@homie-rocks/camera"');
256
+ // What follows the name is npm's to read (it is handed to npm as <name>@<this>); all that is checked is that it can be nothing but a version spec.
257
+ if (typeof range !== 'string' || !/^[0-9A-Za-z.*^~<>=| -]{1,80}$/.test(range) || range.startsWith('-')) bad('requires.packages', `${name}: "${String(range).slice(0, 40)}" is not something npm installs by`, '"^0.2.0", as package.json would say it');
258
+ }
259
+ }
260
+ if (part.requires.parts !== undefined && requiredParts(part).bad.length) bad('requires.parts', `${requiredParts(part).bad.map((b) => `"${b}"`).slice(0, 3).join(', ')} does not name a part`, 'a list such as ["other-studio.example/some-part"]');
261
+ }
262
+ }
263
+ if (part.physical !== undefined) {
264
+ if (!isObj(part.physical)) bad('physical', 'it is not an object', '{ "units": "metres", "scale": 1, "pivot": "feet", "collision": "collision.json" }');
265
+ else {
266
+ if (part.physical.units !== undefined && part.physical.units !== 'metres') bad('physical.units', `"${String(part.physical.units).slice(0, 20)}": a part is measured in metres`, 'convert it and write "metres"');
267
+ if (part.physical.scale !== undefined && !(Number.isFinite(part.physical.scale) && part.physical.scale > 0)) bad('physical.scale', 'it is not a number above 0', '1 when the files are already in metres');
268
+ if (part.physical.pivot !== undefined && !PIVOTS.includes(part.physical.pivot)) bad('physical.pivot', `"${String(part.physical.pivot).slice(0, 20)}" is not a pivot`, `one of: ${PIVOTS.join(', ')}`);
269
+ if (part.physical.collision != null && (!safePath(part.physical.collision) || !/\.json$/.test(part.physical.collision))) bad('physical.collision', 'it is not a JSON file inside the part', 'collision.json, or null for none');
270
+ }
271
+ }
272
+ if (part.skeleton !== undefined) {
273
+ if (!isObj(part.skeleton)) bad('skeleton', 'it is not an object', '{ "rig": "humanoid-v1", "clips": ["idle", "run"] }');
274
+ else {
275
+ if (part.skeleton.rig != null && !/^[a-z0-9][a-z0-9._-]{0,47}$/.test(String(part.skeleton.rig))) bad('skeleton.rig', 'it is not a rig name', 'a lowercase name such as "humanoid-v1"');
276
+ if (part.skeleton.clips !== undefined && (!Array.isArray(part.skeleton.clips) || part.skeleton.clips.some((c) => typeof c !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/.test(c)))) bad('skeleton.clips', 'it is not a list of clip names', '["idle", "run", "jump"]');
277
+ }
278
+ }
279
+ if (part.contract !== undefined) {
280
+ if (!isObj(part.contract)) bad('contract', 'it is not an object', '{ "inputs": {}, "state": {}, "netplay": "host-authoritative" }');
281
+ else {
282
+ for (const k of ['inputs', 'state']) if (part.contract[k] !== undefined && !isObj(part.contract[k])) bad(`contract.${k}`, 'it is not an object of name to description', '{ "position": "where the player is, in metres" }');
283
+ if (part.contract.netplay !== undefined && !NETPLAY_KINDS.includes(part.contract.netplay)) bad('contract.netplay', `"${String(part.contract.netplay).slice(0, 40)}" is not a netplay contract`, `one of: ${NETPLAY_KINDS.join(', ')}`);
284
+ }
285
+ }
286
+ if (part.kind === 'mechanic' && !NETPLAY_KINDS.includes(part.contract?.netplay)) warn('contract.netplay', 'a mechanic does not say how it behaves in a room', `${NETPLAY_KINDS.join(', ')}: another game needs to know who decides`);
287
+ if (part.cost !== undefined) {
288
+ if (!isObj(part.cost)) bad('cost', 'it is not an object', '{ "triangles": 0, "drawCalls": 0, "textureMB": 0, "bytes": 0, "measuredOn": "…" }');
289
+ else for (const k of ['triangles', 'drawCalls', 'textureMB', 'bytes']) if (part.cost[k] !== undefined && !(Number.isFinite(part.cost[k]) && part.cost[k] >= 0)) bad(`cost.${k}`, 'it is not a number of 0 or more', 'leave out a cost nobody measured');
290
+ }
291
+ if (part.from !== undefined && part.from !== null) {
292
+ if (!isObj(part.from) || (part.from.game != null && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(part.from.game)))) bad('from', 'it does not name the game the part came out of', '{ "game": "<the game\'s id>", "studio": "<the studio\'s name>" }');
293
+ }
294
+ if (part.provenance !== undefined) {
295
+ if (!isObj(part.provenance)) bad('provenance', 'it is not an object', '{ "source": "original" }');
296
+ else if (part.provenance.source !== undefined && !PROVENANCE.includes(part.provenance.source)) bad('provenance.source', `"${String(part.provenance.source).slice(0, 40)}" does not say where the part came from`, `one of: ${PROVENANCE.join(', ')}`);
297
+ }
298
+ return { ok: !problems.some((p) => p.level === 'refuse'), problems };
299
+ }
300
+
301
+ /** `<host>/<id>` (and an optional exact `@version`): { host, id, version, ref }, or null. A host is a site's name, optionally a port. */
302
+ export function parseRef(text) {
303
+ const m = /^([a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?(?::\d{1,5})?)\/([a-z0-9][a-z0-9-]{0,47})(?:@(.{1,60}))?$/.exec(String(text ?? '').trim().replace(/^https?:\/\//, ''));
304
+ if (!m) return null;
305
+ if (m[3] !== undefined && !isVersion(m[3])) return null;
306
+ return { host: m[1], id: m[2], version: m[3] ?? null, ref: `${m[1]}/${m[2]}` };
307
+ }
308
+
309
+ /**
310
+ * The other parts a part says it needs: `requires.parts`, a plain list of "<host>/<id>". They are NAMED to the
311
+ * person with what adds each; nothing is solved.
312
+ */
313
+ export function requiredParts(part) {
314
+ const raw = part?.requires?.parts;
315
+ const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];
316
+ const parts = []; const bad = [];
317
+ for (const item of list) { const r = typeof item === 'string' ? parseRef(item) : null; if (r) parts.push({ ref: r.ref, host: r.host, id: r.id }); else bad.push(String(item).slice(0, 60)); }
318
+ return { parts, bad };
319
+ }
320
+
321
+ /* ------------------------------------------------------------------ rights */
322
+
323
+ /**
324
+ * Every game's asset records by the SHA-256 of the file they are about (games/<id>/assets/manifest.json, what
325
+ * RIGHTS.md is written from): a part made from a game's model takes that model's licence record, not a guess.
326
+ */
327
+ export function assetRecords(root) {
328
+ const out = new Map();
329
+ const games = join(root, 'games');
330
+ if (!existsSync(games)) return out;
331
+ for (const e of readdirSync(games, { withFileTypes: true })) {
332
+ if (!e.isDirectory()) continue;
333
+ let m = null;
334
+ try { m = JSON.parse(readFileSync(join(games, e.name, 'assets', 'manifest.json'), 'utf8')); } catch { m = null; }
335
+ for (const a of Array.isArray(m?.assets) ? m.assets : []) for (const f of a.files ?? []) if (/^[a-f0-9]{64}$/.test(String(f.sha256 ?? ''))) out.set(f.sha256, { game: e.name, asset: a.id, kind: a.license?.kind ?? null, attribution: a.license?.attribution ?? null });
336
+ }
337
+ return out;
338
+ }
339
+ /** The asset-manifest licence kinds a file may be handed on under (lib/asset-manifest.mjs LICENSES, `redistribute`). */
340
+ const ASSET_SHAREABLE = new Set(['cc0', 'cc-by-4.0', 'cc-by-3.0', 'own', 'generated']);
341
+
342
+ /**
343
+ * Whose one file is: { known, why, from }. In order: the file's own `rights`; a game's asset record of the same
344
+ * bytes; the part's provenance. `known: false` is what stops a share.
345
+ */
346
+ export function fileRights(part, file, records = new Map()) {
347
+ if (file.rights === 'unknown') return { known: false, from: 'file', why: 'its rights are marked unknown' };
348
+ if (isObj(file.rights)) return licenseOfPart(file.rights.license) ? { known: true, from: 'file', license: file.rights.license } : { known: false, from: 'file', why: 'its "rights" names no SPDX licence' };
349
+ const rec = records.get(file.sha256);
350
+ if (rec) {
351
+ if (!rec.kind) return { known: false, from: 'asset', why: `it is ${rec.asset} of games/${rec.game}, which has no licence record` };
352
+ if (!ASSET_SHAREABLE.has(rec.kind)) return { known: false, from: 'asset', why: `it is ${rec.asset} of games/${rec.game}, recorded as "${rec.kind}": that licence does not let the file be handed on` };
353
+ return { known: true, from: 'asset', license: rec.kind };
354
+ }
355
+ const src = part.provenance?.source;
356
+ if (!PROVENANCE.includes(src)) return { known: false, from: 'part', why: 'the part does not say where it came from (provenance.source)' };
357
+ if (src === 'imported' && !clean(part.provenance.origin)) return { known: false, from: 'part', why: 'the part is imported and does not say from where (provenance.origin)' };
358
+ return { known: true, from: 'part', license: part.license ?? null };
359
+ }
360
+
361
+ /**
362
+ * Why a part cannot be shared, in plain words: [{ field, problem, fix }]; empty when it can. `dir` (its folder)
363
+ * also checks the files on disk against the hashes.
364
+ */
365
+ export function shareProblems(part, { dir = null, records = new Map() } = {}) {
366
+ const out = [];
367
+ const add = (field, problem, fix) => out.push({ field, problem, fix });
368
+ const shape = checkPart(part);
369
+ for (const p of shape.problems.filter((x) => x.level === 'refuse')) add(p.field, p.problem, p.fix);
370
+ const lic = licenseOfPart(part.license);
371
+ if (!clean(part.summary)) add('summary', 'it has no summary: nobody searching can tell what it is', 'one sentence: what it does and what it needs from a game');
372
+ if (!part.license) add('license', 'it has no licence: a stranger cannot tell what they may do with it', licenceHint(''));
373
+ else if (lic?.credit && lic.class !== 'unrecognised' && !clean(part.attribution)) add('attribution', `${lic.id} asks for credit, and the part names nobody to credit`, 'set "attribution" to the line a game should show (the studio\'s name, or who made it)');
374
+ if (!PROVENANCE.includes(part.provenance?.source)) add('provenance.source', 'it does not say where it came from', `set provenance.source: ${PROVENANCE.join(', ')}`);
375
+ if (!Array.isArray(part.files) || !part.files.length) add('files', 'its files are not hashed yet', 'checking the part rewrites them (part_share with nothing to change)');
376
+ else {
377
+ for (const f of part.files) {
378
+ if (!isObj(f)) continue;
379
+ const r = fileRights(part, f, records);
380
+ if (!r.known) add('files', `${f.path}: ${r.why}`, r.from === 'asset' ? 'leave the file out of the part, or replace it with one you may share' : r.from === 'file' ? `find out, then set its "rights" to { "license": "<SPDX>" } or leave the file out` : 'say where the part came from');
381
+ }
382
+ if (dir) {
383
+ const v = verifyFiles(dir, part);
384
+ if (!v.ok) add('files', `the files differ from the hashes (${[...v.changed.map((p) => `${p} changed`), ...v.missing.map((p) => `${p} is missing`), ...v.extra.map((p) => `${p} is not listed`)].slice(0, 6).join('; ')})`, 'checking the part rewrites them (part_share with nothing to change)');
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /* ------------------------------------------------------------------ writing hashes, packing */
391
+
392
+ /** Rewrites `files` (keeping each file's own `rights`) and `cost.bytes` from what is on disk. Returns the part. */
393
+ export function writeHashes(dir) {
394
+ const part = readPart(dir);
395
+ const rights = new Map((Array.isArray(part.files) ? part.files : []).filter((f) => isObj(f) && f.rights !== undefined).map((f) => [f.path, f.rights]));
396
+ part.files = hashDir(dir).map((f) => (rights.has(f.path) ? { ...f, rights: rights.get(f.path) } : f));
397
+ part.cost = { ...(isObj(part.cost) ? part.cost : {}), bytes: part.files.reduce((n, f) => n + f.bytes, 0) };
398
+ return writePart(dir, part);
399
+ }
400
+
401
+ /** A part.json as another studio may see it: no field whose name starts with `_`, at any depth. */
402
+ export function publicPart(part) {
403
+ const strip = (v) => (Array.isArray(v) ? v.map(strip) : isObj(v) ? Object.fromEntries(Object.entries(v).filter(([k]) => !k.startsWith('_')).map(([k, x]) => [k, strip(x)])) : v);
404
+ return strip(part);
405
+ }
406
+
407
+ /**
408
+ * `parts pack`: the hashes written, the part checked, and this version's exact bytes frozen in
409
+ * parts/_packed/<id>/<version>/. A version packed before with other bytes is refused: a packed version never
410
+ * changes, because a game somewhere locked its hashes. `bump` moves the version first.
411
+ */
412
+ export function packPart(root, id, { bump = null } = {}) {
413
+ const dir = partDir(root, id);
414
+ let part = readPart(dir);
415
+ if (bump) { part.version = bumpVersion(part.version, bump); writePart(dir, part); }
416
+ part = writeHashes(dir);
417
+ const shape = checkPart(part, { id });
418
+ 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 };
419
+ const out = packedDir(root, id, part.version);
420
+ if (existsSync(out)) {
421
+ const was = readPart(out);
422
+ const same = JSON.stringify((was.files ?? []).map((f) => [f.path, f.sha256])) === JSON.stringify(part.files.map((f) => [f.path, f.sha256]));
423
+ 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.` };
424
+ // The same bytes: only the description may have moved (a summary, `share`), so the frozen copy takes it.
425
+ writePart(out, part);
426
+ return { ok: true, command: 'parts pack', id, version: part.version, files: part.files.length, bytes: part.cost.bytes, already: true };
427
+ }
428
+ const tmp = `${out}.tmp`;
429
+ rmSync(tmp, { recursive: true, force: true });
430
+ mkdirSync(tmp, { recursive: true });
431
+ for (const f of part.files) { mkdirSync(dirname(join(tmp, f.path)), { recursive: true }); cpSync(join(dir, f.path), join(tmp, f.path)); }
432
+ writePart(tmp, part);
433
+ renameSync(tmp, out);
434
+ return { ok: true, command: 'parts pack', id, version: part.version, files: part.files.length, bytes: part.cost.bytes, already: false };
435
+ }
436
+
437
+ /**
438
+ * WHAT THIS STUDIO SHARES: the one answer, read by the build (so by the site and its well-known file, and through
439
+ * them the hub), by the deploy plan and by the tools. A part is in it when its own `share` is true and a version of
440
+ * it is packed; each entry is { id, versions, part } with `part` the latest packed version as another studio may
441
+ * see it. Nothing else anywhere decides what a studio shares.
442
+ */
443
+ export function sharedPartsOf(root) {
444
+ const out = [];
445
+ for (const o of listOwnParts(root)) {
446
+ if (o.part?.share !== true) continue;
447
+ const versions = packedVersions(root, o.id);
448
+ if (!versions.length) continue;
449
+ out.push({ id: o.id, versions, part: publicPart({ ...readPart(packedDir(root, o.id, versions.at(-1))), share: true }) });
450
+ }
451
+ return out;
452
+ }
453
+
454
+ /* ------------------------------------------------------------------ a new part */
455
+
456
+ const PREVIEW_HTML = (name) => `<!doctype html>
457
+ <html lang="en">
458
+ <head>
459
+ <meta charset="utf-8">
460
+ <meta name="viewport" content="width=device-width, initial-scale=1">
461
+ <title>${name.replace(/[<&]/g, '')}</title>
462
+ <style>html,body{margin:0;height:100%;background:#10131a;color:#e8ecf4;font:16px/1.4 system-ui,sans-serif}main{display:grid;place-items:center;height:100%;text-align:center;padding:16px;box-sizing:border-box}</style>
463
+ </head>
464
+ <body>
465
+ <main><p>${name.replace(/[<&]/g, '')}<br><small>Show the part working here: this page is what people try before they add it. It must run as served (relative imports only).</small></p></main>
466
+ </body>
467
+ </html>
468
+ `;
469
+
470
+ const MODULE = /\.(ts|tsx|js|mjs|jsx)$/;
471
+ const RESOLVE = ['', '.ts', '.tsx', '.js', '.mjs', '.jsx', '/index.ts', '/index.js', '/index.mjs'];
472
+ /** The relative specifiers a module imports or re-exports (static, side-effect and dynamic). */
473
+ function relativeImports(text) {
474
+ const out = new Set();
475
+ for (const re of [/\b(?:import|export)\b[^'"`;]*?\bfrom\s*['"](\.{1,2}\/[^'"]+)['"]/g, /\bimport\s*['"](\.{1,2}\/[^'"]+)['"]/g, /\bimport\s*\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g]) for (const m of text.matchAll(re)) out.add(m[1]);
476
+ return [...out];
477
+ }
478
+ const posix = (p) => p.split('\\').join('/');
479
+ function relSpec(fromFile, toFile) {
480
+ let r = posix(relative(dirname(fromFile), toFile));
481
+ if (!r.startsWith('.')) r = `./${r}`;
482
+ return r;
483
+ }
484
+
485
+ /**
486
+ * LIFTING A PART OUT OF A GAME (`parts new <id> --from <game> <paths…>`): parts are pieces of real games, so the
487
+ * usual way to make one is to take files a game already has. The files move into parts/<id>/src/ (keeping their
488
+ * layout under the folder they share), and each module's old path keeps a one-line module that re-exports the
489
+ * part's, so the game builds and behaves exactly as before and now imports the part. Anything that is not a module
490
+ * (a model, a picture, a JSON table) is copied: the game keeps its own.
491
+ *
492
+ * A lifted module that imports a file of the game's that is NOT being lifted is refused by name: moved, that import
493
+ * would point nowhere, and a part that reaches back into one game is not a part another game can use.
494
+ */
495
+ function liftPlan(root, game, paths) {
496
+ const gdir = join(root, 'games', game);
497
+ const files = [];
498
+ const walk = (rel) => {
499
+ const abs = join(gdir, rel);
500
+ if (!existsSync(abs)) throw new Error(`games/${game}/${rel} is not there`);
501
+ if (statSync(abs).isDirectory()) { for (const e of readdirSync(abs, { withFileTypes: true })) if (!e.name.startsWith('.') && e.name !== 'node_modules') walk(`${rel}/${e.name}`); } else files.push(rel);
502
+ };
503
+ for (const raw of paths) {
504
+ const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^games\/[^/]+\//, '').replace(/\/+$/, '');
505
+ if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside games/${game}`);
506
+ walk(rel);
507
+ }
508
+ if (!files.length) throw new Error(`name the files of games/${game} that become the part (src/creature.ts, or a folder)`);
509
+ // A part is a piece of a game, never the whole game: its entry, its page and its game.json stay the game's.
510
+ let entry = 'src/main.ts';
511
+ try { entry = JSON.parse(readFileSync(join(gdir, 'game.json'), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
512
+ const whole = files.filter((f) => f === entry || f === 'game.json' || f === 'index.html');
513
+ if (whole.length) throw new Error(`${whole.join(', ')} ${whole.length === 1 ? 'is' : 'are'} the game itself. A part is a piece of a game, never the whole game: name the files of one piece (the creature, the level generator, the camera).`);
514
+ // The folder they share (without a leading src/, which every part has of its own).
515
+ const dirs = files.map((f) => f.split('/').slice(0, -1));
516
+ let common = dirs[0];
517
+ for (const d of dirs) { let n = 0; while (n < common.length && n < d.length && common[n] === d[n]) n++; common = common.slice(0, n); }
518
+ const base = common.join('/');
519
+ const to = (f) => `src/${base ? f.slice(base.length + 1) : f}`;
520
+ const set = new Set(files);
521
+ const outside = [];
522
+ for (const f of files.filter((x) => MODULE.test(x))) {
523
+ for (const spec of relativeImports(readFileSync(join(gdir, f), 'utf8'))) {
524
+ const target = posix(join(dirname(f), spec));
525
+ const hit = RESOLVE.map((ext) => `${target}${ext}`).find((c) => existsSync(join(gdir, c)) && statSync(join(gdir, c)).isFile());
526
+ if (target.startsWith('..') || (hit && !set.has(hit))) outside.push(`${f} imports ${spec}`);
527
+ }
528
+ }
529
+ return { gdir, files, to, outside };
530
+ }
531
+
532
+ /**
533
+ * `parts new`: a private part with every field a sharer will need, so the gaps are visible from the first minute.
534
+ * `from` (a game's id) with `paths` lifts those files out of the game (above); without, the part starts empty.
535
+ */
536
+ export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [] } = {}) {
537
+ 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)` };
538
+ if (!PART_KINDS.includes(kind)) return { ok: false, command: 'parts new', why: `"${kind}" is not a kind of part: ${PART_KINDS.join(', ')}` };
539
+ const dir = partDir(root, id);
540
+ if (existsSync(dir)) return { ok: false, command: 'parts new', why: `parts/${id} is there already` };
541
+ const title = clean(name, 80) || id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
542
+ let entry = null; let lifted = null; let fromGame = null;
543
+ if (from) {
544
+ if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(join(root, 'games', from, 'game.json'))) return { ok: false, command: 'parts new', why: `there is no game "${from}" in this studio to lift a part out of` };
545
+ let plan;
546
+ try { plan = liftPlan(root, from, paths); } catch (error) { return { ok: false, command: 'parts new', why: error.message }; }
547
+ if (plan.outside.length) return { ok: false, command: 'parts new', why: `These files still reach into the rest of games/${from}: ${plan.outside.slice(0, 6).join('; ')}. A part has to stand without the game: lift those files too, or pass what they provide in as an argument first. Nothing was moved.`, outside: plan.outside };
548
+ mkdirSync(dir, { recursive: true });
549
+ lifted = { moved: [], copied: [] };
550
+ for (const f of plan.files) {
551
+ const src = join(plan.gdir, f); const dest = join(dir, plan.to(f));
552
+ mkdirSync(dirname(dest), { recursive: true });
553
+ if (!MODULE.test(f)) { cpSync(src, dest); lifted.copied.push(f); continue; }
554
+ const text = readFileSync(src, 'utf8');
555
+ writeFileSync(dest, text);
556
+ const spec = relSpec(src, dest);
557
+ const hasDefault = /\bexport\s+default\b|\bas\s+default\b/.test(text);
558
+ writeFileSync(src, `// This is now the "${id}" part (parts/${id}): the game imports it from there.\nexport * from '${spec}';\n${hasDefault ? `export { default } from '${spec}';\n` : ''}`);
559
+ lifted.moved.push(f);
560
+ }
561
+ const mods = plan.files.filter((f) => MODULE.test(f)).map(plan.to);
562
+ entry = mods.find((p) => /^src\/index\.[a-z]+$/.test(p)) ?? mods[0] ?? null;
563
+ let studio = null;
564
+ try { studio = JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')).name ?? null; } catch { studio = null; }
565
+ fromGame = { game: from, ...(studio ? { studio: clean(studio, 80) } : {}) };
566
+ } else {
567
+ mkdirSync(dir, { recursive: true });
568
+ if (['mechanic', 'ui', 'effect', 'shader', 'level-generator', 'bot-brain'].includes(kind)) {
569
+ mkdirSync(join(dir, 'src'), { recursive: true });
570
+ writeFileSync(join(dir, 'src', 'index.ts'), `/**\n * ${title}. What a game imports: \`import { create } from '@parts/${id}'\`.\n * Keep it free of the game's own globals: everything it needs comes in as an argument (see "contract" in part.json).\n */\nimport tuning from '../tuning.json';\n\nexport function create(options: Record<string, unknown> = {}) {\n return { tuning: { ...tuning, ...options } };\n}\n`);
571
+ entry = 'src/index.ts';
572
+ }
573
+ }
574
+ if (!existsSync(join(dir, TUNING_FILE))) writeFileSync(join(dir, TUNING_FILE), '{}\n');
575
+ mkdirSync(join(dir, 'preview'), { recursive: true });
576
+ if (!existsSync(join(dir, 'preview', 'index.html'))) writeFileSync(join(dir, 'preview', 'index.html'), PREVIEW_HTML(title));
577
+ writePart(dir, {
578
+ id, name: title, kind, version: '0.1.0', summary: '', license: '', attribution: '', share: false, tags: [],
579
+ ...(fromGame ? { from: fromGame } : {}),
580
+ ...(entry ? { entry } : {}),
581
+ files: [],
582
+ preview: { page: 'preview/index.html' },
583
+ requires: {},
584
+ ...(['character', 'rig', 'clips', 'environment', 'set-piece'].includes(kind) ? { physical: { units: 'metres', scale: 1, pivot: kind === 'environment' || kind === 'set-piece' ? 'origin' : 'feet', collision: null } } : {}),
585
+ ...(['character', 'rig', 'clips'].includes(kind) ? { skeleton: { rig: null, clips: [] } } : {}),
586
+ ...(kind === 'mechanic' || kind === 'bot-brain' ? { contract: { inputs: {}, state: {}, netplay: 'host-authoritative' } } : {}),
587
+ cost: {},
588
+ provenance: { source: 'original', notes: '' },
589
+ });
590
+ const part = writeHashes(dir);
591
+ return {
592
+ ok: true, command: 'parts new', id, dir: `${PARTS_DIR}/${id}`, kind, entry, files: part.files.map((f) => f.path), ...(lifted ? { from, lifted } : {}),
593
+ next: [
594
+ ...(lifted ? [`games/${from} imports it now (each lifted module's old path re-exports the part's); build the game to see it unchanged.`] : []),
595
+ `Write a one-sentence "summary" in parts/${id}/part.json, and what it needs from a game under "contract".`,
596
+ `Another game of this studio uses it with: import … from '@parts/${id}'`,
597
+ 'It is private. Sharing it needs a licence (an SPDX identifier such as CC-BY-4.0) and is live after the studio\'s next deploy.',
598
+ ],
599
+ };
600
+ }
601
+
602
+ /* ------------------------------------------------------------------ checking a set together */
603
+
604
+ const creditsFile = (root, game) => join(root, 'games', game, 'credits.json');
605
+
606
+ /**
607
+ * THE LICENCES OF THE PARTS A GAME USES, and where they cannot be combined: [{ level: 'conflict' | 'warn' | 'note',
608
+ * parts, problem, fix }]. Each item is { ref, part, vendored, changed } (lib/parts-store.mjs partItems). What is
609
+ * shown before a game with brought-in parts goes online, and when a part is added.
610
+ *
611
+ * conflict two parts under different share-alike licences in one game; a non-commercial part in a studio that
612
+ * sells; a no-derivatives part whose files were changed here; a part that names no licence; credit owed
613
+ * and missing from the game's credits
614
+ * warn a share-alike part (the game built with it has to be offered under the same terms: the person's
615
+ * call); a licence the toolkit cannot reason about
616
+ *
617
+ * `game`: { id, sells, credits[] } when the set is one game's (lib/parts-store.mjs gameFacts).
618
+ */
619
+ export function licenceIssues(items, { game = null } = {}) {
620
+ const out = [];
621
+ const add = (level, parts, problem, fix = null) => out.push({ level, parts, problem, fix });
622
+ const sa = new Map();
623
+ for (const x of items.filter((y) => isObj(y.part))) {
624
+ const lic = licenseOfPart(x.part.license);
625
+ 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; }
626
+ 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');
627
+ if (lic.class === 'share-alike') { if (!sa.has(lic.id)) sa.set(lic.id, []); sa.get(lic.id).push(x.ref); if (x.vendored) add('warn', [x.ref], `${lic.id} asks that a game built with it be offered under the same terms, its own source included`, 'the person decides: publish the game under that licence, or use a part under a permissive one'); }
628
+ if (lic.class === 'non-commercial' && game?.sells) add('conflict', [x.ref], `${lic.id} does not allow commercial use, and this studio has a shop`, 'use another part, or ask its studio for a commercial licence');
629
+ if (lic.class === 'non-commercial' && game && !game.sells) add('note', [x.ref], `${lic.id}: fine while nothing is sold; a shop would make it a conflict`, null);
630
+ if (lic.class === 'no-derivatives') add(x.changed?.length ? 'conflict' : 'warn', [x.ref], x.changed?.length ? `${lic.id} asks that it be used unchanged, and ${x.changed.join(', ')} ${x.changed.length === 1 ? 'was' : 'were'} changed here` : `${lic.id} asks that it be used unchanged: change its tuning.json, never its files`, x.changed?.length ? `adding ${x.ref} again puts the original files back` : null);
631
+ if (lic.class === 'weak-copyleft') add('note', [x.ref], `${lic.id}: changes to this part's own files must stay open; the rest of the game is unaffected`, null);
632
+ if (lic.credit && game && x.vendored && !game.credits.some((c) => c?.part === x.ref)) add('conflict', [x.ref], `${lic.id} asks for credit, and games/${game.id}/credits.json has no line for it`, 'building the game writes it');
633
+ }
634
+ if (sa.size > 1) add('conflict', [...sa.values()].flat(), `${[...sa.keys()].join(' and ')} each ask that the whole game be under their own terms; one game cannot be under both`, 'keep the parts of one of those licences');
635
+ const rank = { conflict: 0, warn: 1, note: 2 };
636
+ return out.sort((a, b) => rank[a.level] - rank[b.level]);
637
+ }
638
+
639
+ /** The credit line a game owes one brought-in part (games/<id>/credits.json `parts[]`, the landing's credits page). */
640
+ export function creditLine(ref, entry) {
641
+ const lic = licenseOfPart(entry.license);
642
+ return {
643
+ what: `${entry.name ?? ref.split('/').pop()} (${entry.kind ?? 'part'})`,
644
+ author: clean(entry.attribution, 120) || clean(entry.studio, 80) || ref.split('/')[0],
645
+ url: entry.page ?? null,
646
+ licence: lic?.id ?? null,
647
+ licenceUrl: lic?.known ? `https://spdx.org/licenses/${lic.id}.html` : null,
648
+ note: entry.from?.name || entry.from?.game ? `A part of ${entry.from.name ?? entry.from.game}, from ${ref.split('/')[0]}` : `A game part from ${ref.split('/')[0]}`,
649
+ part: ref,
650
+ };
651
+ }
652
+ /**
653
+ * games/<id>/credits.json `parts[]`, with one line per brought-in part the game uses (lines this wrote carry
654
+ * `"part": "<host>/<id>"`; every other line stays, as lib/asset-manifest.mjs syncCredits leaves these alone).
655
+ */
656
+ export function syncPartCredits(root, game, origins) {
657
+ const file = creditsFile(root, game);
658
+ let credits = {};
659
+ try { credits = JSON.parse(readFileSync(file, 'utf8')); } catch { credits = {}; }
660
+ const keep = (Array.isArray(credits.parts) ? credits.parts : []).filter((p) => typeof p?.part !== 'string');
661
+ const mine = Object.entries(origins.parts ?? {}).filter(([, e]) => (e.games ?? []).includes(game)).map(([ref, e]) => creditLine(ref, e));
662
+ if (!mine.length && !existsSync(file)) return null;
663
+ if (!existsSync(join(root, 'games', game))) return null;
664
+ const text = `${JSON.stringify({ ...credits, parts: [...keep, ...mine] }, null, 2)}\n`;
665
+ if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
666
+ return `games/${game}/credits.json`;
667
+ }
668
+
669
+ /* ------------------------------------------------------------------ where brought-in parts came from */
670
+
671
+ /**
672
+ * parts/origins.json: for each part brought in from another studio, where it came from (site, version, address), its
673
+ * licence and attribution, the game it came out of, the hashes it had when fetched, and which of this studio's games
674
+ * use it. A record to read, not a lock: nothing is resolved or pinned against it.
675
+ */
676
+ export function readOrigins(root) {
677
+ const file = join(partsRoot(root), ORIGINS_FILE);
678
+ if (!existsSync(file)) return { v: 1, parts: {} };
679
+ let doc;
680
+ try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`parts/${ORIGINS_FILE} is not JSON: ${error.message}`); }
681
+ if (doc?.v !== 1 || !isObj(doc.parts)) throw new Error(`parts/${ORIGINS_FILE} is not the record of brought-in parts (v 1)`);
682
+ return doc;
683
+ }
684
+ export function writeOrigins(root, doc) {
685
+ const file = join(partsRoot(root), ORIGINS_FILE);
686
+ mkdirSync(dirname(file), { recursive: true });
687
+ const sorted = { v: 1, parts: Object.fromEntries(Object.entries(doc.parts).sort(([a], [b]) => a.localeCompare(b))) };
688
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(sorted, null, 2)}\n`);
689
+ renameSync(`${file}.tmp`, file);
690
+ return sorted;
691
+ }
692
+ /** Where a brought-in part lives: parts/_vendor/<host>/<id>/ (a port's colon cannot be in a folder name everywhere). */
693
+ export const vendorDir = (root, ref) => { const r = parseRef(ref); return join(partsRoot(root), VENDOR_DIR, r.host.replace(':', '_'), r.id); };