sindicate 0.11.0 → 0.13.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.
@@ -23,6 +23,10 @@
23
23
  // perfSink: process.env.MY_PERF_SINK || '/tmp/mygame-perf.jsonl',
24
24
  // })]
25
25
  import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { fileURLToPath } from 'node:url';
29
+ const _toolsDir = path.dirname(fileURLToPath(import.meta.url));
26
30
 
27
31
  // Guard for the source-writing endpoints: only same-machine pages may write, and the
28
32
  // payload must look like the file it claims to be — an empty/garbage body used to be
@@ -205,6 +209,336 @@ function editorShotsPlugin({ shotsDir, mapsDir }) {
205
209
  };
206
210
  }
207
211
 
212
+ // THE PACK IMPORTER (ENGINE_DESIGN §5c) — bring publisher packs (Synty et al.) into the
213
+ // RUNNING GAME's repo. A .unitypackage is a gzip tar of GUID dirs, each holding `pathname`
214
+ // (the original Assets/… path) and `asset` (the bytes) — the parse western's python
215
+ // extractor proved, done here with the OS tar. Assets land in the GAME (public/assets/
216
+ // packs/<name>/), never in Sindicate: the engine stays content-free and licenses stay
217
+ // per-project. Animation FBXs route through accentimation-converter when it's present.
218
+ export function packImporterPlugin({ stagingDir = '_staging', packsDir = 'public/assets/packs', converterPath = '../accentimation-converter' } = {}) {
219
+ // category rules from the 85-pack survey (135k paths): newer packs live under
220
+ // Assets/Synty/<Pack>/, older under Assets/<Pack>/Models/; Convex/Collision dirs hold
221
+ // collision meshes (not display models); /Animations/ FBXs are converter fodder;
222
+ // .prefab/.asset/.mat/.cs and friends are Unity-only noise.
223
+ const TEXTURE_RE = /\.(png|jpg|jpeg|tga)$/i;
224
+ const MODEL_RE = /\.(fbx|glb|gltf|obj)$/i;
225
+ const ANIM_HINT = /(^|\/)animations?\//i;
226
+ const COLLISION_RE = /(^|\/)(convex|collision|collisionconvex)\//i;
227
+ const isAnim = (p) => (ANIM_HINT.test(p) && /\.fbx$/i.test(p)) || /(^|\/)A_[^/]+\.fbx$/i.test(p);
228
+
229
+ function inventory(dir) {
230
+ const out = { models: [], textures: [], animations: [], collision: [], other: [] };
231
+ const walk = (d, rel = '') => {
232
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
233
+ if (e.name.startsWith('_')) continue; // working dirs (_anims) are not pack content
234
+ const r = rel ? `${rel}/${e.name}` : e.name;
235
+ if (e.isDirectory()) walk(path.join(d, e.name), r);
236
+ else if (isAnim(r)) out.animations.push(r);
237
+ else if (COLLISION_RE.test(r) && MODEL_RE.test(r)) out.collision.push(r);
238
+ else if (MODEL_RE.test(r)) out.models.push(r);
239
+ else if (TEXTURE_RE.test(r)) out.textures.push(r);
240
+ else out.other.push(r);
241
+ }
242
+ };
243
+ walk(dir);
244
+ return out;
245
+ }
246
+
247
+ return {
248
+ name: 'sindicate-pack-importer',
249
+ apply: 'serve',
250
+ configureServer(server) {
251
+ // upload: stream the .unitypackage to disk, tar-extract, resolve pathname map, stage
252
+ server.middlewares.use('/__pack/upload', (req, res) => {
253
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
254
+ const name = (new URL(req.url, 'http://x').searchParams.get('name') || 'pack').replace(/\.unitypackage$/i, '').replace(/[^\w.-]+/g, '_');
255
+ const upDir = path.join(stagingDir, '_uploads');
256
+ fs.mkdirSync(upDir, { recursive: true });
257
+ const tmp = path.join(upDir, `${name}.unitypackage`);
258
+ const ws = fs.createWriteStream(tmp);
259
+ req.pipe(ws);
260
+ ws.on('finish', () => {
261
+ try {
262
+ const raw = path.join(stagingDir, '_raw', name);
263
+ fs.rmSync(raw, { recursive: true, force: true });
264
+ fs.mkdirSync(raw, { recursive: true });
265
+ execFileSync('tar', ['-xzf', tmp, '-C', raw], { stdio: 'pipe' });
266
+ const dest = path.join(stagingDir, name);
267
+ fs.rmSync(dest, { recursive: true, force: true });
268
+ let staged = 0;
269
+ for (const guid of fs.readdirSync(raw)) {
270
+ const pn = path.join(raw, guid, 'pathname');
271
+ const as = path.join(raw, guid, 'asset');
272
+ if (!fs.existsSync(pn) || !fs.existsSync(as)) continue;
273
+ const orig = fs.readFileSync(pn, 'utf8').split('\n')[0].trim();
274
+ if (!orig || orig.split('/').includes('..')) continue;
275
+ const to = path.join(dest, orig);
276
+ fs.mkdirSync(path.dirname(to), { recursive: true });
277
+ fs.renameSync(as, to);
278
+ staged++;
279
+ }
280
+ fs.rmSync(raw, { recursive: true, force: true });
281
+ fs.rmSync(tmp, { force: true });
282
+ sendJson(res, 200, { pack: name, staged, inventory: summarize(inventory(dest)) });
283
+ } catch (e) { sendJson(res, 500, { err: String(e).slice(0, 300) }); }
284
+ });
285
+ ws.on('error', (e) => sendJson(res, 500, { err: String(e) }));
286
+ });
287
+
288
+ const summarize = (inv) => ({ models: inv.models.length, textures: inv.textures.length, animations: inv.animations.length, collision: inv.collision.length, other: inv.other.length });
289
+ // THE CATALOG: registry.json (ships with the engine) + live status per entry —
290
+ // installed (node_modules/<name>/sindicate.json) and packaged (newest
291
+ // *.sindicatepackage in the repo dir, resolved against the Sites root = game's parent)
292
+ server.middlewares.use('/__pack/registry', (req, res) => {
293
+ try {
294
+ const reg = JSON.parse(fs.readFileSync(path.join(_toolsDir, 'registry.json'), 'utf8'));
295
+ const sites = path.resolve('..');
296
+ const out = reg.packages.map((p) => {
297
+ const manPath = path.join('node_modules', p.name, 'sindicate.json');
298
+ let installed = null;
299
+ if (fs.existsSync(manPath)) { try { installed = JSON.parse(fs.readFileSync(manPath, 'utf8')).version; } catch { installed = '?'; } }
300
+ const repoDir = path.join(sites, p.repo ?? '');
301
+ let packageFile = null;
302
+ if (p.repo && fs.existsSync(repoDir)) {
303
+ const c = fs.readdirSync(repoDir).filter((f) => f.endsWith('.sindicatepackage')).sort();
304
+ if (c.length) packageFile = path.join(repoDir, c[c.length - 1]);
305
+ }
306
+ return { ...p, installed, packageFile, repoExists: p.repo ? fs.existsSync(repoDir) : false };
307
+ });
308
+ sendJson(res, 200, { packages: out });
309
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
310
+ });
311
+
312
+ // install straight from the catalog: run the sindicate CLI server-side into THIS game
313
+ server.middlewares.use('/__pack/installAddon', async (req, res) => {
314
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
315
+ try {
316
+ const { file } = JSON.parse((await readBody(req)).toString());
317
+ if (!file || !String(file).endsWith('.sindicatepackage') || !fs.existsSync(file)) return sendJson(res, 400, { err: 'no such package file' });
318
+ const out = execFileSync('node', [path.join(_toolsDir, 'sindicate-cli.mjs'), 'install', file, '--game', '.'], { stdio: 'pipe', timeout: 300000 });
319
+ sendJson(res, 200, { ok: true, log: out.toString().slice(-400) });
320
+ } catch (e) { sendJson(res, 500, { err: String(e.stderr ?? e).slice(0, 300) }); }
321
+ });
322
+
323
+ // installed add-ons: every node_modules package that ships a sindicate.json
324
+ server.middlewares.use('/__pack/addons', (req, res) => {
325
+ try {
326
+ const nm = 'node_modules';
327
+ const addons = [];
328
+ if (fs.existsSync(nm)) for (const d of fs.readdirSync(nm)) {
329
+ if (d.startsWith('.')) continue;
330
+ const man = path.join(nm, d, 'sindicate.json');
331
+ if (!fs.existsSync(man)) continue;
332
+ try { addons.push(JSON.parse(fs.readFileSync(man, 'utf8'))); } catch { /* unreadable manifest */ }
333
+ }
334
+ sendJson(res, 200, { addons });
335
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
336
+ });
337
+
338
+ // staged packs + inventories
339
+ server.middlewares.use('/__pack/list', (req, res) => {
340
+ try {
341
+ const packs = fs.existsSync(stagingDir)
342
+ ? fs.readdirSync(stagingDir).filter((d) => !d.startsWith('_') && fs.statSync(path.join(stagingDir, d)).isDirectory())
343
+ .map((d) => ({ pack: d, inventory: summarize(inventory(path.join(stagingDir, d))) }))
344
+ : [];
345
+ const imported = fs.existsSync(packsDir)
346
+ ? fs.readdirSync(packsDir).filter((d) => fs.existsSync(path.join(packsDir, d, 'pack.json')))
347
+ : [];
348
+ sendJson(res, 200, { packs, imported });
349
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
350
+ });
351
+
352
+ // import: copy models/textures into the game pack dir; cook animations via the converter
353
+ server.middlewares.use('/__pack/import', async (req, res) => {
354
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
355
+ try {
356
+ const { pack, categories = ['models', 'textures', 'animations'] } = JSON.parse((await readBody(req)).toString());
357
+ const src = path.join(stagingDir, String(pack).replace(/[^\w.-]+/g, '_'));
358
+ if (!fs.existsSync(src)) return sendJson(res, 404, { err: 'no such staged pack' });
359
+ const inv = inventory(src);
360
+ const out = path.join(packsDir, pack);
361
+ const copied = { models: 0, textures: 0 };
362
+ const copy = (rel, sub) => {
363
+ const to = path.join(out, sub, path.basename(rel));
364
+ fs.mkdirSync(path.dirname(to), { recursive: true });
365
+ fs.copyFileSync(path.join(src, rel), to);
366
+ };
367
+ if (categories.includes('models')) for (const r of inv.models) { copy(r, 'models'); copied.models++; }
368
+ if (categories.includes('textures')) for (const r of inv.textures) { copy(r, 'textures'); copied.textures++; }
369
+ let clips = null;
370
+ if (categories.includes('animations') && inv.animations.length) {
371
+ const conv = path.resolve(converterPath, 'bin/accentimation.js');
372
+ if (fs.existsSync(conv)) {
373
+ const animDir = path.join(src, '_anims');
374
+ fs.rmSync(animDir, { recursive: true, force: true });
375
+ fs.mkdirSync(animDir, { recursive: true });
376
+ for (const r of inv.animations) fs.copyFileSync(path.join(src, r), path.join(animDir, path.basename(r)));
377
+ const clipsOut = path.join(out, 'clips');
378
+ try {
379
+ execFileSync('node', [conv, 'convert', animDir, '--out', clipsOut], { stdio: 'pipe', timeout: 300000 });
380
+ clips = fs.readdirSync(clipsOut).filter((f) => f.endsWith('.json')).length;
381
+ } catch (e) { clips = `converter failed: ${String(e.stderr ?? e).slice(0, 200)}`; }
382
+ } else clips = `converter not found at ${conv} — animations staged, not cooked`;
383
+ }
384
+ const manifest = { name: pack, importedAt: new Date().toISOString(), counts: { ...copied, clips }, source: 'unitypackage' };
385
+ fs.mkdirSync(out, { recursive: true });
386
+ fs.writeFileSync(path.join(out, 'pack.json'), JSON.stringify(manifest, null, 2));
387
+ sendJson(res, 200, { ok: true, pack, copied, clips, dest: out });
388
+ } catch (e) { sendJson(res, 500, { err: String(e).slice(0, 300) }); }
389
+ });
390
+ },
391
+ };
392
+ }
393
+
394
+ // THE ENGINE DEV GUI (/__sindicate/) — served by the dev server, talks to the RUNNING
395
+ // game through the game bus. v1: live tunables (core/tune.js groups render as sliders;
396
+ // edits apply in the game the same frame). Panels for add-ons/packs mount here next.
397
+ export function guiPlugin() {
398
+ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sindicate</title><style>
399
+ body{margin:0;font:14px/1.5 -apple-system,sans-serif;background:#14141f;color:#e8e2d0;padding:28px}
400
+ h1{font-size:18px;letter-spacing:0.14em;color:#e8c66a;margin:0 0 4px} .sub{color:#8d8875;font-size:12px;margin-bottom:22px}
401
+ .grp{background:#1c1c2b;border:1px solid #2c2c40;border-radius:10px;padding:16px 18px;margin-bottom:14px;max-width:520px}
402
+ .grp h2{font-size:13px;letter-spacing:0.1em;text-transform:uppercase;color:#e8c66a;margin:0 0 10px}
403
+ .row{display:grid;grid-template-columns:130px 1fr 64px;gap:12px;align-items:center;margin:7px 0}
404
+ .row label{color:#b8b09a;font-size:12px} .row output{text-align:right;font-variant-numeric:tabular-nums;color:#f5ead0}
405
+ input[type=range]{width:100%;accent-color:#e8c66a}
406
+ .file{color:#6d6858;font-size:11px;margin-top:8px} .status{color:#8d8875;font-size:12px;margin-top:18px}
407
+ </style></head><body>
408
+ <h1>SINDICATE</h1><div class="sub">dev panel — tunables apply LIVE in the running game</div>
409
+ <div id="groups"></div><div class="status" id="status">connecting to the game bus…</div>
410
+ <div class="grp" style="margin-top:26px"><h2>Packages</h2>
411
+ <div style="font-size:12px;color:#8d8875;margin-bottom:8px">The Sindicate catalog — install into THIS game with one click (or: sindicate install &lt;file&gt; --game .)</div>
412
+ <div id="ad-list" style="font-size:12px;color:#b8b09a"></div>
413
+ <div id="ad-status" style="font-size:12px;color:#8d8875;margin-top:6px"></div>
414
+ </div>
415
+ <script>
416
+ async function adRefresh() {
417
+ try {
418
+ const j = await fetch('/__pack/registry').then((r) => r.json());
419
+ const el = document.getElementById('ad-list'); el.innerHTML = '';
420
+ for (const p of j.packages ?? []) {
421
+ const row = document.createElement('div');
422
+ row.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin:8px 0';
423
+ const status = p.installed ? '<span style="color:#7dc24a">installed v' + p.installed + '</span>'
424
+ : p.packageFile ? '<span style="color:#e8c66a">available</span>'
425
+ : '<span style="color:#6d6858">' + (p.note ?? 'not packaged') + '</span>';
426
+ row.innerHTML = '<div style="flex:1"><span style="color:#f5ead0">' + p.name + '</span> <span style="color:#6d6858">· ' + p.type + '</span> ' + status
427
+ + '<br><span style="color:#8d8875">' + (p.description ?? '') + '</span></div>';
428
+ if (!p.installed && p.packageFile) {
429
+ const btn = document.createElement('button');
430
+ btn.textContent = 'Install';
431
+ btn.style.cssText = 'padding:3px 12px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px;flex:none';
432
+ btn.onclick = async () => {
433
+ btn.disabled = true;
434
+ document.getElementById('ad-status').textContent = 'installing ' + p.name + '…';
435
+ const r = await fetch('/__pack/installAddon', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ file: p.packageFile }) }).then((x) => x.json());
436
+ document.getElementById('ad-status').textContent = r.ok ? 'installed ' + p.name + ' — restart the dev server to use it' : 'failed: ' + (r.err ?? '?');
437
+ adRefresh();
438
+ };
439
+ row.appendChild(btn);
440
+ }
441
+ el.appendChild(row);
442
+ }
443
+ } catch (e) { document.getElementById('ad-list').textContent = 'unreachable: ' + e.message; }
444
+ }
445
+ adRefresh();
446
+ </script>
447
+ <div class="grp" style="margin-top:14px"><h2>Import Packs</h2>
448
+ <div style="font-size:12px;color:#8d8875;margin-bottom:10px">Drop or choose a .unitypackage — assets land in THIS game's repo (public/assets/packs/). Animations cook through accentimation-converter.</div>
449
+ <input type="file" id="pk-file" accept=".unitypackage" style="color:#b8b09a;font-size:12px">
450
+ <div id="pk-status" style="font-size:12px;color:#8d8875;margin-top:8px"></div>
451
+ <div id="pk-list" style="margin-top:10px"></div>
452
+ </div>
453
+ <script>
454
+ const pkStatus = (m) => { document.getElementById('pk-status').textContent = m; };
455
+ async function pkRefresh() {
456
+ try {
457
+ const j = await fetch('/__pack/list').then((r) => r.json());
458
+ const el = document.getElementById('pk-list'); el.innerHTML = '';
459
+ for (const p of j.packs ?? []) {
460
+ const row = document.createElement('div');
461
+ row.style.cssText = 'display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px;color:#b8b09a';
462
+ const inv = p.inventory;
463
+ const done = (j.imported ?? []).includes(p.pack);
464
+ row.innerHTML = '<span style="color:#f5ead0">' + p.pack + '</span><span>' + inv.models + ' models · ' + inv.textures + ' tex · ' + inv.animations + ' anims</span>' + (done ? '<span style="color:#7dc24a">imported</span>' : '');
465
+ const btn = document.createElement('button');
466
+ btn.textContent = done ? 'Re-import' : 'Import';
467
+ btn.style.cssText = 'padding:3px 12px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px';
468
+ btn.onclick = async () => {
469
+ btn.disabled = true; pkStatus('importing ' + p.pack + '…');
470
+ const r = await fetch('/__pack/import', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pack: p.pack }) }).then((x) => x.json());
471
+ pkStatus(r.ok ? 'imported ' + p.pack + ': ' + r.copied.models + ' models, ' + r.copied.textures + ' textures' + (r.clips != null ? ', clips: ' + r.clips : '') : 'failed: ' + (r.err ?? '?'));
472
+ btn.disabled = false; pkRefresh();
473
+ };
474
+ row.appendChild(btn); el.appendChild(row);
475
+ }
476
+ if (!(j.packs ?? []).length) el.innerHTML = '<div style="font-size:12px;color:#6d6858">nothing staged yet</div>';
477
+ } catch (e) { pkStatus('importer unreachable: ' + e.message); }
478
+ }
479
+ document.getElementById('pk-file').onchange = async (ev) => {
480
+ const f = ev.target.files[0]; if (!f) return;
481
+ pkStatus('uploading ' + f.name + ' (' + Math.round(f.size / 1048576) + 'MB)…');
482
+ try {
483
+ const j = await fetch('/__pack/upload?name=' + encodeURIComponent(f.name), { method: 'POST', body: f }).then((r) => r.json());
484
+ pkStatus(j.err ? 'failed: ' + j.err : 'staged ' + j.pack + ' — ' + j.staged + ' files');
485
+ pkRefresh();
486
+ } catch (e) { pkStatus('upload failed: ' + e.message); }
487
+ };
488
+ pkRefresh();
489
+ </script>
490
+ <script>
491
+ const cmd = (js) => fetch('/__game/cmd', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ js }) }).then((r) => r.json());
492
+ const take = async (id) => { for (let i = 0; i < 40; i++) { const r = await fetch('/__game/take?id=' + id).then((x) => x.json()); if (!r.pending) return r; await new Promise((res) => setTimeout(res, 250)); } return { err: 'timeout' }; };
493
+ const bus = async (js) => { const { id, err } = await cmd(js); if (err) throw new Error(err); const r = await take(id); if (!r.ok) throw new Error(r.err ?? 'bus error'); return r.value; };
494
+ let sig = '', dragging = false;
495
+ const rows = new Map();
496
+ addEventListener('pointerup', () => { dragging = false; });
497
+ async function refresh() {
498
+ try {
499
+ const r = await bus('return { who: location.pathname + location.search, title: document.title, groups: window.__sindTune ? window.__sindTune.list() : [] }');
500
+ const groups = r.groups, tab = r.title + ' — ' + r.who;
501
+ const s = JSON.stringify(groups.map((g) => [g.name, Object.keys(g.spec)]));
502
+ document.getElementById('status').textContent = (groups.length ? 'live — ' + groups.length + ' group(s)' : 'connected — no tunables registered') + ' · answering tab: ' + tab;
503
+ if (s === sig) {
504
+ if (!dragging) for (const g of groups) for (const [k, v] of Object.entries(g.spec)) {
505
+ const row = rows.get(g.name + '.' + k);
506
+ if (row && String(row.inp.value) !== String(v.value)) { row.inp.value = v.value; row.out.textContent = v.value; }
507
+ }
508
+ return;
509
+ }
510
+ sig = s; rows.clear();
511
+ const root = document.getElementById('groups'); root.innerHTML = '';
512
+ for (const g of groups) {
513
+ const div = document.createElement('div'); div.className = 'grp';
514
+ div.innerHTML = '<h2>' + g.name + '</h2>';
515
+ for (const [k, v] of Object.entries(g.spec)) {
516
+ const row = document.createElement('div'); row.className = 'row';
517
+ row.innerHTML = '<label>' + k + '</label><input type="range" min="' + (v.min ?? 0) + '" max="' + (v.max ?? 1) + '" step="' + (v.step ?? 0.01) + '" value="' + v.value + '"><output>' + v.value + '</output>';
518
+ const inp = row.querySelector('input'), out = row.querySelector('output');
519
+ rows.set(g.name + '.' + k, { inp, out });
520
+ inp.onpointerdown = () => { dragging = true; };
521
+ inp.oninput = () => { out.textContent = inp.value; bus('return window.__sindTune.set(' + JSON.stringify(g.name) + ',' + JSON.stringify(k) + ',' + inp.value + ')').catch(() => {}); };
522
+ div.appendChild(row);
523
+ }
524
+ if (g.file) { const f = document.createElement('div'); f.className = 'file'; f.textContent = 'persists to: ' + g.file; div.appendChild(f); }
525
+ root.appendChild(div);
526
+ }
527
+ } catch (e) { document.getElementById('status').textContent = 'game bus unreachable — is a game tab open? (' + e.message + ')'; }
528
+ }
529
+ refresh(); setInterval(refresh, 3000);
530
+ </script></body></html>`;
531
+ return {
532
+ name: 'sindicate-gui',
533
+ apply: 'serve',
534
+ configureServer(server) {
535
+ server.middlewares.use('/__sindicate', (req, res) => {
536
+ res.setHeader('content-type', 'text/html'); res.end(PAGE);
537
+ });
538
+ },
539
+ };
540
+ }
541
+
208
542
  export function sindicateDevTools({
209
543
  sourceWrites = [],
210
544
  assetLists = [],
@@ -225,6 +559,8 @@ export function sindicateDevTools({
225
559
  ...assetLists.map(assetListPlugin),
226
560
  ...stores.map(binStorePlugin),
227
561
  perfSinkPlugin(perfSink),
562
+ guiPlugin(),
563
+ packImporterPlugin(),
228
564
  busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
229
565
  busPlugin({ name: 'editor', prefix: '/__editor' }),
230
566
  editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
@@ -0,0 +1,45 @@
1
+ {
2
+ "//": "The Sindicate package catalog — what exists in the ecosystem and where it lives on this machine (repo paths relative to the Sites root, i.e. the games' parent dir). The dev GUI reads this via /__pack/registry and installs straight from it.",
3
+ "packages": [
4
+ {
5
+ "name": "rayhit-three",
6
+ "type": "addon",
7
+ "description": "Runtime mesh fracturing & physics destruction — Voronoi shattering, demolition, bombs, structural collapse.",
8
+ "repo": "rayhit-three"
9
+ },
10
+ {
11
+ "name": "weather-threejs",
12
+ "type": "addon",
13
+ "description": "Stylized weather, atmosphere & time-of-day — sky, sun/moon/stars, clouds, fog, rain & snow, forecast system.",
14
+ "repo": "weather-threejs"
15
+ },
16
+ {
17
+ "name": "accentimations",
18
+ "type": "clips",
19
+ "description": "The animation pack: 259 cooked clips + manifest. Dev flow consumes it via file: + registerClipPack; a packaged release is pending.",
20
+ "repo": "Accentimations",
21
+ "note": "consumed via file: dependency in dev — not yet packaged"
22
+ },
23
+ {
24
+ "name": "kitforge",
25
+ "type": "addon",
26
+ "description": "Kit-piece cataloguer + render-to-PNG thumbnails (feeds the importer's future thumbnail grid).",
27
+ "repo": "kitforge",
28
+ "note": "not yet manifested"
29
+ },
30
+ {
31
+ "name": "fft-water",
32
+ "type": "addon",
33
+ "description": "FFT ocean/water surface (from transport-tycoon-remake) — extraction pending.",
34
+ "repo": "transport-tycoon-remake",
35
+ "note": "planned — extraction not started"
36
+ },
37
+ {
38
+ "name": "glodify",
39
+ "type": "service",
40
+ "description": "LOD baker (CLI/REST service) — games consume its output files; never installed as a package.",
41
+ "repo": "glodify",
42
+ "note": "service, not installable"
43
+ }
44
+ ]
45
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // THE .sindicatepackage CLI (ENGINE_DESIGN §4c) — one distributable wrapper for everything
3
+ // installable. Under the hood: an npm-compatible tarball (package/ prefix) with a
4
+ // sindicate.json manifest at the package root, so code add-ons install with plain npm and
5
+ // the engine tooling can read what a thing IS before installing it.
6
+ //
7
+ // sindicate pack <dir> → <name>-<version>.sindicatepackage (from
8
+ // package.json + sindicate.json in <dir>)
9
+ // sindicate inspect <file> → print the manifest
10
+ // sindicate install <file> [--game .] → type 'addon' = npm install the tarball into
11
+ // the game (dep + node_modules, bridge on boot)
12
+ // type 'assets'|'clips' = extract package/assets
13
+ // into <game>/public/assets/packs/<name>/
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { execFileSync } from 'node:child_process';
17
+
18
+ const [, , cmd, target, ...rest] = process.argv;
19
+ const opt = (name, dflt) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : dflt; };
20
+ const die = (m) => { console.error(`sindicate: ${m}`); process.exit(1); };
21
+
22
+ function readManifestFromTarball(file) {
23
+ try {
24
+ const out = execFileSync('tar', ['-xzOf', file, 'package/sindicate.json'], { stdio: ['ignore', 'pipe', 'pipe'] });
25
+ return JSON.parse(out.toString());
26
+ } catch (e) { die(`${path.basename(file)}: no readable package/sindicate.json — not a .sindicatepackage (${String(e).slice(0, 120)})`); }
27
+ }
28
+
29
+ if (cmd === 'pack') {
30
+ const dir = path.resolve(target ?? '.');
31
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
32
+ const sinPath = path.join(dir, 'sindicate.json');
33
+ if (!fs.existsSync(sinPath)) die(`${dir}/sindicate.json missing — add { name, version, type, description } first`);
34
+ const sin = JSON.parse(fs.readFileSync(sinPath, 'utf8'));
35
+ // npm pack builds the canonical npm tarball (honours "files", writes package/ prefix);
36
+ // sindicate.json rides along because it is listed in "files" (enforce that here).
37
+ const files = pkg.files ?? [];
38
+ if (files.length && !files.includes('sindicate.json')) die(`package.json "files" must include "sindicate.json" so the manifest ships`);
39
+ const out = execFileSync('npm', ['pack', '--silent'], { cwd: dir }).toString().trim().split('\n').pop();
40
+ const dest = path.join(dir, `${(sin.name ?? pkg.name).replace('/', '-')}-${pkg.version}.sindicatepackage`);
41
+ fs.renameSync(path.join(dir, out), dest);
42
+ console.log(`packed ${dest} (type: ${sin.type})`);
43
+ } else if (cmd === 'inspect') {
44
+ console.log(JSON.stringify(readManifestFromTarball(path.resolve(target)), null, 2));
45
+ } else if (cmd === 'install') {
46
+ const file = path.resolve(target);
47
+ const game = path.resolve(opt('--game', '.'));
48
+ if (!fs.existsSync(path.join(game, 'package.json'))) die(`${game} is not a game (no package.json) — pass --game <dir>`);
49
+ const sin = readManifestFromTarball(file);
50
+ if (sin.engineRange) console.log(`engine range: ${sin.engineRange} (checked at boot by the bridge)`);
51
+ if (sin.type === 'addon') {
52
+ // npm only recognises tarballs by extension (.sindicatepackage reads as a folder path
53
+ // → ENOTDIR), so a .tgz TWIN sits beside the original — and STAYS: npm records the
54
+ // dep as file:<path-to-tgz>, so deleting it would break the game's next npm install
55
+ const tgz = file.replace(/\.sindicatepackage$/, '.tgz');
56
+ fs.copyFileSync(file, tgz);
57
+ execFileSync('npm', ['install', tgz], { cwd: game, stdio: 'inherit' });
58
+ console.log(`installed addon '${sin.name}' into ${path.basename(game)} — import it from game code${sin.bridge ? `; bridge: ${sin.bridge}` : ''}`);
59
+ } else if (sin.type === 'assets' || sin.type === 'clips') {
60
+ const dest = path.join(game, 'public/assets/packs', sin.name);
61
+ fs.rmSync(dest, { recursive: true, force: true });
62
+ fs.mkdirSync(dest, { recursive: true });
63
+ // extract only the package/assets tree, stripping the two leading path parts
64
+ execFileSync('tar', ['-xzf', file, '-C', dest, '--strip-components', '2', 'package/assets'], { stdio: 'pipe' });
65
+ fs.writeFileSync(path.join(dest, 'pack.json'), JSON.stringify({ ...sin, installedAt: new Date().toISOString(), source: 'sindicatepackage' }, null, 2));
66
+ console.log(`installed ${sin.type} pack '${sin.name}' → ${dest}`);
67
+ } else die(`unknown type '${sin.type}' (addon | assets | clips | pack-profile)`);
68
+ } else {
69
+ console.log('usage: sindicate pack <dir> | inspect <file> | install <file> [--game <dir>]');
70
+ process.exit(cmd ? 1 : 0);
71
+ }