@homie-rocks/studio 0.30.2 → 0.31.1

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 (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  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 +204 -124
  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 +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -2,18 +2,21 @@
2
2
  * EVERY ASSET'S PROVENANCE AND LICENCE: games/<id>/assets/manifest.json, one entry per model,
3
3
  * texture, sky or procedural part a game ships, with where it came from (procedural, library, generated, imported),
4
4
  * each step that made it (endpoint, receipt, price), the decision revisions it was made under (lib/decisions.mjs),
5
- * its licence and what a remix gets, its measurements and its review. An entry the game's code changes at runtime
5
+ * its licence, its measurements and its review. An entry the game's code changes at runtime
6
6
  * says so in `inGame` ({ heightM, repaint }: drawn at that height, repainted from style.json), so the lineup draws
7
- * it as the game does. From it:
7
+ * it as the game does. An entry may also say what the game does with it now (`usage`: cast, prop, environment, or
8
+ * unused for a file kept in the folder that the game no longer draws), so a lineup and a scene estimate are about the
9
+ * game, not the folder. From it:
8
10
  *
9
11
  * games/<id>/assets/RIGHTS.md what each file is, where it came from, its licence and what that allows, in plain words
10
12
  * games/<id>/credits.json a `parts[]` line per pack or attribution-required file (the landing's credits page)
11
13
  * licenceProblems() what `publish` and `assets check` refuse: a file with no licence record, a licence
12
- * that forbids a public source or a web game, attribution that never reached credits
13
- * servedAssets() /games/<id>/assets.json on the site: each redistributable file's address and SHA-256,
14
- * which `game remix` fetches
14
+ * that forbids a web game, attribution that never reached credits
15
15
  *
16
- * Both files are text: they travel in the remix source; the codex/ folder (decisions, the board) does not.
16
+ * A game's files reach its players and nobody else: no game is handed over whole (remix was retired). A piece of a
17
+ * game its studio shares is a part, and lib/parts.mjs decides what may be in one from the `redistribute` word here.
18
+ * A manifest written by an older toolkit carries `license.remix` on every entry (what a remix was to get); nothing
19
+ * reads it any more and nothing writes it.
17
20
  */
18
21
  import { createHash } from 'node:crypto';
19
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
@@ -26,27 +29,40 @@ export const RIGHTS_FILE = join('assets', 'RIGHTS.md');
26
29
  export const ROUTES = Object.freeze(['procedural', 'library', 'generated', 'imported', 'premium']);
27
30
  export const KINDS = Object.freeze(['prop', 'character', 'creature', 'kit', 'environment', 'texture', 'sky', 'clip']);
28
31
  export const ASSET_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
32
+ /**
33
+ * What the game does with an asset now (`usage` on its record): `cast` (the characters and creatures in play), `prop`
34
+ * (things in play: pickups, weapons, set pieces), `environment` (scenery, kits, ground textures, skies) or `unused`
35
+ * (a file still in the folder that the game no longer draws). A record that does not say is read by its kind, so a
36
+ * manifest written before this field is sorted the way it always was, with nothing marked unused.
37
+ */
38
+ export const USAGES = Object.freeze(['cast', 'prop', 'environment', 'unused']);
39
+ const USAGE_OF_KIND = { character: 'cast', creature: 'cast', prop: 'prop', kit: 'environment', environment: 'environment', texture: 'environment', sky: 'environment' };
40
+ /** An asset's usage: what its record says, else what its kind suggests. A clip library has none (it is drawn by no one). */
41
+ export function usageOf(a) {
42
+ if (a?.kind === 'clip') return null;
43
+ return USAGES.includes(a?.usage) ? a.usage : USAGE_OF_KIND[a?.kind] ?? 'prop';
44
+ }
29
45
 
30
46
  /**
31
- * Licence kinds: whether the file may sit in a public remix source and on Homie's mirror, whether
32
- * credit is required, what a remix gets by default, and whether a browser game may serve the file at all.
47
+ * Licence kinds: whether the file itself may be handed on (in a shared part, lib/parts.mjs, and on Homie's mirror),
48
+ * whether credit is required, and whether a browser game may serve the file at all.
33
49
  */
34
50
  export const LICENSES = Object.freeze({
35
- cc0: { label: 'CC0 1.0 (public domain)', spdx: 'CC0-1.0', url: 'https://creativecommons.org/publicdomain/zero/1.0/', redistribute: true, attribution: false, remix: 'include', web: true },
36
- 'cc-by-4.0': { label: 'CC BY 4.0', spdx: 'CC-BY-4.0', url: 'https://creativecommons.org/licenses/by/4.0/', redistribute: true, attribution: true, remix: 'include', web: true },
37
- 'cc-by-3.0': { label: 'CC BY 3.0', spdx: 'CC-BY-3.0', url: 'https://creativecommons.org/licenses/by/3.0/', redistribute: true, attribution: true, remix: 'include', web: true },
38
- own: { label: 'The studio\'s own work', spdx: null, url: null, redistribute: true, attribution: false, remix: 'include', web: true },
39
- generated: { label: 'Generated on the studio\'s own account', spdx: null, url: null, redistribute: true, attribution: false, remix: 'include', web: true },
40
- qal: { label: 'Quaternius Asset License', spdx: null, url: 'https://quaternius.com/license.html', redistribute: false, attribution: false, remix: 'reference', web: true },
41
- mixamo: { label: 'Mixamo (Adobe)', spdx: null, url: 'https://helpx.adobe.com/creative-cloud/faq/mixamo-faq.html', redistribute: false, attribution: false, remix: 'none', web: true },
42
- other: { label: 'Other (see notes)', spdx: null, url: null, redistribute: false, attribution: false, remix: 'none', web: true },
51
+ cc0: { label: 'CC0 1.0 (public domain)', spdx: 'CC0-1.0', url: 'https://creativecommons.org/publicdomain/zero/1.0/', redistribute: true, attribution: false, web: true },
52
+ 'cc-by-4.0': { label: 'CC BY 4.0', spdx: 'CC-BY-4.0', url: 'https://creativecommons.org/licenses/by/4.0/', redistribute: true, attribution: true, web: true },
53
+ 'cc-by-3.0': { label: 'CC BY 3.0', spdx: 'CC-BY-3.0', url: 'https://creativecommons.org/licenses/by/3.0/', redistribute: true, attribution: true, web: true },
54
+ own: { label: 'The studio\'s own work', spdx: null, url: null, redistribute: true, attribution: false, web: true },
55
+ generated: { label: 'Generated on the studio\'s own account', spdx: null, url: null, redistribute: true, attribution: false, web: true },
56
+ qal: { label: 'Quaternius Asset License', spdx: null, url: 'https://quaternius.com/license.html', redistribute: false, attribution: false, web: true },
57
+ mixamo: { label: 'Mixamo (Adobe)', spdx: null, url: 'https://helpx.adobe.com/creative-cloud/faq/mixamo-faq.html', redistribute: false, attribution: false, web: true },
58
+ other: { label: 'Other (see notes)', spdx: null, url: null, redistribute: false, attribution: false, web: true },
43
59
  });
44
- /** `eula:<name>` and `market:<listing>` are commercial licences: never in a public source; TurboSquid never on the web. */
60
+ /** `eula:<name>` and `market:<listing>` are commercial licences: never handed on; TurboSquid never on the web. */
45
61
  export function licenseInfo(kind) {
46
62
  const k = String(kind ?? '');
47
63
  if (LICENSES[k]) return { kind: k, ...LICENSES[k] };
48
- if (/^eula:[a-z0-9-]{1,40}$/.test(k)) return { kind: k, label: `Commercial licence (${k.slice(5)})`, spdx: null, url: null, redistribute: false, attribution: false, remix: 'none', web: k !== 'eula:turbosquid' };
49
- if (/^market:[a-z0-9:._-]{1,80}$/.test(k)) return { kind: k, label: `Bought (${k.slice(7)})`, spdx: null, url: null, redistribute: false, attribution: false, remix: 'none', web: true };
64
+ if (/^eula:[a-z0-9-]{1,40}$/.test(k)) return { kind: k, label: `Commercial licence (${k.slice(5)})`, spdx: null, url: null, redistribute: false, attribution: false, web: k !== 'eula:turbosquid' };
65
+ if (/^market:[a-z0-9:._-]{1,80}$/.test(k)) return { kind: k, label: `Bought (${k.slice(7)})`, spdx: null, url: null, redistribute: false, attribution: false, web: true };
50
66
  return null;
51
67
  }
52
68
 
@@ -95,6 +111,7 @@ export function checkEntry(e) {
95
111
  if (!ROUTES.includes(e?.route)) problems.push(`a route: ${ROUTES.join(', ')}`);
96
112
  if (!e?.license || !licenseInfo(e.license.kind)) problems.push('a licence record (license.kind: cc0, cc-by-4.0, own, generated, …)');
97
113
  else if (licenseInfo(e.license.kind).attribution && !clean(e.license.attribution)) problems.push(`${e.license.kind} requires an attribution line (license.attribution)`);
114
+ if (e?.usage !== undefined && e.usage !== null && !USAGES.includes(e.usage)) problems.push(`a usage: ${USAGES.join(', ')}`);
98
115
  if (e?.route !== 'procedural' && !(e?.files ?? []).some((f) => f.role === 'model' || f.role === 'texture' || f.role === 'sky' || f.role === 'clip')) problems.push('a shipped file (files[].role model, texture, sky or clip)');
99
116
  return { ok: problems.length === 0, problems };
100
117
  }
@@ -107,9 +124,15 @@ export function checkEntry(e) {
107
124
  export function recordAsset(root, id, entry) {
108
125
  const manifest = readManifest(root, id);
109
126
  const e = JSON.parse(JSON.stringify(entry));
110
- e.license = { remix: licenseInfo(e.license?.kind)?.remix ?? 'none', ...e.license };
111
127
  const check = checkEntry(e);
112
128
  if (!check.ok) throw new Error(`asset ${e.id ?? '?'} needs ${check.problems.join('; ')}`);
129
+ // One clip library a skeleton, unless the second says what it is. A second library registered for the same skeleton
130
+ // used to shadow the first silently: every verb of the original then read as missing and its previews drew nothing.
131
+ // Only a NEW record is a registration: baking more verbs into a library already here replaces its own record.
132
+ if (e.kind === 'clip' && e.rig?.skeleton && e.rig.supplemental !== true && !manifest.assets.some((a) => a.id === e.id)) {
133
+ const other = manifest.assets.find((a) => a.kind === 'clip' && a.id !== e.id && a.rig?.skeleton === e.rig.skeleton && a.rig?.supplemental !== true);
134
+ if (other) throw new Error(`asset ${e.id} would be a second clip library for the skeleton ${e.rig.skeleton}, which ${other.id} already is. Add its verbs to that library instead (homie-studio anim add ${id} <character> --verbs … --from <this file>), or record it as a supplemental library the game loads itself ("rig": { …, "supplemental": true })`);
135
+ }
113
136
  const doc = readDecisions(root, id);
114
137
  const under = underFor(e.kind, e.route);
115
138
  e.made = { ...(e.made ?? {}), under: { ...revisionsFor(doc, under), ...(e.made?.under ?? {}) } };
@@ -133,6 +156,14 @@ export function removeAsset(root, id, assetId) {
133
156
  const gone = manifest.assets.find((a) => a.id === assetId);
134
157
  if (!gone) return { removed: false, deleted: [], kept: [] };
135
158
  manifest.assets = manifest.assets.filter((a) => a.id !== assetId);
159
+ // Its pack's name, author and page go with it; a clip library still holding clips retargeted from that pack keeps
160
+ // them, so the credit for the animation outlives the model it came in with.
161
+ if (gone.from?.pack) {
162
+ for (const a of manifest.assets) {
163
+ if (a.kind !== 'clip' || !clipPacks(a).includes(gone.from.pack)) continue;
164
+ a.from = { ...(a.from ?? {}), packs: { ...(a.from?.packs ?? {}), [gone.from.pack]: a.from?.packs?.[gone.from.pack] ?? packDetails(gone) } };
165
+ }
166
+ }
136
167
  writeManifest(root, id, manifest);
137
168
  writeRights(root, id, manifest);
138
169
  syncCredits(root, id, manifest);
@@ -149,22 +180,35 @@ export function removeAsset(root, id, assetId) {
149
180
  return { removed: true, deleted, kept };
150
181
  }
151
182
 
183
+ /** `assets use <id> <asset> <usage>`: say what the game does with an asset now (USAGES); `auto` forgets it. */
184
+ export function setUsage(root, id, assetId, usage) {
185
+ const manifest = readManifest(root, id);
186
+ const a = manifest.assets.find((x) => x.id === assetId);
187
+ if (!a) throw new Error(`no asset "${assetId}" in games/${id}/${MANIFEST_FILE}`);
188
+ if (a.kind === 'clip') throw new Error(`${assetId} is a clip library: it has no usage (its characters do)`);
189
+ if (usage === 'auto' || usage === null) delete a.usage;
190
+ else if (USAGES.includes(usage)) a.usage = usage;
191
+ else throw new Error(`a usage is one of ${USAGES.join(', ')} (or auto, to go by its kind again)`);
192
+ writeManifest(root, id, manifest);
193
+ return { asset: assetId, usage: usageOf(a), explicit: Boolean(a.usage) };
194
+ }
195
+
152
196
  /* ------------------------------------------------------------------ the licence check (publish, assets check) */
153
197
 
154
198
  /**
155
- * What stops a public game, per asset: [{ asset, level: 'refuse' | 'warn', problem, fix }]. `public`: the game's
156
- * source is shared (game.json share.source is not false) and it is listed. A file a game ships under games/<id>/public
157
- * with no manifest entry at all is refused too: every shipped model needs a record.
199
+ * What stops a public game, per asset: [{ asset, level: 'refuse' | 'warn', problem, fix }]. A file a game ships under
200
+ * games/<id>/public with no manifest entry at all is refused too: every shipped model needs a record.
158
201
  */
159
- export function licenceProblems(root, id, manifest = readManifest(root, id), { public: pub = true } = {}) {
202
+ export function licenceProblems(root, id, manifest = readManifest(root, id)) {
160
203
  const out = [];
161
204
  for (const a of manifest.assets) {
162
205
  const info = licenseInfo(a.license?.kind);
163
206
  if (!info) { out.push({ asset: a.id, level: 'refuse', problem: 'no licence record', fix: `record its licence: homie-studio assets add ${id} --file … --license <cc0|cc-by-4.0|own|…>` }); continue; }
164
207
  if (!info.web) out.push({ asset: a.id, level: 'refuse', problem: `${info.label} does not allow a three.js web game to serve the file`, fix: 'replace it (a library match, or generate one)' });
165
208
  if (info.attribution && !clean(a.license.attribution)) out.push({ asset: a.id, level: 'refuse', problem: `${info.label} requires credit, and there is no attribution line`, fix: 'add license.attribution (who made it, where it is from)' });
166
- if (pub && !info.redistribute && a.license.remix === 'include') out.push({ asset: a.id, level: 'refuse', problem: `${info.label} forbids handing the file to remixers, but its remix is "include"`, fix: `set license.remix to "${info.remix}" (remixers get ${info.remix === 'reference' ? 'it from its origin' : 'a placeholder'})` });
167
- else if (pub && !info.redistribute) out.push({ asset: a.id, level: 'warn', problem: `${info.label}: a public game serves the file to players but never to remixers (${a.license.remix}); keep the studio's repository private while it holds the raw file`, fix: null });
209
+ // A licence that lets the game serve the file but not hand it on: nothing in a build hands it on, so this is a
210
+ // word about the two places it still could be (the studio's own repository, and a part the studio shares).
211
+ if (!info.redistribute) out.push({ asset: a.id, level: 'warn', problem: `${info.label}: the game may serve the file to players, but the file itself may not be handed on; keep the studio's repository private while it holds the raw file, and leave it out of any part the studio shares`, fix: null });
168
212
  }
169
213
  for (const f of unrecordedModels(root, id, manifest)) out.push({ asset: f, level: 'refuse', problem: 'a shipped model with no record in assets/manifest.json', fix: `homie-studio assets add ${id} --file ${f} --license <kind>` });
170
214
  return out;
@@ -191,6 +235,35 @@ export function unrecordedModels(root, id, manifest = readManifest(root, id)) {
191
235
  }
192
236
  const readdirSyncSafe = (p) => readdirSync(p, { withFileTypes: true });
193
237
 
238
+ const PICTURE = /\.(png|jpe?g|webp|avif|gif|hdr|exr|ktx2)$/i;
239
+ /**
240
+ * Pictures in a game's folder that no manifest entry names, in two lists that mean different things:
241
+ * shipped under public/ (the build serves them): runtime artwork with no hash, size or licence on record, and
242
+ * no part of the picture-memory estimate. The game's cover and its hero/ footage are left out (they are
243
+ * the landing's, never a texture).
244
+ * notShipped anywhere else in the folder (raw or private art the build never serves): named, never counted.
245
+ * The codex (private, the style board's own swatches) is not walked at all.
246
+ */
247
+ export function unrecordedPictures(root, id, manifest = readManifest(root, id), { cover = null } = {}) {
248
+ const dir = join(root, 'games', id);
249
+ const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
250
+ const coverPaths = new Set(cover ? [String(cover).replace(/^\.\//, ''), `public/${String(cover).replace(/^\.\//, '')}`] : []);
251
+ const shipped = []; const notShipped = [];
252
+ const walk = (rel, depth) => {
253
+ if (depth > 6) return;
254
+ let entries = [];
255
+ try { entries = readdirSyncSafe(join(dir, rel)); } catch { return; }
256
+ for (const e of entries) {
257
+ if (e.name.startsWith('.') || ['node_modules', 'codex', 'dist', 'hero', '_landing'].includes(e.name)) continue;
258
+ const p = rel ? `${rel}/${e.name}` : e.name;
259
+ if (e.isDirectory()) walk(p, depth + 1);
260
+ else if (PICTURE.test(e.name) && !named.has(p) && !coverPaths.has(p)) (p.startsWith('public/') ? shipped : notShipped).push(p);
261
+ }
262
+ };
263
+ walk('', 0);
264
+ return { shipped: shipped.sort(), notShipped: notShipped.sort() };
265
+ }
266
+
194
267
  /* ------------------------------------------------------------------ RIGHTS.md */
195
268
 
196
269
  /**
@@ -206,9 +279,12 @@ export const PROVIDER_TERMS = Object.freeze([
206
279
  { who: 'ambientCG', url: 'https://docs.ambientcg.com/license/', read: '2026-10-02', says: 'CC0 1.0: the raw files may be included in a project such as a video game.' },
207
280
  ]);
208
281
 
209
- const REMIX_WORDS = { include: 'a remix gets this file (fetched from this studio\'s site, checked by SHA-256)', reference: 'a remix fetches it from its origin, never from this studio', none: 'a remix gets a grey placeholder of the same size and needs its own licence' };
210
282
  const ROUTE_WORDS = { procedural: 'made by the game\'s own code', library: 'from the Homie starter library', generated: 'generated on the studio\'s own account', imported: 'brought in by the studio', premium: 'bought' };
211
- /** A remix's file was made by the original studio, not this one: say whose. */
283
+ /**
284
+ * A game made from another studio's while that was possible (`game remix`, retired) carried that game's files, and
285
+ * each such record says so in `from.remixOf` ({ studio, game }). The file was made by the original studio, not this
286
+ * one: RIGHTS.md keeps saying whose, as a credit owed.
287
+ */
212
288
  const whoseAccount = (a) => (a.from?.remixOf?.studio ? `${a.from.remixOf.studio}'s` : 'the original studio\'s');
213
289
  function routeWords(a) {
214
290
  if (!a.from?.remixOf) return ROUTE_WORDS[a.route] ?? a.route;
@@ -231,7 +307,7 @@ export function rightsMarkdown(root, id, manifest = readManifest(root, id)) {
231
307
  'Read on the dates shown. Terms change: read the live page again before publishing anything commercial.',
232
308
  '',
233
309
  ...PROVIDER_TERMS.filter((t) => manifest.assets.some((a) => termsApply(t, a))).map((t) => `- **${t.who}** (${t.url}, read ${t.read}): ${t.says}`),
234
- ...(manifest.assets.some((a) => a.route === 'generated') ? ['- **Copyright in AI-made files** may be thin: "remix with credit" may not bind a remixer for files no person authored. This file never claims more than the terms above.'] : []),
310
+ ...(manifest.assets.some((a) => a.route === 'generated') ? ['- **Copyright in AI-made files** may be thin: a licence on a file no person authored may not bind whoever copies it. This file never claims more than the terms above.'] : []),
235
311
  '',
236
312
  '## Files',
237
313
  '',
@@ -244,7 +320,6 @@ export function rightsMarkdown(root, id, manifest = readManifest(root, id)) {
244
320
  `- **From:** ${routeWords(a)}${a.from?.pack ? `: ${a.from.packLabel ?? a.from.pack}${a.from.item ? `, ${String(a.from.item).split('/').pop()}` : ''}` : ''}${a.from?.packUrl ?? a.from?.origin ? ` (${a.from.packUrl ?? a.from.origin})` : ''}${a.from?.remixOf ? `; carried from ${a.from.remixOf.game ?? 'the original'}${a.from.remixOf.studio ? ` by ${a.from.remixOf.studio}` : ''}` : ''}${a.made?.steps?.length ? `; ${a.made.steps.map((s) => s.endpoint ? `${s.what} with ${s.endpoint}${Number(s.usd) ? ` (US$${Number(s.usd).toFixed(3)})` : ''}` : `${s.what}${s.tool ? ` (${s.tool})` : ''}`).join(', ')}` : ''}`,
245
321
  `- **Licence:** ${info ? (a.from?.remixOf && info.kind === 'generated' ? `Generated on ${whoseAccount(a)} own account` : info.label) : 'NONE RECORDED: this file cannot be published'}${info?.url ? ` (${info.url})` : ''}${a.license?.owner ? `; owner: ${a.license.owner}` : ''}`,
246
322
  ...(a.license?.attribution ? [`- **Credit:** ${a.license.attribution}`] : []),
247
- `- **A remix:** ${REMIX_WORDS[a.license?.remix] ?? REMIX_WORDS.none}`,
248
323
  ...(a.license?.notes ? [`- **Notes:** ${a.license.notes}`] : []),
249
324
  '');
250
325
  }
@@ -275,7 +350,8 @@ export function syncCredits(root, id, manifest = readManifest(root, id)) {
275
350
  const file = join(root, 'games', id, 'credits.json');
276
351
  let credits = {};
277
352
  try { credits = JSON.parse(readFileSync(file, 'utf8')); } catch { credits = {}; }
278
- const keep = (Array.isArray(credits.parts) ? credits.parts : []).filter((p) => p?.asset !== true);
353
+ const before = Array.isArray(credits.parts) ? credits.parts : [];
354
+ const keep = before.filter((p) => p?.asset !== true);
279
355
  const parts = [];
280
356
  const packs = new Map();
281
357
  for (const a of manifest.assets) {
@@ -287,6 +363,21 @@ export function syncCredits(root, id, manifest = readManifest(root, id)) {
287
363
  if (!packs.has(key)) packs.set(key, { what: `${a.from.packLabel ?? a.from.pack} (${a.kind === 'texture' ? 'textures' : 'models'})`, author: a.from.author ?? null, url: a.from.packUrl ?? a.from.origin ?? null, licence: info.spdx ?? info.label, licenceUrl: info.url, note: 'Copied from the Homie starter library', asset: true });
288
364
  }
289
365
  }
366
+ // Clips retargeted from a pack are that pack's work for as long as a clip library holds them, whether or not any of
367
+ // its models is still in the game: removing the starter characters must not drop the credit for their animation.
368
+ // The pack's name and page come from the clip record (kept when its last model left), else from a model of the pack
369
+ // still here, else from the line this file already had; a pack nothing describes is credited by its id.
370
+ for (const a of manifest.assets) {
371
+ if (a.kind !== 'clip') continue;
372
+ const info = licenseInfo(a.license?.kind) ?? licenseInfo('cc0');
373
+ for (const key of clipPacks(a)) {
374
+ if (packs.has(key)) continue;
375
+ const peer = manifest.assets.find((x) => x.from?.pack === key);
376
+ const d = a.from?.packs?.[key] ?? (peer ? packDetails(peer) : null);
377
+ const was = before.find((p) => p?.asset === true && (p.pack === key || (d?.label && String(p.what ?? '').startsWith(d.label))));
378
+ packs.set(key, { what: `${d?.label ?? (was ? String(was.what).replace(/ \([^)]*\)$/, '') : key)} (animation clips)`, author: d?.author ?? was?.author ?? null, url: d?.url ?? was?.url ?? null, licence: info.spdx ?? info.label, licenceUrl: info.url, note: 'Animation clips from the Homie starter library, retargeted onto this game\'s characters', pack: key, asset: true });
379
+ }
380
+ }
290
381
  const next = [...keep, ...packs.values(), ...parts].slice(0, 40);
291
382
  if (!next.length && !existsSync(file)) return null;
292
383
  const out = { ...credits, parts: next };
@@ -295,24 +386,10 @@ export function syncCredits(root, id, manifest = readManifest(root, id)) {
295
386
  return relative(root, file);
296
387
  }
297
388
 
298
- /* ------------------------------------------------------------------ the site's assets.json (remix carry-over) */
299
-
300
- /**
301
- * /games/<id>/assets.json on the built site: each asset's licence and remix rule, and for `include` files their
302
- * served address (relative to /games/<id>/) and SHA-256, so `game remix` can fetch them. Only files under the
303
- * game's public/ folder are served; a file's address there is its path without "public/".
304
- */
305
- export function servedAssets(root, id, out, manifest = readManifest(root, id)) {
306
- const assets = [];
307
- for (const a of manifest.assets) {
308
- const info = licenseInfo(a.license?.kind);
309
- const remix = info ? (info.redistribute ? a.license.remix ?? info.remix : info.remix === 'include' ? 'none' : info.remix) : 'none';
310
- const files = (a.files ?? []).filter((f) => f.public !== false && /^public\//.test(String(f.path ?? ''))).map((f) => {
311
- const served = f.path.replace(/^public\//, '');
312
- const abs = join(out, served);
313
- return existsSync(abs) ? { role: f.role, path: f.path, url: served, bytes: statSync(abs).size, sha256: f.sha256 ?? null } : null;
314
- }).filter(Boolean);
315
- assets.push({ id: a.id, kind: a.kind, route: a.route, card: a.card ?? null, license: { kind: a.license?.kind ?? null, spdx: info?.spdx ?? null, attribution: a.license?.attribution ?? null, remix }, from: a.from ? { library: a.from.library ?? null, pack: a.from.pack ?? null, item: a.from.item ?? null, origin: a.from.origin ?? null, sha256: a.from.sha256 ?? null } : null, size: a.measured?.box?.size ?? null, files: remix === 'include' ? files : files.map(({ url, ...f }) => ({ ...f, url: null })) });
316
- }
317
- return { v: 1, kind: 'homie-game-assets', game: id, assets };
389
+ /** The packs a clip library's clips came from: the pack part of each library item it names ("<pack>/<item>"). */
390
+ export function clipPacks(a) {
391
+ const from = [...(a?.from?.items ?? []), ...(a?.clips ?? []).map((c) => c?.from)];
392
+ return [...new Set(from.filter((x) => typeof x === 'string' && /^[a-z0-9][a-z0-9-]*\/[^/]+$/i.test(x)).map((x) => x.split('/')[0]))];
318
393
  }
394
+ /** A pack's name, author and page as a record of one of its items holds them. */
395
+ const packDetails = (a) => ({ label: a.from?.packLabel ?? a.from?.pack ?? null, author: a.from?.author ?? null, url: a.from?.packUrl ?? a.from?.origin ?? null });