sindicate 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sindicate",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -22,7 +22,8 @@
22
22
  ],
23
23
  "exports": {
24
24
  ".": "./src/index.js",
25
- "./*": "./src/*"
25
+ "./*": "./src/*",
26
+ "./tools/*": "./tools/*"
26
27
  },
27
28
  "files": [
28
29
  "src",
@@ -111,7 +111,7 @@ export class Music {
111
111
  _onEnded(t) {
112
112
  if (this._cur !== t) return;
113
113
  if (t === this.restSleep) return; // asleep under the black; the wake cut takes over
114
- if (t === this.restWake) { this._state = 'area'; this._playArea(this._area || 'desert'); return; }
114
+ if (t === this.restWake) { this._state = 'area'; this._playArea(this._area || Object.keys(this.areas)[0]); return; }
115
115
  if (this._state === 'sting') {
116
116
  const fb = this._stingFallback; this._stingFallback = null;
117
117
  this._state = 'area'; this._playArea(fb || this._area || this._half);
@@ -126,6 +126,16 @@ export class Music {
126
126
  if (this._unlocked) return;
127
127
  this._unlocked = true;
128
128
  this._ensureCtx();
129
+ // Pre-decode the catalogue's `resident` list (file names) so combat stings and
130
+ // exploration cuts start INSTANTLY — everything else decodes on first crossfade.
131
+ if (this._cat.resident?.length) {
132
+ const defs = [this.menu, ...this.attack, ...this.venue,
133
+ ...Object.values(this.areas).flat(), this.restSleep, this.restWake].filter(Boolean);
134
+ for (const name of this._cat.resident) {
135
+ const t = defs.find((d) => d.url.endsWith(`/${name}`));
136
+ if (t) this._decode(t).catch(() => { /* pre-decode is best-effort */ });
137
+ }
138
+ }
129
139
  if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
130
140
  }
131
141
 
@@ -8,6 +8,11 @@ import { texture, uv } from 'three/tsl';
8
8
  // Register BEFORE the first applyAtlas/instantiate call (materials are cached).
9
9
  let atlasEmissiveHook = null;
10
10
  export function setAtlasEmissiveHook(fn) { atlasEmissiveHook = fn; }
11
+
12
+ // The cooked-clip cache version tracks each GAME's animation sources (a rebake bumps
13
+ // it), not the engine — register per game: setClipCacheVersion(2). Defaults to 2
14
+ // (western's current rebake generation).
15
+ export function setClipCacheVersion(v) { CLIP_JSON_VER = v; }
11
16
  // Patched loader from vendor/ — merges ALL FBX animation layers per stack. The
12
17
  // engine does NOT depend on a node_modules postinstall patch.
13
18
  import { FBXLoader } from '../vendor/FBXLoader.js';
@@ -532,7 +537,7 @@ export function applyAtlas(root, tex, { emissive = null } = {}) {
532
537
  // public/assets/clips/<name>.json on first parse (self-written via /__save-clipjson)
533
538
  // and read the JSON on every later boot. Bump CLIP_JSON_VER after changing any source
534
539
  // animation (the name alone can't see content changes), or delete the folder.
535
- const CLIP_JSON_VER = 2; // v2: full-pack gsbake rebake (97 clips, 2026-07-12)
540
+ let CLIP_JSON_VER = 2; // v2: full-pack gsbake rebake (97 clips, 2026-07-12)
536
541
  function clipJsonName(url) {
537
542
  let h = 5381;
538
543
  for (let i = 0; i < url.length; i++) h = ((h << 5) + h + url.charCodeAt(i)) >>> 0;
@@ -0,0 +1,232 @@
1
+ // sindicateDevTools(config) — the engine's dev-server tooling suite, extracted from the
2
+ // western game's vite config. Returns an array of vite plugins (all `apply: 'serve'` —
3
+ // none of this exists in a production build):
4
+ //
5
+ // • guarded SOURCE-WRITE endpoints — bench pages persist tuned data straight into the
6
+ // game's data files (grip/rig/map/LOD... whatever the game declares)
7
+ // • COOK-TO-FILE stores — the game bakes its own caches (VAT bins, nav grids, parsed
8
+ // animation clips, world cook bins, map PNGs) and every later boot is a file read.
9
+ // Each store serves its directory itself: the dirs are excluded from vite's watcher
10
+ // (writes must not full-reload the page), and vite 8's watcher-fed public registry
11
+ // 404s files created after startup — so we bypass it.
12
+ // • PERF SINK — [PERF]/FREEZE samples append to a JSONL on disk, readable without the
13
+ // console (a backgrounded tab pauses rAF and CDP is flaky; disk is neither)
14
+ // • EDITOR + GAME COMMAND BUSES — queue {js|op} snippets over HTTP; the live page polls,
15
+ // executes, posts results back. Scripted playtests and editor drives with curl.
16
+ //
17
+ // Usage (a game's vite.config.js):
18
+ // import { sindicateDevTools } from 'sindicate/tools/devPlugin.js';
19
+ // plugins: [...sindicateDevTools({
20
+ // sourceWrites: [{ route: '/__save-grip', file: 'src/game/data/gripTuning.json',
21
+ // json: true, mustInclude: 'revolver' }],
22
+ // assetLists: [{ route: '/__list-assets', dir: 'public/assets/world', ext: 'fbx' }],
23
+ // perfSink: process.env.MY_PERF_SINK || '/tmp/mygame-perf.jsonl',
24
+ // })]
25
+ import fs from 'node:fs';
26
+
27
+ // Guard for the source-writing endpoints: only same-machine pages may write, and the
28
+ // payload must look like the file it claims to be — an empty/garbage body used to be
29
+ // written verbatim (truncating a committed src file to 0 bytes and breaking the boot via
30
+ // HMR), and cross-origin simple POSTs from any open webpage could reach these routes.
31
+ function guardOk(req, res, body, checks) {
32
+ const origin = req.headers.origin;
33
+ if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
34
+ res.statusCode = 403; res.end('cross-origin write refused'); return false;
35
+ }
36
+ if (!body || body.length < 20) { res.statusCode = 400; res.end('empty body refused'); return false; }
37
+ for (const [label, ok] of checks) {
38
+ if (!ok) { res.statusCode = 400; res.end(`payload failed check: ${label}`); return false; }
39
+ }
40
+ return true;
41
+ }
42
+
43
+ const readBody = (req) => new Promise((resolve) => {
44
+ const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c)));
45
+ });
46
+ const sendJson = (res, code, obj) => {
47
+ res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(obj));
48
+ };
49
+
50
+ // One guarded write-through-to-a-source-file endpoint (grip/rig/map/LOD tuners).
51
+ function sourceWritePlugin({ route, file, json = false, mustInclude = null }) {
52
+ return {
53
+ name: `sindicate-write:${route}`,
54
+ apply: 'serve',
55
+ configureServer(server) {
56
+ server.middlewares.use(route, (req, res) => {
57
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
58
+ let body = '';
59
+ req.on('data', (c) => { body += c; });
60
+ req.on('end', () => {
61
+ try {
62
+ const checks = mustInclude ? [[`payload mentions '${mustInclude}'`, body.includes(mustInclude)]] : [];
63
+ if (!guardOk(req, res, body, checks)) return;
64
+ if (json) JSON.parse(body); // never write a malformed file
65
+ fs.writeFileSync(file, body);
66
+ res.statusCode = 200; res.end('ok');
67
+ } catch (e) { res.statusCode = 500; res.end(String(e)); }
68
+ });
69
+ });
70
+ },
71
+ };
72
+ }
73
+
74
+ // Directory listing for editor asset palettes.
75
+ function assetListPlugin({ route, dir, ext }) {
76
+ const re = new RegExp(`\\.${ext}$`, 'i');
77
+ return {
78
+ name: `sindicate-list:${route}`,
79
+ apply: 'serve',
80
+ configureServer(server) {
81
+ server.middlewares.use(route, (req, res) => {
82
+ try {
83
+ const files = fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => f.replace(re, '')).sort();
84
+ sendJson(res, 200, files);
85
+ } catch (e) { res.statusCode = 500; res.end('[]'); }
86
+ });
87
+ },
88
+ };
89
+ }
90
+
91
+ // Serve + accept a cook-to-file store (bins/JSON/PNG the game bakes for itself).
92
+ function binStorePlugin({ name, serveRoute, saveRoute, dir, nameRe, contentType }) {
93
+ return {
94
+ name: `sindicate-store:${name}`,
95
+ apply: 'serve',
96
+ configureServer(server) {
97
+ server.middlewares.use(serveRoute, (req, res, next) => {
98
+ const file = (req.url || '').split('?')[0].replace(/^\//, '');
99
+ if (!nameRe.test(file)) return next();
100
+ try {
101
+ const buf = fs.readFileSync(`${dir}/${file}`);
102
+ res.setHeader('content-type', typeof contentType === 'function' ? contentType(file) : contentType);
103
+ res.end(buf);
104
+ } catch (e) { res.statusCode = 404; res.end('not cooked'); }
105
+ });
106
+ server.middlewares.use(saveRoute, async (req, res) => {
107
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
108
+ const file = new URL(req.url, 'http://x').searchParams.get('name') || '';
109
+ if (!nameRe.test(file)) { res.statusCode = 400; return res.end('bad name'); }
110
+ try {
111
+ fs.mkdirSync(dir, { recursive: true });
112
+ fs.writeFileSync(`${dir}/${file}`, await readBody(req));
113
+ res.statusCode = 200; res.end('ok');
114
+ } catch (e) { res.statusCode = 500; res.end(String(e)); }
115
+ });
116
+ },
117
+ };
118
+ }
119
+
120
+ // JSONL telemetry sink with size rotation.
121
+ function perfSinkPlugin(sinkPath) {
122
+ return {
123
+ name: 'sindicate-perf-sink',
124
+ apply: 'serve',
125
+ configureServer(server) {
126
+ server.middlewares.use('/__perf', (req, res) => {
127
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
128
+ let body = '';
129
+ req.on('data', (c) => { body += c; });
130
+ req.on('end', () => {
131
+ try {
132
+ try { if (fs.statSync(sinkPath).size > 8_000_000) fs.renameSync(sinkPath, sinkPath + '.1'); } catch { /* no sink yet */ }
133
+ fs.appendFileSync(sinkPath, body.trim() + '\n'); res.statusCode = 200; res.end('ok');
134
+ } catch (e) { res.statusCode = 500; res.end(String(e)); }
135
+ });
136
+ });
137
+ },
138
+ };
139
+ }
140
+
141
+ // A poll/take command bus: POST {…} to /cmd → the page polls /poll → posts /result →
142
+ // collect via /take?id=N. `originGuard` restricts /cmd to same-machine pages.
143
+ function busPlugin({ name, prefix, originGuard = false }) {
144
+ return {
145
+ name: `sindicate-bus:${name}`,
146
+ apply: 'serve',
147
+ configureServer(server) {
148
+ const queue = [];
149
+ const results = new Map();
150
+ let nextId = 1;
151
+ server.middlewares.use(`${prefix}/cmd`, async (req, res) => {
152
+ if (req.method !== 'POST') return sendJson(res, 405, { err: 'POST only' });
153
+ if (originGuard) {
154
+ const origin = req.headers.origin;
155
+ if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return sendJson(res, 403, { err: 'cross-origin refused' });
156
+ }
157
+ try {
158
+ const cmd = JSON.parse((await readBody(req)).toString());
159
+ cmd.id = nextId++; queue.push(cmd);
160
+ if (queue.length > 40) queue.splice(0, queue.length - 40);
161
+ sendJson(res, 200, { id: cmd.id });
162
+ } catch (e) { sendJson(res, 400, { err: String(e) }); }
163
+ });
164
+ server.middlewares.use(`${prefix}/poll`, (req, res) => sendJson(res, 200, { cmds: queue.splice(0) }));
165
+ server.middlewares.use(`${prefix}/result`, async (req, res) => {
166
+ if (req.method !== 'POST') return sendJson(res, 405, { err: 'POST only' });
167
+ try {
168
+ const r = JSON.parse((await readBody(req)).toString());
169
+ results.set(r.id, r);
170
+ if (results.size > 100) results.delete(results.keys().next().value);
171
+ sendJson(res, 200, { ok: 1 });
172
+ } catch (e) { sendJson(res, 400, { err: String(e) }); }
173
+ });
174
+ server.middlewares.use(`${prefix}/take`, (req, res) => {
175
+ const id = Number(new URL(req.url, 'http://x').searchParams.get('id') || 0);
176
+ if (!results.has(id)) return sendJson(res, 404, { pending: true });
177
+ const r = results.get(id); results.delete(id); sendJson(res, 200, r);
178
+ });
179
+ },
180
+ };
181
+ }
182
+
183
+ // Editor screenshot drop (ring-limited) + baked-map store.
184
+ function editorShotsPlugin({ shotsDir, mapsDir }) {
185
+ return {
186
+ name: 'sindicate-editor-shots',
187
+ apply: 'serve',
188
+ configureServer(server) {
189
+ server.middlewares.use('/__save-editorshot', async (req, res) => {
190
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
191
+ const name = new URL(req.url, 'http://x').searchParams.get('name') || '';
192
+ if (!/^[\w.-]+\.png$/.test(name)) { res.statusCode = 400; return res.end('bad name'); }
193
+ try {
194
+ fs.mkdirSync(shotsDir, { recursive: true });
195
+ fs.writeFileSync(`${shotsDir}/${name}`, await readBody(req));
196
+ const shots = fs.readdirSync(shotsDir).filter((f) => f.endsWith('.png'));
197
+ if (shots.length > 150) {
198
+ shots.map((f) => [f, fs.statSync(`${shotsDir}/${f}`).mtimeMs]).sort((a, b) => a[1] - b[1])
199
+ .slice(0, shots.length - 150).forEach(([f]) => { try { fs.unlinkSync(`${shotsDir}/${f}`); } catch { /* raced */ } });
200
+ }
201
+ res.end('ok');
202
+ } catch (e) { res.statusCode = 500; res.end(String(e)); }
203
+ });
204
+ },
205
+ };
206
+ }
207
+
208
+ export function sindicateDevTools({
209
+ sourceWrites = [],
210
+ assetLists = [],
211
+ perfSink = process.env.SINDICATE_PERF_SINK || '/tmp/sindicate-perf.jsonl',
212
+ cookStores = null, // override to add/remove stores; null = the standard set below
213
+ editorShotsDir = 'public/editor-shots',
214
+ mapsDir = 'public/assets/maps',
215
+ } = {}) {
216
+ const stores = cookStores ?? [
217
+ { name: 'vat', serveRoute: '/assets/vat', saveRoute: '/__save-vatbin', dir: 'public/assets/vat', nameRe: /^[\w-]+\.bin$/, contentType: 'application/octet-stream' },
218
+ { name: 'nav', serveRoute: '/assets/nav', saveRoute: '/__save-navbin', dir: 'public/assets/nav', nameRe: /^[\w.-]+\.bin$/, contentType: 'application/octet-stream' },
219
+ { name: 'clips', serveRoute: '/assets/clips', saveRoute: '/__save-clipjson', dir: 'public/assets/clips', nameRe: /^[\w.-]+\.json$/, contentType: 'application/json' },
220
+ { name: 'cook', serveRoute: '/assets/cook', saveRoute: '/__save-cookbin', dir: 'public/assets/cook', nameRe: /^[\w.-]+\.(bin|json)$/, contentType: 'application/octet-stream' },
221
+ { name: 'maps', serveRoute: '/assets/maps', saveRoute: '/__save-mappng', dir: mapsDir, nameRe: /^[\w.-]+\.(png|json)$/, contentType: (f) => (f.endsWith('.json') ? 'application/json' : 'image/png') },
222
+ ];
223
+ return [
224
+ ...sourceWrites.map(sourceWritePlugin),
225
+ ...assetLists.map(assetListPlugin),
226
+ ...stores.map(binStorePlugin),
227
+ perfSinkPlugin(perfSink),
228
+ busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
229
+ busPlugin({ name: 'editor', prefix: '/__editor' }),
230
+ editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
231
+ ];
232
+ }