sindicate 0.5.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 +3 -2
- package/src/audio/ambience.js +65 -0
- package/src/audio/music.js +244 -0
- package/src/audio/synth.js +228 -0
- package/src/core/assets.js +6 -1
- package/src/ui/cartograph.js +456 -0
- package/src/ui/deathScreen.js +130 -0
- package/src/ui/dialogue.js +434 -0
- package/src/ui/iconBaker.js +180 -0
- package/src/ui/intro.js +137 -0
- package/src/ui/menuHints.js +79 -0
- package/src/ui/pauseMenu.js +194 -0
- package/src/ui/saveLoadScreen.js +156 -0
- package/src/ui/titleScreen.js +359 -0
- package/src/ui/worldmap.js +837 -0
- package/tools/devPlugin.js +232 -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
|
+
}
|