@mavonengine/cli 0.0.0-stage → 0.0.2
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/README.md +302 -2
- package/dist/chunk-OSCNM66O.js +91 -0
- package/dist/chunk-TNN4S5K4.js +44 -0
- package/dist/docs-4C7X6FUQ.js +13 -0
- package/dist/index.js +2501 -0
- package/dist/server-35RY6IGC.js +731 -0
- package/package.json +47 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,2501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_DOCS_URL,
|
|
4
|
+
MCP_CONFIG_FILE,
|
|
5
|
+
version,
|
|
6
|
+
writeMcpConfig
|
|
7
|
+
} from "./chunk-TNN4S5K4.js";
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
import process9 from "process";
|
|
11
|
+
|
|
12
|
+
// src/commands/build.ts
|
|
13
|
+
import { spawn as spawn3 } from "child_process";
|
|
14
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
15
|
+
import { join as join3, resolve as resolve3 } from "path";
|
|
16
|
+
import process4 from "process";
|
|
17
|
+
import { parseArgs as parseArgs3 } from "util";
|
|
18
|
+
|
|
19
|
+
// ../core/dist/Plugins/ProjectConfig.js
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
21
|
+
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
22
|
+
var CONFIG_FILE = "mavonengine.json";
|
|
23
|
+
var TARGETS = ["client", "server", "both"];
|
|
24
|
+
var PluginConfigError = class extends Error {
|
|
25
|
+
};
|
|
26
|
+
function configPath(root) {
|
|
27
|
+
return join(root, CONFIG_FILE);
|
|
28
|
+
}
|
|
29
|
+
function readConfig(root) {
|
|
30
|
+
const path = configPath(root);
|
|
31
|
+
if (!existsSync(path))
|
|
32
|
+
return { plugins: {} };
|
|
33
|
+
let config;
|
|
34
|
+
try {
|
|
35
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new PluginConfigError(`${path} is not valid JSON: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
40
|
+
throw new PluginConfigError(`${path} must contain a JSON object`);
|
|
41
|
+
const result = config;
|
|
42
|
+
if (result.plugins === void 0)
|
|
43
|
+
result.plugins = {};
|
|
44
|
+
else if (!result.plugins || typeof result.plugins !== "object" || Array.isArray(result.plugins))
|
|
45
|
+
throw new PluginConfigError(`"plugins" in ${path} must be an object`);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function writeConfig(root, config) {
|
|
49
|
+
writeFileSync(configPath(root), `${JSON.stringify(config, null, 2)}
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
function updateConfig(root, change) {
|
|
53
|
+
const config = readConfig(root);
|
|
54
|
+
if (!change(config))
|
|
55
|
+
return false;
|
|
56
|
+
writeConfig(root, config);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function addPluginToConfig(root, pkg) {
|
|
60
|
+
return updateConfig(root, (config) => {
|
|
61
|
+
const existing = config.plugins[pkg];
|
|
62
|
+
if (existing && existing.enabled !== false)
|
|
63
|
+
return false;
|
|
64
|
+
config.plugins[pkg] = existing ? { ...existing, enabled: true } : { enabled: true };
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function findPackageDir(root, pkg) {
|
|
69
|
+
for (let dir = resolve(root); ; dir = dirname(dir)) {
|
|
70
|
+
const candidate = join(dir, "node_modules", pkg);
|
|
71
|
+
if (existsSync(join(candidate, "package.json")))
|
|
72
|
+
return candidate;
|
|
73
|
+
if (dirname(dir) === dir)
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function validateManifest(pkg, value) {
|
|
78
|
+
if (!value || typeof value !== "object")
|
|
79
|
+
throw new PluginConfigError(`${pkg}: "mavonengine" in package.json must be an object`);
|
|
80
|
+
const manifest = value;
|
|
81
|
+
if (!TARGETS.includes(manifest.target))
|
|
82
|
+
throw new PluginConfigError(`${pkg}: "mavonengine.target" must be one of ${TARGETS.join(", ")}`);
|
|
83
|
+
for (const side of ["client", "server"]) {
|
|
84
|
+
if (runsOn(manifest.target, side) && typeof manifest[side] !== "string")
|
|
85
|
+
throw new PluginConfigError(`${pkg}: "mavonengine.${side}" is required for target "${manifest.target}"`);
|
|
86
|
+
}
|
|
87
|
+
return manifest;
|
|
88
|
+
}
|
|
89
|
+
function readPluginManifest(root, pkg) {
|
|
90
|
+
const dir = findPackageDir(root, pkg);
|
|
91
|
+
if (!dir)
|
|
92
|
+
return void 0;
|
|
93
|
+
const packageJson = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
94
|
+
if (packageJson.mavonengine === void 0)
|
|
95
|
+
return void 0;
|
|
96
|
+
return { package: pkg, version: packageJson.version, dir, manifest: validateManifest(pkg, packageJson.mavonengine) };
|
|
97
|
+
}
|
|
98
|
+
function runsOn(target, side) {
|
|
99
|
+
return target === "both" || target === side;
|
|
100
|
+
}
|
|
101
|
+
function resolveInside(base, path) {
|
|
102
|
+
const full = resolve(base, path);
|
|
103
|
+
const inside = relative(base, full);
|
|
104
|
+
return inside && !inside.startsWith("..") && !isAbsolute(inside) ? full : void 0;
|
|
105
|
+
}
|
|
106
|
+
function assetsDir(plugin) {
|
|
107
|
+
return resolve(plugin.dir, plugin.manifest.assets?.dir ?? "assets");
|
|
108
|
+
}
|
|
109
|
+
function enabledPlugins(root, side) {
|
|
110
|
+
const plugins = [];
|
|
111
|
+
const warnings = [];
|
|
112
|
+
for (const [pkg, entry] of Object.entries(readConfig(root).plugins)) {
|
|
113
|
+
if (entry?.enabled === false)
|
|
114
|
+
continue;
|
|
115
|
+
let installed;
|
|
116
|
+
try {
|
|
117
|
+
installed = readPluginManifest(root, pkg);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
warnings.push(err.message);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!installed) {
|
|
123
|
+
warnings.push(`${pkg} is listed in ${CONFIG_FILE} but is not installed or is not a MavonEngine plugin`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!runsOn(installed.manifest.target, side))
|
|
127
|
+
continue;
|
|
128
|
+
const entryPath = resolveInside(installed.dir, installed.manifest[side]);
|
|
129
|
+
if (!entryPath || !existsSync(entryPath)) {
|
|
130
|
+
warnings.push(`${pkg}: ${side} entry ${installed.manifest[side]} not found, is the plugin built?`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
plugins.push({
|
|
134
|
+
...installed,
|
|
135
|
+
options: entry?.options ?? {},
|
|
136
|
+
prefabs: Array.isArray(entry?.prefabs) ? entry.prefabs : [],
|
|
137
|
+
entry: entryPath
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { plugins, warnings };
|
|
141
|
+
}
|
|
142
|
+
function shippedAssetPaths(manifest, usedPrefabs) {
|
|
143
|
+
const assets = manifest.assets;
|
|
144
|
+
const paths = [...assets?.shared ?? []];
|
|
145
|
+
for (const prefab of usedPrefabs)
|
|
146
|
+
paths.push(...assets?.prefabs?.[prefab] ?? []);
|
|
147
|
+
return [...new Set(paths)];
|
|
148
|
+
}
|
|
149
|
+
function releaseAssets(root) {
|
|
150
|
+
const { plugins, warnings } = enabledPlugins(root, "client");
|
|
151
|
+
const assets = [];
|
|
152
|
+
for (const plugin of plugins) {
|
|
153
|
+
const base = assetsDir(plugin);
|
|
154
|
+
for (const path of shippedAssetPaths(plugin.manifest, plugin.prefabs)) {
|
|
155
|
+
const from = resolveInside(base, path);
|
|
156
|
+
if (!from) {
|
|
157
|
+
warnings.push(`${plugin.package}: asset ${path} points outside its assets directory`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!existsSync(from)) {
|
|
161
|
+
warnings.push(`${plugin.package}: asset ${path} not found`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
assets.push({ package: plugin.package, from, to: `plugins/${plugin.package}/${path}`, path });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { assets, warnings };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/commands/dashboard.ts
|
|
171
|
+
import process2 from "process";
|
|
172
|
+
import { parseArgs } from "util";
|
|
173
|
+
|
|
174
|
+
// src/browser.ts
|
|
175
|
+
import { spawn } from "child_process";
|
|
176
|
+
import process from "process";
|
|
177
|
+
var AMPERSAND_RE = /&/g;
|
|
178
|
+
function openCommand(url, platform = process.platform) {
|
|
179
|
+
if (platform === "darwin")
|
|
180
|
+
return ["open", [url]];
|
|
181
|
+
if (platform === "win32")
|
|
182
|
+
return ["cmd", ["/c", "start", '""', url.replace(AMPERSAND_RE, "^&")]];
|
|
183
|
+
return ["xdg-open", [url]];
|
|
184
|
+
}
|
|
185
|
+
function openBrowser(url) {
|
|
186
|
+
const [command, args] = openCommand(url);
|
|
187
|
+
try {
|
|
188
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
189
|
+
child.on("error", () => {
|
|
190
|
+
});
|
|
191
|
+
child.unref();
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function canOpenBrowserHere(env = process.env) {
|
|
196
|
+
return !env.SSH_CONNECTION && !env.SSH_TTY;
|
|
197
|
+
}
|
|
198
|
+
async function servesPage(url, { timeoutMs = 3e3, fetch = globalThis.fetch } = {}) {
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
201
|
+
await res.body?.cancel();
|
|
202
|
+
return res.ok && (res.headers.get("content-type") ?? "").includes("text/html");
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/dashboard/snapshot.ts
|
|
209
|
+
var SUPPORTED_SCHEMA_VERSION = 1;
|
|
210
|
+
function isRecord(value) {
|
|
211
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
212
|
+
}
|
|
213
|
+
function num(value, fallback = 0) {
|
|
214
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
215
|
+
}
|
|
216
|
+
function str(value, fallback = "") {
|
|
217
|
+
return typeof value === "string" ? value : fallback;
|
|
218
|
+
}
|
|
219
|
+
function numOrNull(value) {
|
|
220
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
221
|
+
}
|
|
222
|
+
function strOrNull(value) {
|
|
223
|
+
return typeof value === "string" ? value : null;
|
|
224
|
+
}
|
|
225
|
+
function arr(value, map) {
|
|
226
|
+
return Array.isArray(value) ? value.filter(isRecord).map(map) : [];
|
|
227
|
+
}
|
|
228
|
+
function section(value) {
|
|
229
|
+
return isRecord(value) ? value : {};
|
|
230
|
+
}
|
|
231
|
+
function tickSample(t) {
|
|
232
|
+
return {
|
|
233
|
+
timestamp: num(t.timestamp),
|
|
234
|
+
ticks: num(t.ticks),
|
|
235
|
+
ticksPerSecond: num(t.ticksPerSecond),
|
|
236
|
+
avgMs: num(t.avgMs),
|
|
237
|
+
maxMs: num(t.maxMs),
|
|
238
|
+
commandsPerSecond: num(t.commandsPerSecond),
|
|
239
|
+
maxCommandsPerTick: num(t.maxCommandsPerTick)
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function normalizeSnapshot(raw) {
|
|
243
|
+
const s = section(raw);
|
|
244
|
+
const world = section(s.world);
|
|
245
|
+
const cpu = section(s.cpu);
|
|
246
|
+
const bandwidth = section(s.bandwidth);
|
|
247
|
+
const tick = section(s.tick);
|
|
248
|
+
const memory = section(s.memory);
|
|
249
|
+
const physics = isRecord(s.physics) ? s.physics : null;
|
|
250
|
+
const latency = isRecord(s.latencySimulation) ? s.latencySimulation : null;
|
|
251
|
+
return {
|
|
252
|
+
schemaVersion: num(s.schemaVersion, SUPPORTED_SCHEMA_VERSION),
|
|
253
|
+
engineVersion: str(s.engineVersion, "unknown"),
|
|
254
|
+
serverTime: str(s.serverTime),
|
|
255
|
+
uptime: num(s.uptime),
|
|
256
|
+
sampleIntervalMs: num(s.sampleIntervalMs, 1e3) || 1e3,
|
|
257
|
+
tickRate: num(s.tickRate),
|
|
258
|
+
players: arr(s.players, (p) => {
|
|
259
|
+
const position = section(p.position);
|
|
260
|
+
const connection = isRecord(p.connection) ? p.connection : null;
|
|
261
|
+
const id = str(p.id, "?");
|
|
262
|
+
return {
|
|
263
|
+
id,
|
|
264
|
+
// Older servers don't send a name
|
|
265
|
+
displayName: str(p.displayName, id),
|
|
266
|
+
position: { x: num(position.x), y: num(position.y), z: num(position.z) },
|
|
267
|
+
lastProcessedSequenceId: num(p.lastProcessedSequenceId),
|
|
268
|
+
trackedEntities: num(p.trackedEntities),
|
|
269
|
+
connectedFor: num(p.connectedFor),
|
|
270
|
+
connection: connection ? {
|
|
271
|
+
address: strOrNull(connection.address),
|
|
272
|
+
rttMs: numOrNull(connection.rttMs),
|
|
273
|
+
bytesSent: num(connection.bytesSent),
|
|
274
|
+
bytesReceived: num(connection.bytesReceived)
|
|
275
|
+
} : null
|
|
276
|
+
};
|
|
277
|
+
}),
|
|
278
|
+
world: {
|
|
279
|
+
entities: num(world.entities)
|
|
280
|
+
},
|
|
281
|
+
physics: physics ? { bodies: num(physics.bodies), colliders: num(physics.colliders) } : null,
|
|
282
|
+
cpu: {
|
|
283
|
+
current: num(cpu.current),
|
|
284
|
+
avg1s: num(cpu.avg1s),
|
|
285
|
+
avg10s: num(cpu.avg10s),
|
|
286
|
+
avg60s: num(cpu.avg60s),
|
|
287
|
+
history: arr(cpu.history, (h) => ({ timestamp: num(h.timestamp), cpuPercentage: num(h.cpuPercentage) }))
|
|
288
|
+
},
|
|
289
|
+
bandwidth: {
|
|
290
|
+
sentPerSecond: num(bandwidth.sentPerSecond),
|
|
291
|
+
receivedPerSecond: num(bandwidth.receivedPerSecond),
|
|
292
|
+
history: arr(bandwidth.history, (b) => ({ timestamp: num(b.timestamp), sent: num(b.sent), received: num(b.received) }))
|
|
293
|
+
},
|
|
294
|
+
tick: {
|
|
295
|
+
budgetMs: num(tick.budgetMs),
|
|
296
|
+
latest: isRecord(tick.latest) ? tickSample(tick.latest) : null,
|
|
297
|
+
history: arr(tick.history, tickSample)
|
|
298
|
+
},
|
|
299
|
+
memory: {
|
|
300
|
+
rss: num(memory.rss),
|
|
301
|
+
heapUsed: num(memory.heapUsed),
|
|
302
|
+
heapTotal: num(memory.heapTotal),
|
|
303
|
+
external: num(memory.external)
|
|
304
|
+
},
|
|
305
|
+
latencySimulation: latency ? { fixedLatency: num(latency.fixedLatency), jitter: num(latency.jitter), packetLoss: num(latency.packetLoss) } : null
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function normalizeLogs(raw) {
|
|
309
|
+
const r = section(raw);
|
|
310
|
+
const entries = arr(r.entries, (e) => ({
|
|
311
|
+
id: num(e.id),
|
|
312
|
+
timestamp: num(e.timestamp),
|
|
313
|
+
level: str(e.level, "info"),
|
|
314
|
+
message: str(e.message)
|
|
315
|
+
}));
|
|
316
|
+
return {
|
|
317
|
+
entries,
|
|
318
|
+
latestId: num(r.latestId, entries.at(-1)?.id ?? 0)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function normalizeRequests(raw) {
|
|
322
|
+
const r = section(raw);
|
|
323
|
+
const entries = arr(r.entries, (e) => ({
|
|
324
|
+
id: num(e.id),
|
|
325
|
+
timestamp: num(e.timestamp),
|
|
326
|
+
method: str(e.method, "?"),
|
|
327
|
+
path: str(e.path),
|
|
328
|
+
status: numOrNull(e.status),
|
|
329
|
+
durationMs: num(e.durationMs),
|
|
330
|
+
bytes: num(e.bytes),
|
|
331
|
+
address: strOrNull(e.address)
|
|
332
|
+
}));
|
|
333
|
+
return {
|
|
334
|
+
entries,
|
|
335
|
+
latestId: num(r.latestId, entries.at(-1)?.id ?? 0)
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/dashboard/MetricsClient.ts
|
|
340
|
+
var MAX_LOG_LINES = 1e3;
|
|
341
|
+
var MAX_REQUEST_LINES = 1e3;
|
|
342
|
+
var MetricsSourceError = class extends Error {
|
|
343
|
+
constructor(message, status) {
|
|
344
|
+
super(message);
|
|
345
|
+
this.status = status;
|
|
346
|
+
}
|
|
347
|
+
status;
|
|
348
|
+
};
|
|
349
|
+
function httpSource({ url, token, timeoutMs = 3e3, fetch = globalThis.fetch }) {
|
|
350
|
+
const logsUrl = new URL("logs", url);
|
|
351
|
+
const requestsUrl = new URL("requests", url);
|
|
352
|
+
async function request(target) {
|
|
353
|
+
let res;
|
|
354
|
+
try {
|
|
355
|
+
res = await fetch(target, {
|
|
356
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
357
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
358
|
+
});
|
|
359
|
+
} catch (err) {
|
|
360
|
+
throw new MetricsSourceError(`Can't reach ${url}: ${err.message}`, "unreachable");
|
|
361
|
+
}
|
|
362
|
+
if (res.status === 401)
|
|
363
|
+
throw new MetricsSourceError("Metrics token missing or wrong (use --token)", "unauthorized");
|
|
364
|
+
if (res.status === 403)
|
|
365
|
+
throw new MetricsSourceError("Server only allows metrics from localhost without a token (use --token)", "forbidden");
|
|
366
|
+
if (res.status === 404)
|
|
367
|
+
throw new MetricsSourceError("Metrics endpoint not found: it is disabled, or the server runs a @mavonengine/core release without it", "disabled");
|
|
368
|
+
if (!res.ok)
|
|
369
|
+
throw new MetricsSourceError(`Server responded ${res.status} ${res.statusText}`, "unreachable");
|
|
370
|
+
return res.json();
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
label: url,
|
|
374
|
+
fetch: () => request(url),
|
|
375
|
+
fetchLogs: (afterId) => {
|
|
376
|
+
logsUrl.searchParams.set("after", String(afterId));
|
|
377
|
+
return request(logsUrl.toString());
|
|
378
|
+
},
|
|
379
|
+
fetchRequests: (afterId) => {
|
|
380
|
+
requestsUrl.searchParams.set("after", String(afterId));
|
|
381
|
+
return request(requestsUrl.toString());
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
var Feed = class {
|
|
386
|
+
constructor(fetchPage, normalize, max) {
|
|
387
|
+
this.fetchPage = fetchPage;
|
|
388
|
+
this.normalize = normalize;
|
|
389
|
+
this.max = max;
|
|
390
|
+
this.available = fetchPage ? null : false;
|
|
391
|
+
}
|
|
392
|
+
fetchPage;
|
|
393
|
+
normalize;
|
|
394
|
+
max;
|
|
395
|
+
entries = [];
|
|
396
|
+
/** false once the server turned out not to serve it, null until known */
|
|
397
|
+
available;
|
|
398
|
+
lastId = 0;
|
|
399
|
+
async poll() {
|
|
400
|
+
if (!this.fetchPage || this.available === false)
|
|
401
|
+
return;
|
|
402
|
+
try {
|
|
403
|
+
let { entries, latestId } = this.normalize(await this.fetchPage(this.lastId));
|
|
404
|
+
if (latestId < this.lastId) {
|
|
405
|
+
this.entries = [];
|
|
406
|
+
this.lastId = 0;
|
|
407
|
+
({ entries, latestId } = this.normalize(await this.fetchPage(0)));
|
|
408
|
+
}
|
|
409
|
+
this.available = true;
|
|
410
|
+
this.entries.push(...entries.filter((entry) => entry.id > this.lastId));
|
|
411
|
+
this.lastId = Math.max(this.lastId, latestId);
|
|
412
|
+
if (this.entries.length > this.max)
|
|
413
|
+
this.entries.splice(0, this.entries.length - this.max);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
if (err instanceof MetricsSourceError && err.status === "disabled")
|
|
416
|
+
this.available = false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
var MetricsClient = class {
|
|
421
|
+
constructor(source, { intervalMs = 1e3, maxBackoffMs = 1e4, startupIntervalMs } = {}) {
|
|
422
|
+
this.source = source;
|
|
423
|
+
this.intervalMs = intervalMs;
|
|
424
|
+
this.maxBackoffMs = maxBackoffMs;
|
|
425
|
+
this.startupIntervalMs = startupIntervalMs;
|
|
426
|
+
this.logs = new Feed(source.fetchLogs?.bind(source), normalizeLogs, MAX_LOG_LINES);
|
|
427
|
+
this.requests = new Feed(source.fetchRequests?.bind(source), normalizeRequests, MAX_REQUEST_LINES);
|
|
428
|
+
this.state = {
|
|
429
|
+
status: "connecting",
|
|
430
|
+
source: source.label,
|
|
431
|
+
snapshot: null,
|
|
432
|
+
lastUpdate: null,
|
|
433
|
+
error: null,
|
|
434
|
+
failures: 0,
|
|
435
|
+
logs: [],
|
|
436
|
+
logsAvailable: this.logs.available,
|
|
437
|
+
requests: [],
|
|
438
|
+
requestsAvailable: this.requests.available
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
source;
|
|
442
|
+
state;
|
|
443
|
+
intervalMs;
|
|
444
|
+
maxBackoffMs;
|
|
445
|
+
startupIntervalMs;
|
|
446
|
+
timer = null;
|
|
447
|
+
running = false;
|
|
448
|
+
listeners = /* @__PURE__ */ new Set();
|
|
449
|
+
logs;
|
|
450
|
+
requests;
|
|
451
|
+
onChange(listener) {
|
|
452
|
+
this.listeners.add(listener);
|
|
453
|
+
return () => this.listeners.delete(listener);
|
|
454
|
+
}
|
|
455
|
+
start() {
|
|
456
|
+
if (this.running)
|
|
457
|
+
return;
|
|
458
|
+
this.running = true;
|
|
459
|
+
void this.loop();
|
|
460
|
+
}
|
|
461
|
+
stop() {
|
|
462
|
+
this.running = false;
|
|
463
|
+
if (this.timer)
|
|
464
|
+
clearTimeout(this.timer);
|
|
465
|
+
this.timer = null;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Poll once now, skipping the wait.
|
|
469
|
+
*/
|
|
470
|
+
async refresh() {
|
|
471
|
+
if (this.timer)
|
|
472
|
+
clearTimeout(this.timer);
|
|
473
|
+
this.timer = null;
|
|
474
|
+
await this.loop();
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Fetch one snapshot, then any new log lines and requests, and update the state.
|
|
478
|
+
*/
|
|
479
|
+
async poll() {
|
|
480
|
+
try {
|
|
481
|
+
const raw = await this.source.fetch();
|
|
482
|
+
this.state.snapshot = normalizeSnapshot(raw);
|
|
483
|
+
this.state.status = "live";
|
|
484
|
+
this.state.lastUpdate = Date.now();
|
|
485
|
+
this.state.error = null;
|
|
486
|
+
this.state.failures = 0;
|
|
487
|
+
await Promise.all([this.logs.poll(), this.requests.poll()]);
|
|
488
|
+
this.state.logs = this.logs.entries;
|
|
489
|
+
this.state.logsAvailable = this.logs.available;
|
|
490
|
+
this.state.requests = this.requests.entries;
|
|
491
|
+
this.state.requestsAvailable = this.requests.available;
|
|
492
|
+
} catch (err) {
|
|
493
|
+
this.state.status = err instanceof MetricsSourceError ? err.status : "unreachable";
|
|
494
|
+
this.state.error = err.message;
|
|
495
|
+
this.state.failures++;
|
|
496
|
+
}
|
|
497
|
+
this.listeners.forEach((listener) => listener(this.state));
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs.
|
|
501
|
+
* The startup interval until the server first answered, if one is set.
|
|
502
|
+
*/
|
|
503
|
+
nextDelay() {
|
|
504
|
+
if (this.startupIntervalMs !== void 0 && !this.state.snapshot)
|
|
505
|
+
return this.startupIntervalMs;
|
|
506
|
+
if (this.state.failures === 0)
|
|
507
|
+
return this.intervalMs;
|
|
508
|
+
return Math.min(this.intervalMs * 2 ** this.state.failures, Math.max(this.maxBackoffMs, this.intervalMs));
|
|
509
|
+
}
|
|
510
|
+
async loop() {
|
|
511
|
+
await this.poll();
|
|
512
|
+
if (this.running && !this.timer) {
|
|
513
|
+
this.timer = setTimeout(() => {
|
|
514
|
+
this.timer = null;
|
|
515
|
+
void this.loop();
|
|
516
|
+
}, this.nextDelay());
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// src/dashboard/simulator.ts
|
|
522
|
+
var HISTORY_LENGTH = 60;
|
|
523
|
+
function mulberry32(seed) {
|
|
524
|
+
return () => {
|
|
525
|
+
seed |= 0;
|
|
526
|
+
seed = seed + 1831565813 | 0;
|
|
527
|
+
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
|
|
528
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
529
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function push(history, item) {
|
|
533
|
+
history.push(item);
|
|
534
|
+
if (history.length > HISTORY_LENGTH)
|
|
535
|
+
history.shift();
|
|
536
|
+
}
|
|
537
|
+
function simulatedSource(seed = 1, sampleIntervalMs = 1e3) {
|
|
538
|
+
const random = mulberry32(seed);
|
|
539
|
+
const tickRate = 30;
|
|
540
|
+
const budgetMs = Math.round(1e3 / tickRate * 100) / 100;
|
|
541
|
+
let step = 0;
|
|
542
|
+
let now = Date.UTC(2026, 0, 1);
|
|
543
|
+
let sequence = 0;
|
|
544
|
+
const cpuHistory = [];
|
|
545
|
+
const bandwidthHistory = [];
|
|
546
|
+
const tickHistory = [];
|
|
547
|
+
const logs = [];
|
|
548
|
+
let previousPlayers = 0;
|
|
549
|
+
const joinedAt = [];
|
|
550
|
+
const log = (level, message) => {
|
|
551
|
+
logs.push({ id: logs.length + 1, timestamp: now, level, message });
|
|
552
|
+
};
|
|
553
|
+
const requests = [];
|
|
554
|
+
const request = (method, path, status, bytes, address) => {
|
|
555
|
+
requests.push({ id: requests.length + 1, timestamp: now, method, path, status, durationMs: Math.round((1 + random() * 20) * 100) / 100, bytes, address });
|
|
556
|
+
};
|
|
557
|
+
log("info", "Starting @mavon/engine - Socket Server");
|
|
558
|
+
log("info", "Starting @mavon/engine - HTTP Server");
|
|
559
|
+
return {
|
|
560
|
+
label: "simulation",
|
|
561
|
+
async fetch() {
|
|
562
|
+
step++;
|
|
563
|
+
now += sampleIntervalMs;
|
|
564
|
+
sequence += tickRate;
|
|
565
|
+
const playerCount = Math.max(1, Math.round(6 + 5 * Math.sin(step / 20) + random() * 2));
|
|
566
|
+
for (let i = previousPlayers; i < playerCount; i++)
|
|
567
|
+
joinedAt[i] = now;
|
|
568
|
+
const players = Array.from({ length: playerCount }, (_, i) => ({
|
|
569
|
+
id: `sim-${(i + 1).toString().padStart(2, "0")}`,
|
|
570
|
+
displayName: `Player ${i + 1}`,
|
|
571
|
+
position: {
|
|
572
|
+
x: Math.round(Math.cos(step / 10 + i) * (10 + i * 3) * 100) / 100,
|
|
573
|
+
y: 0,
|
|
574
|
+
z: Math.round(Math.sin(step / 10 + i) * (10 + i * 3) * 100) / 100
|
|
575
|
+
},
|
|
576
|
+
lastProcessedSequenceId: sequence - Math.floor(random() * 3),
|
|
577
|
+
trackedEntities: playerCount + 20 + Math.floor(random() * 5),
|
|
578
|
+
connectedFor: Math.round((now - joinedAt[i]) / 1e3),
|
|
579
|
+
connection: {
|
|
580
|
+
address: `203.0.113.${10 + i}`,
|
|
581
|
+
rttMs: Math.round(20 + i * 8 + random() * 10),
|
|
582
|
+
bytesSent: Math.round((now - joinedAt[i]) * (25 + i)),
|
|
583
|
+
bytesReceived: Math.round((now - joinedAt[i]) * (4 + i / 2))
|
|
584
|
+
}
|
|
585
|
+
}));
|
|
586
|
+
for (let i = previousPlayers; i < playerCount; i++) {
|
|
587
|
+
const address = players[i].connection.address;
|
|
588
|
+
request("GET", "/", 200, 1240, address);
|
|
589
|
+
const cached = step % 3 === 0;
|
|
590
|
+
request("GET", "/assets/index-4f2a9c1e.js", cached ? 304 : 200, cached ? 210 : 412e3, address);
|
|
591
|
+
request("GET", "/favicon.ico", 404, 160, address);
|
|
592
|
+
request("POST", "/.wrtc/v2/connections", 200, 2900, address);
|
|
593
|
+
request("POST", `/.wrtc/v2/connections/${players[i].id}/additional-candidates`, 200, 600, address);
|
|
594
|
+
log("info", `Connected: ${players[i].id}`);
|
|
595
|
+
log("info", `${i + 1} total players connected`);
|
|
596
|
+
}
|
|
597
|
+
for (let i = previousPlayers - 1; i >= playerCount; i--) {
|
|
598
|
+
log("info", `Disconnected sim-${(i + 1).toString().padStart(2, "0")}`);
|
|
599
|
+
log("info", `${i} total players connected`);
|
|
600
|
+
}
|
|
601
|
+
previousPlayers = playerCount;
|
|
602
|
+
if (step % 10 === 0)
|
|
603
|
+
request("GET", "/api/game/health", 200, 180, "198.51.100.7");
|
|
604
|
+
if (step % 23 === 0)
|
|
605
|
+
request("GET", "/wp-login.php", 404, 160, "192.0.2.66");
|
|
606
|
+
if (step % 5 === 0)
|
|
607
|
+
log("debug", `Client - Sent: ${playerCount * 4200} B, Received: ${playerCount * 600} B`);
|
|
608
|
+
const cpu = Math.max(0, 12 + playerCount * 2.5 + Math.sin(step / 7) * 6 + random() * 4);
|
|
609
|
+
push(cpuHistory, { timestamp: now, cpuPercentage: Math.round(cpu * 100) / 100 });
|
|
610
|
+
const sent = Math.round(playerCount * (900 + random() * 300) * tickRate * sampleIntervalMs / 1e3);
|
|
611
|
+
const received = Math.round(playerCount * (120 + random() * 60) * tickRate * sampleIntervalMs / 1e3);
|
|
612
|
+
push(bandwidthHistory, { timestamp: now, sent, received });
|
|
613
|
+
const avgMs = 2 + playerCount * 0.6 + random() * 1.5;
|
|
614
|
+
const maxMs = avgMs + random() * 3 + (step % 17 === 0 ? 25 : 0);
|
|
615
|
+
if (maxMs > budgetMs)
|
|
616
|
+
log("warn", `Slow tick: ${maxMs.toFixed(1)} ms (budget ${budgetMs} ms)`);
|
|
617
|
+
const commandsPerSecond = playerCount * tickRate;
|
|
618
|
+
push(tickHistory, {
|
|
619
|
+
timestamp: now,
|
|
620
|
+
ticks: tickRate,
|
|
621
|
+
ticksPerSecond: tickRate - (maxMs > budgetMs ? 1 : 0),
|
|
622
|
+
avgMs: Math.round(avgMs * 100) / 100,
|
|
623
|
+
maxMs: Math.round(maxMs * 100) / 100,
|
|
624
|
+
commandsPerSecond,
|
|
625
|
+
maxCommandsPerTick: playerCount + Math.floor(random() * 3)
|
|
626
|
+
});
|
|
627
|
+
const average = (ms) => {
|
|
628
|
+
const recent = cpuHistory.slice(-Math.max(1, Math.round(ms / sampleIntervalMs)));
|
|
629
|
+
return Math.round(recent.reduce((acc, h) => acc + h.cpuPercentage, 0) / recent.length * 100) / 100;
|
|
630
|
+
};
|
|
631
|
+
const heapUsed = 6e7 + playerCount * 2e6 + step % 30 * 1e6;
|
|
632
|
+
const snapshot = {
|
|
633
|
+
schemaVersion: 1,
|
|
634
|
+
engineVersion: "simulated",
|
|
635
|
+
serverTime: new Date(now).toISOString(),
|
|
636
|
+
uptime: Math.round(step * sampleIntervalMs / 1e3),
|
|
637
|
+
sampleIntervalMs,
|
|
638
|
+
tickRate,
|
|
639
|
+
players,
|
|
640
|
+
world: { entities: playerCount + 140 },
|
|
641
|
+
physics: { bodies: playerCount + 120, colliders: playerCount + 180 },
|
|
642
|
+
cpu: {
|
|
643
|
+
current: cpuHistory.at(-1).cpuPercentage,
|
|
644
|
+
avg1s: average(1e3),
|
|
645
|
+
avg10s: average(1e4),
|
|
646
|
+
avg60s: average(6e4),
|
|
647
|
+
history: [...cpuHistory]
|
|
648
|
+
},
|
|
649
|
+
bandwidth: {
|
|
650
|
+
sentPerSecond: Math.round(sent * 1e3 / sampleIntervalMs),
|
|
651
|
+
receivedPerSecond: Math.round(received * 1e3 / sampleIntervalMs),
|
|
652
|
+
history: [...bandwidthHistory]
|
|
653
|
+
},
|
|
654
|
+
tick: {
|
|
655
|
+
budgetMs,
|
|
656
|
+
latest: tickHistory.at(-1),
|
|
657
|
+
history: [...tickHistory]
|
|
658
|
+
},
|
|
659
|
+
memory: {
|
|
660
|
+
rss: heapUsed + 8e7,
|
|
661
|
+
heapUsed,
|
|
662
|
+
heapTotal: heapUsed + 3e7,
|
|
663
|
+
external: 5e6
|
|
664
|
+
},
|
|
665
|
+
latencySimulation: null
|
|
666
|
+
};
|
|
667
|
+
return snapshot;
|
|
668
|
+
},
|
|
669
|
+
async fetchLogs(afterId) {
|
|
670
|
+
return { entries: logs.filter((entry) => entry.id > afterId), latestId: logs.length };
|
|
671
|
+
},
|
|
672
|
+
async fetchRequests(afterId) {
|
|
673
|
+
return { entries: requests.filter((entry) => entry.id > afterId), latestId: requests.length };
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/dashboard/theme.ts
|
|
679
|
+
import { contrast, defineTheme, mix, resolveTheme } from "@profullstack/hqtui";
|
|
680
|
+
var MIN_MUTED_CONTRAST = 6;
|
|
681
|
+
function readableTheme(name) {
|
|
682
|
+
const base = resolveTheme(name);
|
|
683
|
+
let muted = base.muted;
|
|
684
|
+
for (let t = 0.05; contrast(muted, base.background) < MIN_MUTED_CONTRAST && t <= 1; t += 0.05)
|
|
685
|
+
muted = mix(base.muted, base.foreground, t);
|
|
686
|
+
return defineTheme({ name: base.name, muted }, base);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// src/dashboard/view.ts
|
|
690
|
+
import { stripVTControlCharacters } from "util";
|
|
691
|
+
|
|
692
|
+
// ../core/dist/Utils/BootBanner.js
|
|
693
|
+
function bootBanner({ version: version2, threeRevision, rapierVersion, mode }) {
|
|
694
|
+
const row = (label, value) => `\u2502 ${`${label}:`.padEnd(10)}${value.padEnd(28)} \u2502`;
|
|
695
|
+
return [
|
|
696
|
+
"\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510",
|
|
697
|
+
"\u2502 \u2502",
|
|
698
|
+
"\u2502 MavonEngine \u2502",
|
|
699
|
+
"\u2502 \u2502",
|
|
700
|
+
row("Version", version2),
|
|
701
|
+
...threeRevision ? [row("Three.js", `r${threeRevision}`)] : [],
|
|
702
|
+
...rapierVersion ? [row("Rapier", rapierVersion)] : [],
|
|
703
|
+
row("Mode", mode),
|
|
704
|
+
"\u2502 \u2502",
|
|
705
|
+
"\u2502 https://github.com/MavonEngine/core \u2502",
|
|
706
|
+
"\u2502 https://mavonengine.com \u2502",
|
|
707
|
+
"\u2502 \u2502",
|
|
708
|
+
"\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"
|
|
709
|
+
];
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/dashboard/view.ts
|
|
713
|
+
import { DEFAULT_COLOR, hex } from "@profullstack/hqtui";
|
|
714
|
+
|
|
715
|
+
// src/dashboard/format.ts
|
|
716
|
+
var BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
717
|
+
function formatBytes(bytes, decimals = 1) {
|
|
718
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
719
|
+
return "0 B";
|
|
720
|
+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1);
|
|
721
|
+
const value = bytes / 1024 ** exponent;
|
|
722
|
+
return `${exponent === 0 ? value : value.toFixed(decimals)} ${BYTE_UNITS[exponent]}`;
|
|
723
|
+
}
|
|
724
|
+
function formatRate(bytesPerSecond) {
|
|
725
|
+
return `${formatBytes(bytesPerSecond)}/s`;
|
|
726
|
+
}
|
|
727
|
+
function formatDuration(totalSeconds) {
|
|
728
|
+
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
729
|
+
const d = Math.floor(seconds / 86400);
|
|
730
|
+
const h = Math.floor(seconds % 86400 / 3600);
|
|
731
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
732
|
+
const s = seconds % 60;
|
|
733
|
+
if (d > 0)
|
|
734
|
+
return `${d}d ${h}h ${m}m`;
|
|
735
|
+
if (h > 0)
|
|
736
|
+
return `${h}h ${m}m ${s}s`;
|
|
737
|
+
if (m > 0)
|
|
738
|
+
return `${m}m ${s}s`;
|
|
739
|
+
return `${s}s`;
|
|
740
|
+
}
|
|
741
|
+
function formatMs(ms) {
|
|
742
|
+
return `${ms.toFixed(ms < 10 ? 2 : 1)} ms`;
|
|
743
|
+
}
|
|
744
|
+
function formatPercent(value) {
|
|
745
|
+
return `${value.toFixed(1)}%`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/dashboard/logo.ts
|
|
749
|
+
var LOGO_SIZES = [
|
|
750
|
+
[
|
|
751
|
+
"------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------",
|
|
752
|
+
"------------------------------------------------------------------------------------------------------------------174c6b1f627f216682194f6f------------------------------------------------------------------------------------------------------------------",
|
|
753
|
+
"------------------------------------------------------------------------------------------------------184a6c2164813d98a958cbd357cdd53e9cae236680194b69------------------------------------------------------------------------------------------------------",
|
|
754
|
+
"------------------------------------------------------------------------------------------------1b50723a8aa255b7c344a2b33a9dad399cae3f9caf51b3c13c92a51c5474------------------------------------------------------------------------------------------------",
|
|
755
|
+
"------------------------------------------------------------------------------------16436524648047a0b14da7b72e7590185a7a2c859d2e8ba1195d7b276c8848a0b34aa5b5296b851a4868------------------------------------------------------------------------------------",
|
|
756
|
+
"------------------------------------------------------------------------------1d56763a8ca250b1be3e8b9e194e6d174c6e29789444b6c646bbc92c7f991a5274194f713884984cacb83d93a5215e7b194668------------------------------------------------------------------------",
|
|
757
|
+
"------------------------------------------------------------------1c4f6f357f9655b2bf479eae245c77------205974358da53796ad389bb03a9bb03693a93692a823637f------20566f4090a353b1bf39879c1d5775174366------------------------------------------------------------",
|
|
758
|
+
"------------------------------------------------------------2a6a844da6b555acb82f6e87------------296c85358ba225667f14496726768e297b91154865215e7732879e28748b194965------2665804ca3b34eabba29728b------------------------------------------------------------",
|
|
759
|
+
"------------------------------------------------1d546d3e8fa152b1be378397194a67------245e7a398fa636879d------------1d54703395aa349aad1e5a73------------2e7e943690a8256782------174869307b904aabba3c93a51d546f------------------------------------------------",
|
|
760
|
+
"------------------------------------194b67347b9161c0cc53b0be1e5b78------1b4e6b2e7d94348ca2225c75------------2d76903492a926728c236c86348da32e7a94------------1f53703188a131859d1e5772------16577441a3b556c4ce3583951a4f6a------------------------------------",
|
|
761
|
+
"------------------------------25637d49a5b552b1bf3a8a9f4aa2b43d91a52d7d963490a7266a83------------1e516e338ba32e7c941c4b66------------------2a728c338da5215975------------23627d3491a82d839b368fa441a4b533889e4aa8b649abb8286b83------------------------------",
|
|
762
|
+
"------------------1e55723c93a54ab1c0307e97174b6c------1d506b3a889b56bfca3c90a4------1a4a672e7c963795ab1f526f------------------------------------1d4d693691a732839a1d516e------33829953bdc93d92a41f5571------17496b2a758f47adba4197a8215c76------------------",
|
|
763
|
+
"------------2b728b50bcc84dbbc92780991e64811f5c781d4f6c------------255d774499ab4db3c041a9b92d7e96------------------------------------------------------------2d799343aabb4fb3c148a1b226647c------------1b4c681f5e7b1e6782257d9545b2c155c3ce318198------------",
|
|
764
|
+
"------1e516d4dc6d463ecf54fc0cc3fa3b43b9caf3898aa3c9daf34869c1c4c69------------2c6c8348a5b23c91a11f526e------------------------------------------------1d4c6a3b8a9d4eaeb7327a8f------------------347f943c9cae3597a93a9caf3ea2b44bbac862e6f156d4df225d75------",
|
|
765
|
+
"------205a774abed14eb6d841a6bc3290a315516e------------307991419fb32c758d------------------3680964ca8b72d6b85------------------------------------29637c4ea9b53b8798------------------2a6c85439fb1368397------------164d6d318ba045aabb4bb1d44dc4da23657f------",
|
|
766
|
+
"------1f5b7a43abbe3e7fb4336097357d9c4dabbb358299154665------1e4e6a3385983992a6225c78------------1f53714197aa3d91a6225a771844651845662156743b8ba1449cad245a76------------1e526b388ea0398d9d1f5670------------2f7a914dafbb3b87a2305e913e7eb643aabf236881------",
|
|
767
|
+
"------2056753da3b52859874260a51c35681f50744292a84ba7b723607a------------225c763993a8388ca3205171------1b44672b6b892a6a881d53761d52772868882c708c1b4b6c------1948683483993d97a7256379------------1e58754aa6b64ba0b0255977------3f5ea12a59883d9eb023647d------",
|
|
768
|
+
"------1d54723b9faf1e4971384f8f364b88------------27627f45a1b237879d1d4e6e------------2e7591409db52f7996194067------19456a215b80225c801a456b------------2d718f419eb3337f97------------------337f9549a7b62e7089------------2f427b3d55941c436a3b9bad24637d------",
|
|
769
|
+
"------1c536c3b9eae1b476a2b40783d4d8f------------------1d456738839a4ea8ba397f9a1c476d------1e4e723385a0368ca7255c801f4e772d6d912d6e921f517923587e3989a43b8fa91f5576------16426530758e4da8b73c8ca01f496b------------------3848842e457d------3a9aaa216078------",
|
|
770
|
+
"------1d53703b9dad1b4668273b7437468b------2f6c8d396199------------27577b4597ae4ba5b92c6c8c1b40691b3d682c6f93358cad307ba04196b3429ab52e7aa0388eaf2f78991a3f67------28658449a2b34aa2b42a607f------------32558d327495------32407f2b4079------3a99a8206078------",
|
|
771
|
+
"------1d51713898ad1c4366263a7139438c------3b8ea95f9ae14e62b42d4380------------2b688843a1b64297b2295b8622457a27548b3376a650b7ce51bbd0347ca925568c20457724557f3e93ad48a8b931728e------------2c40784e61ac6394e24198b5------333f7f2b3f75------3895a7205f78------",
|
|
772
|
+
"------1c51703494ab------27397038478e------378fa7417db04b5eb03f54a33d51a2273873------204572367aa14da8c63c76ad335c9f3e83b658c6da59c8dc3e88b82f5e9d396fa649a8c43782a01f476d------23346a42549d4a5ca95164b54776b13e93ac------324281293f76------3693a71f5a78------",
|
|
773
|
+
"------1c4f6e3494ab------28397339498f------328ba1286788384f942a41802a3b794452a73d4c9a273a79243d7a3256974078b6448fc147a9cd4bc5d94bc7dc48a7cf4590c4427cb7305995223a7422346e35478e4455a3303e812d3c7e4256a02b5f873b91a9------324381293f77------3692a8215978------",
|
|
774
|
+
"------1c4e6e3593ad1d4166283a7437468f------2f879c21607c2541744073b224597e2132693e4f995160b74356a638549d448abf50c4df4ccde146c9dc44c8dc4ac9e054c5e14b91c536569d4154a4505fb53d4e991f2f651f47704077b12d4b86215577368da6------3242802b4078------3591a9205978------",
|
|
775
|
+
"------1d4d6e3691ad1d406627387334448b------2f859d205f7c243a6d456eb23a93b22a668e1e356a2f41874567b2468ec84a9dce4da6d451c7e245cbdd44cadc4ecae34eaad54b9bce488ec64968b53143861b2f6523527c3c8fae4677b329407a1e51713288a3------304080293e76------348ca91f5978------",
|
|
776
|
+
"------1d4a6f348dac1c3f65253670324388------2e839c205f7e2639703c509b265a843c9bb6327da01f3e702333732b3c853341964160b150a5d644b8d741b9d54badd74066b4314196293883222e711e366f2e70973f9eba2c60893a5196283c771d52713186a1------2f3c7e293a73------338aa51f5479------",
|
|
777
|
+
"------1b4a6e348bac------24366f32418a------2c819d1f5d7e24376e394a941b406a2d7c9b40aec6358bad2859892136742a387f3545963f6db73e8dc53c90c53d75b834469a29358220306e2551843586a943a9c2398fab204570384b92273d771e50732e82a1------2e3b7d293872------3189a51f5379------",
|
|
778
|
+
"------1c4a6e3287aa------243570313f8a------2a7e9c1f5b7d22346935458d------266d8d2a6d9022567f378bb13784ae26448027357e2e47933066a63169a72c499326337e243e7c3781aa3a90b3255a82245d812f76961d406735478d263a761d4e6f2d7e9f------2d3a7c283874------3085a81c5279------",
|
|
779
|
+
"------1c486e3284ab1c3d68263472303f89------287c991e597a213268324089------266d8d2a698d------1b376528638d3285ae2b5f952745832857902958922543832a58903586ad2c6e951c3b6a------24587c2e7595------3243892437711c4c6f2c7a9d------2c3a7e283875------2f82a71d507a------",
|
|
780
|
+
"------1b47703081ab1d3c6726347430408b------2777981d567920316731418a------266b8b286588------------------2148752d6f9c2b6093244c80244b7f2a5c912e729f244f7b------------------2256792d7395------3041872436711c486e28779b------2d3b80293879------2f7fa61b5077------",
|
|
781
|
+
"------1a446f2d7fac1e3d6e27367b303f8f------26749a1d547b21326a30408c------26658a275f82------------------------------------1d3c681e3d6b1c3465------------------------------2051732c6f92------2e3e862335721a466c257499------2d3d8329387b------2f7da81b4f78------",
|
|
782
|
+
"------1a436f2e7eae1f3e6f28377f2e3d8e183b652c86ae22678f1f336a2c3b87------246088275d82------------------------------------------------------------------------------------1f50712a6b8e------2b397f21346f1f5a832a87b01a3f662c39852838811d38692f7eaa1b4c77------",
|
|
783
|
+
"------1b426e2e7fae1f4171263c803161ab2c7fa92a779f2a74a02e62a02b3d8b------266088255c84------------------------------------273b72293e77------------------------------------1f4f762a6a90------28387d2d609d2876a027719b2d7fa83364ae2940871d3d722f7dae1b4a77------",
|
|
784
|
+
"------1a3f6a2e7daf22578b2f77b23a80c9285090------------2744842e4295------26618f275c85------------------------------------2736722a3878------------------------------------204f772b6b92------2b3b84284585------------274385417ecd337db9255990317cb21b4674------",
|
|
785
|
+
"------1b3c693690c6368bc22057881d37732d3b902c3a8f------1c2a662e398e------265d8b265780------------------------------------262f73293178------------------------------------1f4972296790------2c3684202a67------2c3587333d952438782251853487bb3d9ad21b4371------",
|
|
786
|
+
"------------2b659b3585c021568b------------202e72333d98313c972d378e------245784275483283072252e6f------------------------262d74292e79------------------------222c6729327521487a275f8b------2c3484333a9438409a28337a------------2152853583bc306fa4------------",
|
|
787
|
+
"------------------1d41722d71a73172ac1e4376------------2f368c2f3696------235486244f80------373c92353a90------------------272c7b292d7f------------------313788383e95222b681e4373275b8b------2c3586343893------------1a3c6f2d6ea73075ae214678------------------",
|
|
788
|
+
"------------------------------235087347bb52a699f------------2c378f1b346a285c95254f85------------24296937399729307b------272c7b292d7f------252b6f353891282d71------------1e42742b62991b376a2b36881e2c6a------265d96347bb6265a92------------------------------",
|
|
789
|
+
"------------------------------------------255a912e74ae2c589c3250a62c629d2f66a5283a87232770------------------33368e37379934349932329635359235358e------------------23286f2838842d64a22e66a13451a42b52992a6faa255c951a3667------------------------------------",
|
|
790
|
+
"------------------------------------------------1d3b6f2f69aa3475be27538c------------24277031318e252872------------------3834a03934a3222568------------22276a32338e282b76------------234c803672b6306cab1d3b6f------------------------------------------------",
|
|
791
|
+
"------------------------------------------------------------------------------------------------2e2d842a2b7b------------262673282877------------282972302f86------------------------------------------------------------------------------------------------",
|
|
792
|
+
"------------------------------------------------------------------------292d72413ea3323285------------------302f862b2b7d2d2b892e2b892a2a7a302f87262468------------2d2c7d3e3ca02b2f7b------------------------------------------------------------------------",
|
|
793
|
+
"------------------------------------------------------------------------------------34328a3b369b272770------------2d2a7e4037ac4136b0302c86------------2524673a3397363390------------------------------------------------------------------------------------",
|
|
794
|
+
"------------------------------------------------------------------------------------------2325653a3599353290------------2a26782c277c------------322e853e379d2a2870------------------------------------------------------------------------------------------",
|
|
795
|
+
"------------------------------------------------------------------------------------------------------332e883c35a02e2a812f2a84302a852c277939329634308a------------------------------------------------------------------------------------------------------",
|
|
796
|
+
"------------------------------------------------------------------------------------------------------------------362f914338b54438b7382f96262469------------------------------------------------------------------------------------------------------------",
|
|
797
|
+
"------------------------------------------------------------------------------------------------------------------------252369242369------------------------------------------------------------------------------------------------------------------------",
|
|
798
|
+
"------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
|
799
|
+
],
|
|
800
|
+
[
|
|
801
|
+
"------------------------------------------------------------------------------------------------------------------------------------------------------------------------",
|
|
802
|
+
"------------------------------------------------------------------------215c79368ea13790a4225f7b------------------------------------------------------------------------",
|
|
803
|
+
"------------------------------------------------------------1a47652f728f439cae3fa0b03d9eb0409aac3077911c4967------------------------------------------------------------",
|
|
804
|
+
"------------------------------------------------------235e7c3d8fa23d8da1226582308ca4318fa52267843a899f3d90a326637f------------------------------------------------------",
|
|
805
|
+
"------------------------------------------2255713c889d4294a527627b1e536e2d7d963594aa3594a82e7f981e5772255e773e8ea13c8ba0225a77------------------------------------------",
|
|
806
|
+
"------------------------------------2b6d834294a63172881e526e2a6c862c738a1b4d6926738c26758d1a4d67276f87286f891d53712d6e873d93a52b6e85------------------------------------",
|
|
807
|
+
"------------------------2560794a9eaf53b1bf205d79205b772a738b215771------2a738d28728a276f8729728b------1f536f2a738d21627d1c5e7a44a9ba43a1b027657c------------------------",
|
|
808
|
+
"------------1c4b673282973b90a42864812d6e85439dad3689a0194a66225b772d78911d4a66------------------2d778f25617a1a486532839b409fb02b738a24617d388da13584971e4f6a------------",
|
|
809
|
+
"------225c764ebdca42aaba2779922668831c4b671f4f6b38849942a0b02e7c93------------------------------------30799144a4b33a8a9e1f526d------2467812779923da4b651c1cd286880------",
|
|
810
|
+
"------338aa056cee33ca1b326738a21607c328097296982------23597339869a30728a------------------------306e843b8899276076------28677f34829720627b2570883ca0b255cbe13a97ac------",
|
|
811
|
+
"------30819a3e83b131679539879e2d7089------25627b2f7a91225976------2b69843076901d51711d4f702f758e2f6e86------1e526c2f7a8c27667d------2c6e863c8c9f326a933d7fb0328ca1------",
|
|
812
|
+
"------2d7b912c6088344989------2e6c8738889d2156731a47672c728d317a96204f731c486c1f577b1f567b1d4a6e1e4d703078922e758e1849651d536d3a8a9e33748c------3247852a5c8530859a------",
|
|
813
|
+
"------2c798e24567a344483------------1d446639819a3a829c225175215477307b9828698c2d6c902d6c9129688c347e9c2258791d4e6f37809639839a1f4667------------3345812251732e8396------",
|
|
814
|
+
"------2c778f2353752f3e7e20436c498ebc3f549c213b6c2a5f7f3a89a3317394244f7d2d6a99449ebd449fbd2d6e9b214e792e728f3d8ca32c6483213d6a3c50954b8dbf1f4266313f7c214e6e2e8194------",
|
|
815
|
+
"------28738e225073304081214a723c8aad465da93648922d3e81234174336e994184b33a75ae4eb1cf4eb3d13976af3f81b032719620426c2b3d7a3c4a924d60af4289b022496e30417d204c6d2d7c94------",
|
|
816
|
+
"------28728e235075303f831f486d2b7c9327457a3159902e4383424f9f3a4f9c3c6dac4bb4d549c8dd47c6dd4db4d83e70ae394f993e4d9a2d3e7e30568c2f4b872f799520476b30417e204d6e2c7a95------",
|
|
817
|
+
"------2a6f8e234e752d3d7f1e456c2a7891233e703c74a82c6f9329437d39579f417ab74ca5d449c4dd47c7de4ca8d4407ab73b579f273f7b2a658b3c75a52740772b718d1d44682e3f7d1f496d2b7795------",
|
|
818
|
+
"------286b8e214d732b3b7c1c426b2877932338702d4e872f809d3388a927508427347c3a59a5408cc53f90c5385da825307a2649803380a43789a630518a253d75296e8e1c41682d3b7b1e466a297394------",
|
|
819
|
+
"------27698e214b752b3a7e1c406b26729221366928457a236283265f862c6b9530709e2947892d56982c5798284488306e9d2f709825567e2966872a477d243972276a8c1a3f682c3b7b1e466c286f96------",
|
|
820
|
+
"------27658e2149772b39801d3f6c246e8e20326726427a235f801e456c------1f47712a6091265287264f862a6091234b74------------27638328447b22356f2464891b3e692d3b7e204470266d94------",
|
|
821
|
+
"------24618e224a7c2c38851d416f2571981f3369253e7a21597c------------------------------1d3765------------------------245d7f25407821336e23688f1b416d2c3981214474256b95------",
|
|
822
|
+
"------24608e234c7f2d4e98276c9a28729a2a5692233c7522567c------------------------------------------------------------245a7f213b71295491256e97276a9830529b22487f266796------",
|
|
823
|
+
"------2461902d76ab2b66a52a4b93------213774243e7e23557f------------------------------222f66------------------------245a80253c78223874------2c438f3167a92f76ae28689b------",
|
|
824
|
+
"------214c7c317bb51c4375------2732812c358b253779214f7b243e71------------------1f2865212866------------------20386d22527e2437752f368a2c35821e2a651d4173327cb2255383------",
|
|
825
|
+
"------------1a3765285f96275d92------252c7524367d214d7e203a6c272c6e2c327e------20276a21276a------2b317a282f721c3365234f7f243779272f7a------25588e29629a1d3b69------------",
|
|
826
|
+
"------------------------1f44762860982a56982e579e295591212e70------------2e31823233933131912c2f7f------------212c6e2855902e579e2a5396265e9620477b------------------------",
|
|
827
|
+
"------------------------------------254b85264a83------------27287524286d------282878282979------222667272a75------------24467c244b83------------------------------------",
|
|
828
|
+
"------------------------------------------------272b6e323284------------2728722d2b842d2b83292873------------2e2f7f282d73------------------------------------------------",
|
|
829
|
+
"------------------------------------------------------------312e832b2a79------2f2a842f2b87------2a2873332f85232565------------------------------------------------------",
|
|
830
|
+
"------------------------------------------------------------------292872312d86302b87302a84302b802b2973------------------------------------------------------------------",
|
|
831
|
+
"------------------------------------------------------------------------------322b88332c8a------------------------------------------------------------------------------",
|
|
832
|
+
"------------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
|
833
|
+
]
|
|
834
|
+
];
|
|
835
|
+
|
|
836
|
+
// src/dashboard/view.ts
|
|
837
|
+
var DASHBOARD_TABS = ["Overview", "Players", "Logs", "HTTP"];
|
|
838
|
+
var WIDE_LAYOUT = 100;
|
|
839
|
+
var NEWLINE_RE = /\r?\n/;
|
|
840
|
+
var STATUS_LABELS = {
|
|
841
|
+
connecting: "CONNECTING",
|
|
842
|
+
live: "LIVE",
|
|
843
|
+
unreachable: "UNREACHABLE",
|
|
844
|
+
unauthorized: "UNAUTHORIZED",
|
|
845
|
+
forbidden: "FORBIDDEN",
|
|
846
|
+
disabled: "METRICS DISABLED"
|
|
847
|
+
};
|
|
848
|
+
function tabForKey(key) {
|
|
849
|
+
const index = Number(key) - 1;
|
|
850
|
+
return Number.isInteger(index) && index >= 0 && index < DASHBOARD_TABS.length ? index : null;
|
|
851
|
+
}
|
|
852
|
+
function sortedPlayers(players) {
|
|
853
|
+
return [...players].sort((a, b) => a.id.localeCompare(b.id));
|
|
854
|
+
}
|
|
855
|
+
function handlePlayersKey(view, snapshot, key) {
|
|
856
|
+
if (DASHBOARD_TABS[view.tab] !== "Players" || !snapshot)
|
|
857
|
+
return false;
|
|
858
|
+
if (key === "escape") {
|
|
859
|
+
const had = view.selectedPlayerId !== null;
|
|
860
|
+
view.selectedPlayerId = null;
|
|
861
|
+
return had;
|
|
862
|
+
}
|
|
863
|
+
const delta = key === "down" || key === "j" ? 1 : key === "up" || key === "k" ? -1 : 0;
|
|
864
|
+
const rows = sortedPlayers(snapshot.players);
|
|
865
|
+
if (delta === 0 || rows.length === 0)
|
|
866
|
+
return false;
|
|
867
|
+
const current = rows.findIndex((p) => p.id === view.selectedPlayerId);
|
|
868
|
+
const next = current === -1 ? delta > 0 ? 0 : rows.length - 1 : Math.max(0, Math.min(rows.length - 1, current + delta));
|
|
869
|
+
view.selectedPlayerId = rows[next].id;
|
|
870
|
+
view.followSelectedPlayer = true;
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
function statusColor(status, theme) {
|
|
874
|
+
if (status === "live")
|
|
875
|
+
return theme.success;
|
|
876
|
+
if (status === "connecting")
|
|
877
|
+
return theme.warning;
|
|
878
|
+
return theme.danger;
|
|
879
|
+
}
|
|
880
|
+
function renderDashboard(ui, theme, state, view, { transparent = false, startup, openKey = false } = {}) {
|
|
881
|
+
const { snapshot } = state;
|
|
882
|
+
const starting = !snapshot && !!startup && (state.status === "connecting" || state.status === "unreachable");
|
|
883
|
+
ui.column({}, (root) => {
|
|
884
|
+
header(root, theme, state);
|
|
885
|
+
if (snapshot?.latencySimulation)
|
|
886
|
+
latencyBanner(root, theme, snapshot.latencySimulation);
|
|
887
|
+
if (state.error && snapshot)
|
|
888
|
+
root.text([{ text: ` ${state.error} \u2014 showing last known values`, fg: theme.danger }], { size: 1 });
|
|
889
|
+
if (starting) {
|
|
890
|
+
startupScreen(root, theme, startup);
|
|
891
|
+
} else if (!snapshot) {
|
|
892
|
+
waiting(root, theme, state);
|
|
893
|
+
} else {
|
|
894
|
+
root.tabs({
|
|
895
|
+
tabs: DASHBOARD_TABS.map((name, i) => `${i + 1} ${name}`),
|
|
896
|
+
active: view.tab,
|
|
897
|
+
size: 1,
|
|
898
|
+
onSelect: (index) => {
|
|
899
|
+
view.tab = index;
|
|
900
|
+
root.ctx.invalidate();
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
const tab = DASHBOARD_TABS[view.tab] ?? "Overview";
|
|
904
|
+
if (tab === "Players") {
|
|
905
|
+
playersTab(root, theme, snapshot, view, transparent);
|
|
906
|
+
} else if (tab === "Logs") {
|
|
907
|
+
logsPanel(root, theme, state, view);
|
|
908
|
+
} else if (tab === "HTTP") {
|
|
909
|
+
requestsPanel(root, theme, state, view, transparent);
|
|
910
|
+
} else {
|
|
911
|
+
root.responsive({
|
|
912
|
+
[WIDE_LAYOUT]: (c) => wideLayout(c, theme, state, snapshot, view, transparent),
|
|
913
|
+
0: (c) => narrowLayout(c, theme, state, snapshot, view, transparent)
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
root.statusBar({
|
|
918
|
+
size: 1,
|
|
919
|
+
background: transparent ? DEFAULT_COLOR : void 0,
|
|
920
|
+
items: [
|
|
921
|
+
{ key: "q", label: "Quit" },
|
|
922
|
+
{ key: "r", label: "Refresh" },
|
|
923
|
+
{ key: `1-${DASHBOARD_TABS.length}`, label: "Tabs" },
|
|
924
|
+
...openKey && snapshot ? [{ key: "o", label: "Open game" }] : [],
|
|
925
|
+
...snapshot && DASHBOARD_TABS[view.tab] === "Players" ? [{ key: "\u2191\u2193", label: "Select player" }, ...view.selectedPlayerId ? [{ key: "esc", label: "Close" }] : []] : []
|
|
926
|
+
],
|
|
927
|
+
right: [starting ? { label: "STARTING", color: statusColor("connecting", theme) } : { label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === "live" }]
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
function header(ui, theme, state) {
|
|
932
|
+
const spans = [
|
|
933
|
+
{ text: " MavonEngine ", fg: theme.primary, bold: true },
|
|
934
|
+
{ text: state.source, fg: theme.muted }
|
|
935
|
+
];
|
|
936
|
+
if (state.snapshot) {
|
|
937
|
+
spans.push(
|
|
938
|
+
{ text: " uptime ", fg: theme.muted },
|
|
939
|
+
{ text: formatDuration(state.snapshot.uptime) },
|
|
940
|
+
{ text: " engine ", fg: theme.muted },
|
|
941
|
+
{ text: state.snapshot.engineVersion }
|
|
942
|
+
);
|
|
943
|
+
if (state.snapshot.schemaVersion > SUPPORTED_SCHEMA_VERSION)
|
|
944
|
+
spans.push({ text: " newer metrics schema, update @mavonengine/cli", fg: theme.warning });
|
|
945
|
+
}
|
|
946
|
+
ui.text(spans, { size: 1 });
|
|
947
|
+
}
|
|
948
|
+
function latencyBanner(ui, theme, latency) {
|
|
949
|
+
ui.text([{
|
|
950
|
+
text: ` LATENCY SIMULATION ON ${latency.fixedLatency}ms \xB1 ${latency.jitter / 2}ms, ${formatPercent(latency.packetLoss * 100)} packet loss `,
|
|
951
|
+
fg: theme.background,
|
|
952
|
+
bg: theme.warning,
|
|
953
|
+
bold: true
|
|
954
|
+
}], { size: 1 });
|
|
955
|
+
}
|
|
956
|
+
var STARTUP_OUTPUT_LINES = 8;
|
|
957
|
+
function logoLines(pixels) {
|
|
958
|
+
const pixel = (row, x) => {
|
|
959
|
+
const value = row?.slice(x * 6, x * 6 + 6);
|
|
960
|
+
return value && value !== "------" ? hex(Number.parseInt(value, 16)) : null;
|
|
961
|
+
};
|
|
962
|
+
const lines = [];
|
|
963
|
+
for (let y = 0; y < pixels.length; y += 2) {
|
|
964
|
+
const line = [];
|
|
965
|
+
for (let x = 0; x < pixels[y].length / 6; x++) {
|
|
966
|
+
const top = pixel(pixels[y], x);
|
|
967
|
+
const bottom = pixel(pixels[y + 1], x);
|
|
968
|
+
if (top !== null)
|
|
969
|
+
line.push({ text: "\u2580", fg: top, bg: bottom ?? void 0 });
|
|
970
|
+
else if (bottom !== null)
|
|
971
|
+
line.push({ text: "\u2584", fg: bottom });
|
|
972
|
+
else
|
|
973
|
+
line.push({ text: " " });
|
|
974
|
+
}
|
|
975
|
+
lines.push(line);
|
|
976
|
+
}
|
|
977
|
+
return lines;
|
|
978
|
+
}
|
|
979
|
+
var LOGOS = LOGO_SIZES.map(logoLines);
|
|
980
|
+
function startupScreen(ui, theme, { engineVersion, output, label = "Starting the game server\u2026", outputTitle = "Dev script" }) {
|
|
981
|
+
const lines = stripVTControlCharacters(output).split(NEWLINE_RE).filter((line) => line.trim() !== "");
|
|
982
|
+
const banner = bootBanner({ version: engineVersion ?? "unknown", mode: "development" });
|
|
983
|
+
ui.column({ size: "1fr" }, (column) => {
|
|
984
|
+
const { unicode, colors } = column.ctx.capabilities;
|
|
985
|
+
const logo = unicode && (colors === "truecolor" || colors === "ansi256") ? LOGOS.find((l) => l.length + banner.length + 3 <= column.height && l[0].length <= column.width) : void 0;
|
|
986
|
+
column.spacer("1fr");
|
|
987
|
+
if (logo) {
|
|
988
|
+
for (const line of logo)
|
|
989
|
+
column.text(line, { align: "center", size: 1 });
|
|
990
|
+
column.spacer(1);
|
|
991
|
+
}
|
|
992
|
+
for (const line of banner)
|
|
993
|
+
column.text([{ text: line, fg: theme.primary }], { align: "center", size: 1 });
|
|
994
|
+
column.spacer(1);
|
|
995
|
+
column.row({ size: 1 }, (row) => {
|
|
996
|
+
row.spacer("1fr");
|
|
997
|
+
row.spinner({ label, size: label.length + 2 });
|
|
998
|
+
row.spacer("1fr");
|
|
999
|
+
});
|
|
1000
|
+
column.spacer("1fr");
|
|
1001
|
+
});
|
|
1002
|
+
ui.panel({ title: outputTitle, size: STARTUP_OUTPUT_LINES + 2 }, (panel) => {
|
|
1003
|
+
if (lines.length === 0) {
|
|
1004
|
+
panel.label("No output yet");
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
panel.log({ entries: lines.slice(-STARTUP_OUTPUT_LINES).map((message) => ({ message })), size: "1fr" });
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
function waiting(ui, theme, state) {
|
|
1011
|
+
ui.panel({ title: "Game server", size: "1fr" }, (panel) => {
|
|
1012
|
+
if (state.status === "connecting") {
|
|
1013
|
+
panel.spinner({ label: `Connecting to ${state.source}` });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
panel.text([{ text: STATUS_LABELS[state.status], fg: statusColor(state.status, theme), bold: true }]);
|
|
1017
|
+
panel.text(state.error ?? "", { wrap: true });
|
|
1018
|
+
panel.label(`Retrying (attempt ${state.failures})\u2026`);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
function wideLayout(ui, theme, state, snapshot, view, transparent) {
|
|
1022
|
+
ui.grid({ columns: ["1fr", "1fr"], rows: [11, 11], size: 22 }, (grid) => {
|
|
1023
|
+
grid.panel({ title: "CPU" }, (p) => cpuPanel(p, theme, snapshot));
|
|
1024
|
+
grid.panel({ title: "Network" }, (p) => networkPanel(p, theme, snapshot));
|
|
1025
|
+
grid.panel({ title: "Tick" }, (p) => tickPanel(p, theme, snapshot));
|
|
1026
|
+
grid.panel({ title: "World" }, (p) => worldPanel(p, theme, snapshot));
|
|
1027
|
+
});
|
|
1028
|
+
ui.row({ size: "1fr" }, (row) => {
|
|
1029
|
+
playersPanel(row, theme, snapshot, view, transparent);
|
|
1030
|
+
logsPanel(row, theme, state, view);
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
function narrowLayout(ui, theme, state, snapshot, view, transparent) {
|
|
1034
|
+
ui.panel({ title: "CPU", size: 9 }, (p) => cpuPanel(p, theme, snapshot));
|
|
1035
|
+
ui.panel({ title: "Network", size: 9 }, (p) => networkPanel(p, theme, snapshot));
|
|
1036
|
+
ui.panel({ title: "Tick", size: 9 }, (p) => tickPanel(p, theme, snapshot));
|
|
1037
|
+
ui.panel({ title: "World", size: 11 }, (p) => worldPanel(p, theme, snapshot));
|
|
1038
|
+
playersPanel(ui, theme, snapshot, view, transparent);
|
|
1039
|
+
logsPanel(ui, theme, state, view);
|
|
1040
|
+
}
|
|
1041
|
+
function cpuPanel(ui, theme, { cpu }) {
|
|
1042
|
+
const values = cpu.history.map((h) => h.cpuPercentage);
|
|
1043
|
+
ui.graph({
|
|
1044
|
+
values,
|
|
1045
|
+
min: 0,
|
|
1046
|
+
max: Math.max(100, ...values),
|
|
1047
|
+
fill: true,
|
|
1048
|
+
colors: theme.heat,
|
|
1049
|
+
axis: true,
|
|
1050
|
+
axisFormat: (v) => `${Math.round(v)}%`,
|
|
1051
|
+
size: "1fr"
|
|
1052
|
+
});
|
|
1053
|
+
ui.meters([
|
|
1054
|
+
{ label: "1s", value: cpu.avg1s, max: 100, text: formatPercent(cpu.avg1s) },
|
|
1055
|
+
{ label: "10s", value: cpu.avg10s, max: 100, text: formatPercent(cpu.avg10s) },
|
|
1056
|
+
{ label: "1m", value: cpu.avg60s, max: 100, text: formatPercent(cpu.avg60s) }
|
|
1057
|
+
], { columns: 3 });
|
|
1058
|
+
}
|
|
1059
|
+
function networkPanel(ui, theme, { bandwidth, sampleIntervalMs }) {
|
|
1060
|
+
const perSecond = 1e3 / sampleIntervalMs;
|
|
1061
|
+
ui.multiGraph([
|
|
1062
|
+
{ label: "sent", values: bandwidth.history.map((b) => b.sent * perSecond), color: theme.graph[0] },
|
|
1063
|
+
{ label: "received", values: bandwidth.history.map((b) => b.received * perSecond), color: theme.graph[1] }
|
|
1064
|
+
], {
|
|
1065
|
+
min: 0,
|
|
1066
|
+
axis: true,
|
|
1067
|
+
axisFormat: formatRate,
|
|
1068
|
+
legend: true,
|
|
1069
|
+
legendAlign: "right",
|
|
1070
|
+
size: "1fr"
|
|
1071
|
+
});
|
|
1072
|
+
ui.keyValues([
|
|
1073
|
+
{ label: "Sent", value: formatRate(bandwidth.sentPerSecond), color: theme.graph[0] },
|
|
1074
|
+
{ label: "Received", value: formatRate(bandwidth.receivedPerSecond), color: theme.graph[1] }
|
|
1075
|
+
], { spread: true });
|
|
1076
|
+
}
|
|
1077
|
+
function tickPanel(ui, theme, { tick, tickRate }) {
|
|
1078
|
+
const avg = tick.history.map((t) => t.avgMs);
|
|
1079
|
+
const max = tick.history.map((t) => t.maxMs);
|
|
1080
|
+
const latest = tick.latest;
|
|
1081
|
+
ui.multiGraph([
|
|
1082
|
+
{ label: "avg", values: avg, color: theme.graph[0] },
|
|
1083
|
+
{ label: "max", values: max, color: theme.graph[2] ?? theme.warning }
|
|
1084
|
+
], {
|
|
1085
|
+
min: 0,
|
|
1086
|
+
max: Math.max(tick.budgetMs * 1.25, ...max, 1),
|
|
1087
|
+
axis: true,
|
|
1088
|
+
axisFormat: (v) => `${v.toFixed(0)}ms`,
|
|
1089
|
+
legend: true,
|
|
1090
|
+
legendAlign: "right",
|
|
1091
|
+
size: "1fr"
|
|
1092
|
+
});
|
|
1093
|
+
ui.meter({
|
|
1094
|
+
label: "Budget",
|
|
1095
|
+
value: latest?.avgMs ?? 0,
|
|
1096
|
+
max: tick.budgetMs || 1,
|
|
1097
|
+
text: `${formatMs(latest?.avgMs ?? 0)} / ${formatMs(tick.budgetMs)}`
|
|
1098
|
+
});
|
|
1099
|
+
ui.keyValues([
|
|
1100
|
+
{ label: "Ticks/s", value: `${latest?.ticksPerSecond ?? 0} / ${tickRate}`, color: (latest?.ticksPerSecond ?? 0) < tickRate * 0.9 ? theme.danger : void 0 },
|
|
1101
|
+
{ label: "Commands/s", value: `${latest?.commandsPerSecond ?? 0} (max ${latest?.maxCommandsPerTick ?? 0}/tick)` }
|
|
1102
|
+
], { spread: true });
|
|
1103
|
+
}
|
|
1104
|
+
function worldPanel(ui, theme, { players, world, physics, memory }) {
|
|
1105
|
+
ui.keyValues([
|
|
1106
|
+
{ label: "Players", value: String(players.length), color: theme.primary },
|
|
1107
|
+
{ label: "Entities", value: String(world.entities) },
|
|
1108
|
+
{ label: "Physics bodies", value: physics ? String(physics.bodies) : "\u2014" },
|
|
1109
|
+
{ label: "Colliders", value: physics ? String(physics.colliders) : "\u2014" },
|
|
1110
|
+
{ label: "RSS", value: formatBytes(memory.rss) },
|
|
1111
|
+
{ label: "External", value: formatBytes(memory.external) }
|
|
1112
|
+
], { spread: true });
|
|
1113
|
+
ui.spacer("1fr");
|
|
1114
|
+
ui.meter({
|
|
1115
|
+
label: "Heap",
|
|
1116
|
+
value: memory.heapUsed,
|
|
1117
|
+
max: memory.heapTotal || 1,
|
|
1118
|
+
text: `${formatBytes(memory.heapUsed)} / ${formatBytes(memory.heapTotal)}`
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
function playersTab(ui, theme, snapshot, view, transparent) {
|
|
1122
|
+
const selected = snapshot.players.find((p) => p.id === view.selectedPlayerId);
|
|
1123
|
+
if (!selected) {
|
|
1124
|
+
view.selectedPlayerId = null;
|
|
1125
|
+
playersPanel(ui, theme, snapshot, view, transparent, true);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
ui.responsive({
|
|
1129
|
+
[WIDE_LAYOUT]: (c) => c.row({ size: "1fr" }, (row) => {
|
|
1130
|
+
playersPanel(row, theme, snapshot, view, transparent, true);
|
|
1131
|
+
playerDetails(row, theme, selected, 42);
|
|
1132
|
+
}),
|
|
1133
|
+
0: (c) => {
|
|
1134
|
+
playersPanel(c, theme, snapshot, view, transparent, true);
|
|
1135
|
+
playerDetails(c, theme, selected, 13);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
function playerDetails(ui, theme, player, size) {
|
|
1140
|
+
const { connection, position } = player;
|
|
1141
|
+
ui.panel({ title: player.displayName, size }, (panel) => {
|
|
1142
|
+
panel.keyValues([
|
|
1143
|
+
{ label: "ID", value: player.id },
|
|
1144
|
+
{ label: "Connected", value: formatDuration(player.connectedFor) },
|
|
1145
|
+
{ label: "IP", value: connection ? connection.address ?? "pending" : "\u2014", color: theme.primary },
|
|
1146
|
+
{ label: "RTT", value: connection?.rttMs != null ? formatMs(connection.rttMs) : "\u2014" },
|
|
1147
|
+
{ label: "Sent", value: connection ? formatBytes(connection.bytesSent) : "\u2014" },
|
|
1148
|
+
{ label: "Received", value: connection ? formatBytes(connection.bytesReceived) : "\u2014" },
|
|
1149
|
+
{ label: "Position", value: [position.x, position.y, position.z].map((n) => n.toFixed(2)).join(", ") },
|
|
1150
|
+
{ label: "Last seq", value: String(player.lastProcessedSequenceId) },
|
|
1151
|
+
{ label: "Tracked", value: String(player.trackedEntities) }
|
|
1152
|
+
], { spread: true });
|
|
1153
|
+
if (!connection)
|
|
1154
|
+
panel.label("No live connection (a bot, or an older server)");
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
function playersPanel(ui, theme, { players }, view, transparent, selectable = false) {
|
|
1158
|
+
const rows = sortedPlayers(players);
|
|
1159
|
+
const maxOffset = Math.max(0, rows.length - 1);
|
|
1160
|
+
view.playerOffset = Math.min(view.playerOffset, maxOffset);
|
|
1161
|
+
const showNames = rows.some((p) => p.displayName !== p.id);
|
|
1162
|
+
ui.panel({ title: `Players (${rows.length})`, size: "1fr" }, (panel) => {
|
|
1163
|
+
if (rows.length === 0) {
|
|
1164
|
+
panel.label("No players connected");
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
panel.table({
|
|
1168
|
+
rows,
|
|
1169
|
+
offset: view.playerOffset,
|
|
1170
|
+
selected: selectable ? rows.findIndex((p) => p.id === view.selectedPlayerId) : -1,
|
|
1171
|
+
followSelection: selectable && view.followSelectedPlayer,
|
|
1172
|
+
// Following the selection can move the window, so keep our offset in step with what is drawn
|
|
1173
|
+
onRow: (_row, index, y) => {
|
|
1174
|
+
if (y === 1)
|
|
1175
|
+
view.playerOffset = index;
|
|
1176
|
+
},
|
|
1177
|
+
onSelectRow: selectable ? (visibleRow) => {
|
|
1178
|
+
const row = rows[view.playerOffset + visibleRow];
|
|
1179
|
+
if (!row)
|
|
1180
|
+
return;
|
|
1181
|
+
view.selectedPlayerId = row.id === view.selectedPlayerId ? null : row.id;
|
|
1182
|
+
ui.ctx.invalidate();
|
|
1183
|
+
} : void 0,
|
|
1184
|
+
scrollbar: true,
|
|
1185
|
+
// Zebra stripes are solid fills, which would break transparency
|
|
1186
|
+
zebra: !transparent,
|
|
1187
|
+
size: "1fr",
|
|
1188
|
+
onScroll: (delta) => {
|
|
1189
|
+
view.playerOffset = Math.max(0, Math.min(maxOffset, view.playerOffset + delta));
|
|
1190
|
+
view.followSelectedPlayer = false;
|
|
1191
|
+
ui.ctx.invalidate();
|
|
1192
|
+
},
|
|
1193
|
+
columns: [
|
|
1194
|
+
...showNames ? [{ key: "displayName", title: "Name" }] : [],
|
|
1195
|
+
{ key: "id", title: "ID" },
|
|
1196
|
+
{ key: "x", title: "X", width: 9, align: "right", render: (p) => p.position.x.toFixed(2) },
|
|
1197
|
+
{ key: "y", title: "Y", width: 9, align: "right", render: (p) => p.position.y.toFixed(2) },
|
|
1198
|
+
{ key: "z", title: "Z", width: 9, align: "right", render: (p) => p.position.z.toFixed(2) },
|
|
1199
|
+
{ key: "lastProcessedSequenceId", title: "Seq", width: 10, align: "right" },
|
|
1200
|
+
{ key: "trackedEntities", title: "Tracked", width: 8, align: "right" }
|
|
1201
|
+
]
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
function formatTime(timestamp) {
|
|
1206
|
+
const date = new Date(timestamp);
|
|
1207
|
+
return [date.getHours(), date.getMinutes(), date.getSeconds()].map((n) => n.toString().padStart(2, "0")).join(":");
|
|
1208
|
+
}
|
|
1209
|
+
function toLogRows(lines) {
|
|
1210
|
+
return lines.flatMap(({ timestamp, level, message }) => {
|
|
1211
|
+
const parts = message.split(NEWLINE_RE);
|
|
1212
|
+
while (parts.length > 1 && parts[0].trim() === "")
|
|
1213
|
+
parts.shift();
|
|
1214
|
+
while (parts.length > 1 && parts.at(-1).trim() === "")
|
|
1215
|
+
parts.pop();
|
|
1216
|
+
return parts.map((part, i) => i === 0 ? { time: formatTime(timestamp), level, message: part } : { message: `${" ".repeat(15)}${part}` });
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
function logsPanel(ui, theme, state, view) {
|
|
1220
|
+
const rows = toLogRows(state.logs);
|
|
1221
|
+
view.logFromEnd = Math.min(view.logFromEnd, Math.max(0, rows.length - 1));
|
|
1222
|
+
const title = view.logFromEnd > 0 ? `Logs (scrolled back ${view.logFromEnd}, scroll down to follow)` : "Logs";
|
|
1223
|
+
ui.panel({ title, size: "1fr" }, (panel) => {
|
|
1224
|
+
if (state.logsAvailable === false) {
|
|
1225
|
+
panel.label("Not available: the server runs a @mavonengine/core release without GET /api/game/logs, or has metrics.logBufferSize set to 0");
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (rows.length === 0) {
|
|
1229
|
+
panel.label("No log output yet");
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
panel.log({
|
|
1233
|
+
entries: rows,
|
|
1234
|
+
fromEnd: view.logFromEnd,
|
|
1235
|
+
scrollbar: true,
|
|
1236
|
+
size: "1fr",
|
|
1237
|
+
levelColors: {
|
|
1238
|
+
VERBOSE: theme.muted,
|
|
1239
|
+
SILLY: theme.muted,
|
|
1240
|
+
HTTP: theme.info
|
|
1241
|
+
},
|
|
1242
|
+
onScroll: (delta) => {
|
|
1243
|
+
view.logFromEnd = Math.max(0, Math.min(rows.length - 1, view.logFromEnd - delta * 3));
|
|
1244
|
+
ui.ctx.invalidate();
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
function statusCodeColor(status, theme) {
|
|
1250
|
+
if (status === null || status >= 500)
|
|
1251
|
+
return theme.danger;
|
|
1252
|
+
if (status >= 400)
|
|
1253
|
+
return theme.warning;
|
|
1254
|
+
if (status >= 300)
|
|
1255
|
+
return theme.info;
|
|
1256
|
+
return theme.success;
|
|
1257
|
+
}
|
|
1258
|
+
function requestsPanel(ui, theme, state, view, transparent) {
|
|
1259
|
+
const rows = [...state.requests].reverse();
|
|
1260
|
+
const anchor = view.requestTopId === null ? 0 : rows.findIndex((r) => r.id <= view.requestTopId);
|
|
1261
|
+
const offset = Math.max(0, anchor);
|
|
1262
|
+
const errors = rows.filter((r) => r.status === null || r.status >= 400).length;
|
|
1263
|
+
const title = `HTTP requests (${rows.length}${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""})${offset > 0 ? ", scroll up to follow" : ""}`;
|
|
1264
|
+
ui.panel({ title, size: "1fr" }, (panel) => {
|
|
1265
|
+
if (state.requestsAvailable === false) {
|
|
1266
|
+
panel.label("Not available: the server runs a @mavonengine/core release without GET /api/game/requests, or has metrics.requestLogSize set to 0");
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (rows.length === 0) {
|
|
1270
|
+
panel.label("No requests yet");
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
panel.table({
|
|
1274
|
+
rows,
|
|
1275
|
+
offset,
|
|
1276
|
+
scrollbar: true,
|
|
1277
|
+
// Zebra stripes are solid fills, which would break transparency
|
|
1278
|
+
zebra: !transparent,
|
|
1279
|
+
size: "1fr",
|
|
1280
|
+
// The table won't scroll past the last full page, so keep the anchor on the row it actually drew
|
|
1281
|
+
onRow: (_row, index, y) => {
|
|
1282
|
+
if (y === 1)
|
|
1283
|
+
view.requestTopId = index === 0 ? null : rows[index].id;
|
|
1284
|
+
},
|
|
1285
|
+
onScroll: (delta) => {
|
|
1286
|
+
const next = Math.max(0, Math.min(rows.length - 1, offset + delta));
|
|
1287
|
+
view.requestTopId = next === 0 ? null : rows[next].id;
|
|
1288
|
+
ui.ctx.invalidate();
|
|
1289
|
+
},
|
|
1290
|
+
columns: [
|
|
1291
|
+
{ key: "time", title: "Time", width: 8, color: theme.muted, render: (r) => formatTime(r.timestamp) },
|
|
1292
|
+
{ key: "method", title: "Method", width: 7, color: (r) => r.method === "WS" ? theme.primary : void 0 },
|
|
1293
|
+
{ key: "status", title: "Status", width: 6, align: "right", color: (r) => statusCodeColor(r.status, theme), render: (r) => r.status === null ? "\u2014" : String(r.status) },
|
|
1294
|
+
{ key: "durationMs", title: "Duration", width: 10, align: "right", render: (r) => formatMs(r.durationMs) },
|
|
1295
|
+
{ key: "bytes", title: "Size", width: 10, align: "right", render: (r) => formatBytes(r.bytes) },
|
|
1296
|
+
{ key: "address", title: "Client", width: 18, color: theme.muted, render: (r) => r.address ?? "\u2014" },
|
|
1297
|
+
{ key: "path", title: "Path" }
|
|
1298
|
+
]
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// src/commands/dashboard.ts
|
|
1304
|
+
var DASHBOARD_HELP = `Usage: mavonengine dashboard [options]
|
|
1305
|
+
|
|
1306
|
+
Live terminal dashboard for a running MavonEngine game server.
|
|
1307
|
+
|
|
1308
|
+
Options:
|
|
1309
|
+
--host <host> Server host (default: localhost)
|
|
1310
|
+
--port <port> Server HTTP port (default: 8050)
|
|
1311
|
+
--url <url> Full metrics URL, overrides --host and --port
|
|
1312
|
+
--token <token> Metrics token (or set MAVONENGINE_METRICS_TOKEN)
|
|
1313
|
+
--interval <ms> Poll interval in ms (default: 1000)
|
|
1314
|
+
--theme <name> dark, dracula, nord, tokyoNight, gruvbox, matrix, light, \u2026
|
|
1315
|
+
--transparent Let a transparent terminal background show through
|
|
1316
|
+
(text can be hard to read over a light wallpaper)
|
|
1317
|
+
--sim Show simulated data instead of connecting to a server
|
|
1318
|
+
-h, --help Show this help
|
|
1319
|
+
`;
|
|
1320
|
+
var STARTUP_POLL_MS = 250;
|
|
1321
|
+
var UsageError = class extends Error {
|
|
1322
|
+
};
|
|
1323
|
+
function parseDashboardArgs(args, env = process2.env) {
|
|
1324
|
+
const { values } = parseArgs({
|
|
1325
|
+
args,
|
|
1326
|
+
options: {
|
|
1327
|
+
host: { type: "string", default: "localhost" },
|
|
1328
|
+
port: { type: "string", default: "8050" },
|
|
1329
|
+
url: { type: "string" },
|
|
1330
|
+
token: { type: "string" },
|
|
1331
|
+
interval: { type: "string", default: "1000" },
|
|
1332
|
+
theme: { type: "string" },
|
|
1333
|
+
transparent: { type: "boolean", default: false },
|
|
1334
|
+
sim: { type: "boolean", default: false },
|
|
1335
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1336
|
+
},
|
|
1337
|
+
strict: true,
|
|
1338
|
+
allowPositionals: false
|
|
1339
|
+
});
|
|
1340
|
+
if (values.help)
|
|
1341
|
+
return "help";
|
|
1342
|
+
const intervalMs = Number(values.interval);
|
|
1343
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 100)
|
|
1344
|
+
throw new UsageError("--interval must be a whole number of ms, at least 100");
|
|
1345
|
+
if (values.sim)
|
|
1346
|
+
return { source: simulatedSource(), intervalMs, theme: values.theme, transparent: values.transparent };
|
|
1347
|
+
let url = values.url;
|
|
1348
|
+
if (!url) {
|
|
1349
|
+
const port = Number(values.port);
|
|
1350
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
1351
|
+
throw new UsageError("--port must be between 1 and 65535");
|
|
1352
|
+
const host = values.host.includes(":") && !values.host.startsWith("[") ? `[${values.host}]` : values.host;
|
|
1353
|
+
url = `http://${host}:${port}/api/game/metrics`;
|
|
1354
|
+
}
|
|
1355
|
+
try {
|
|
1356
|
+
void new URL(url);
|
|
1357
|
+
} catch {
|
|
1358
|
+
throw new UsageError(`Invalid --url: ${url}`);
|
|
1359
|
+
}
|
|
1360
|
+
const token = values.token ?? env.MAVONENGINE_METRICS_TOKEN;
|
|
1361
|
+
return { source: httpSource({ url, token }), clientUrl: new URL("/", url).href, intervalMs, theme: values.theme, transparent: values.transparent };
|
|
1362
|
+
}
|
|
1363
|
+
async function runDashboard(options, signal) {
|
|
1364
|
+
const { createApp } = await import("@profullstack/hqtui");
|
|
1365
|
+
const client = new MetricsClient(options.source, {
|
|
1366
|
+
intervalMs: options.intervalMs,
|
|
1367
|
+
startupIntervalMs: options.startup ? STARTUP_POLL_MS : void 0
|
|
1368
|
+
});
|
|
1369
|
+
const view = { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true };
|
|
1370
|
+
const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent });
|
|
1371
|
+
app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view, {
|
|
1372
|
+
transparent: options.transparent,
|
|
1373
|
+
startup: options.startup?.(),
|
|
1374
|
+
openKey: !!options.clientUrl
|
|
1375
|
+
}));
|
|
1376
|
+
client.onChange(() => app.invalidate());
|
|
1377
|
+
const { clientUrl } = options;
|
|
1378
|
+
if (clientUrl && options.openBrowser) {
|
|
1379
|
+
const off = client.onChange(async (state) => {
|
|
1380
|
+
if (state.status !== "live")
|
|
1381
|
+
return;
|
|
1382
|
+
off();
|
|
1383
|
+
if (await servesPage(clientUrl))
|
|
1384
|
+
openBrowser(clientUrl);
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
app.on("key", (event) => {
|
|
1388
|
+
if (event.key === "r")
|
|
1389
|
+
void client.refresh();
|
|
1390
|
+
if (event.key === "o" && options.clientUrl)
|
|
1391
|
+
openBrowser(options.clientUrl);
|
|
1392
|
+
const tab = tabForKey(event.key);
|
|
1393
|
+
if (tab !== null) {
|
|
1394
|
+
view.tab = tab;
|
|
1395
|
+
app.invalidate();
|
|
1396
|
+
}
|
|
1397
|
+
if (handlePlayersKey(view, client.state.snapshot, event.key))
|
|
1398
|
+
app.invalidate();
|
|
1399
|
+
});
|
|
1400
|
+
app.on("exit", () => client.stop());
|
|
1401
|
+
const onAbort = () => app.stop();
|
|
1402
|
+
signal?.addEventListener("abort", onAbort);
|
|
1403
|
+
client.start();
|
|
1404
|
+
if (!signal?.aborted)
|
|
1405
|
+
await app.start();
|
|
1406
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1407
|
+
client.stop();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/commands/dev.ts
|
|
1411
|
+
import { execFileSync, spawn as spawn2 } from "child_process";
|
|
1412
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
1413
|
+
import { constants } from "os";
|
|
1414
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
1415
|
+
import process3 from "process";
|
|
1416
|
+
import { setTimeout as delay } from "timers/promises";
|
|
1417
|
+
import { parseArgs as parseArgs2 } from "util";
|
|
1418
|
+
var DEV_HELP = `Usage: mavonengine dev [options]
|
|
1419
|
+
|
|
1420
|
+
Run the dev servers of the MavonEngine project you are in (game server and client)
|
|
1421
|
+
and show the dashboard for it. Once the game server is up, the game opens in the
|
|
1422
|
+
browser. Quitting the dashboard stops the dev servers.
|
|
1423
|
+
|
|
1424
|
+
Runs the project's 'dev:server' script, or its 'dev' script when there is none.
|
|
1425
|
+
So 'dev' can be 'mavonengine dev' itself, as in the multiplayer template.
|
|
1426
|
+
|
|
1427
|
+
Options:
|
|
1428
|
+
--cwd <dir> Start looking for the project here (default: current directory)
|
|
1429
|
+
--no-dashboard Only run the dev script, with its output in the terminal
|
|
1430
|
+
--port <port> Game server HTTP port for the dashboard (default: 8050)
|
|
1431
|
+
--theme <name> Dashboard theme, see 'mavonengine dashboard --help'
|
|
1432
|
+
--transparent Let a transparent terminal background show through
|
|
1433
|
+
--no-open Don't open the game in the browser once the server is up
|
|
1434
|
+
-h, --help Show this help
|
|
1435
|
+
`;
|
|
1436
|
+
var MAX_CAPTURED_OUTPUT = 64 * 1024;
|
|
1437
|
+
var STOP_TIMEOUT_MS = 3e3;
|
|
1438
|
+
var KILL_TIMEOUT_MS = 2e3;
|
|
1439
|
+
var LOCKFILES = [
|
|
1440
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1441
|
+
["yarn.lock", "yarn"],
|
|
1442
|
+
["bun.lock", "bun"],
|
|
1443
|
+
["bun.lockb", "bun"],
|
|
1444
|
+
["package-lock.json", "npm"]
|
|
1445
|
+
];
|
|
1446
|
+
var WHITESPACE_RE = /\s+/;
|
|
1447
|
+
var RUNS_MAVONENGINE_DEV_RE = /\bmavonengine\s+dev\b/;
|
|
1448
|
+
var DEV_ENV_FLAG = "MAVONENGINE_DEV_SCRIPT";
|
|
1449
|
+
var DEV_SERVER_SCRIPT = "dev:server";
|
|
1450
|
+
var DEV_FLAGS = {
|
|
1451
|
+
cwd: { type: "string" },
|
|
1452
|
+
dashboard: { type: "boolean", default: true },
|
|
1453
|
+
port: { type: "string" },
|
|
1454
|
+
theme: { type: "string" },
|
|
1455
|
+
transparent: { type: "boolean", default: false },
|
|
1456
|
+
open: { type: "boolean", default: true },
|
|
1457
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1458
|
+
};
|
|
1459
|
+
function parseDevArgs(args, cwd = process3.cwd(), env = process3.env) {
|
|
1460
|
+
const { values } = parseArgs2({
|
|
1461
|
+
args,
|
|
1462
|
+
options: DEV_FLAGS,
|
|
1463
|
+
strict: true,
|
|
1464
|
+
allowPositionals: false,
|
|
1465
|
+
allowNegative: true
|
|
1466
|
+
});
|
|
1467
|
+
if (values.help)
|
|
1468
|
+
return "help";
|
|
1469
|
+
return toDevOptions(values, cwd, env);
|
|
1470
|
+
}
|
|
1471
|
+
function toDevOptions(values, cwd, env) {
|
|
1472
|
+
let dashboard = false;
|
|
1473
|
+
if (values.dashboard) {
|
|
1474
|
+
const dashboardArgs = [
|
|
1475
|
+
...values.port ? ["--port", values.port] : [],
|
|
1476
|
+
...values.theme ? ["--theme", values.theme] : [],
|
|
1477
|
+
...values.transparent ? ["--transparent"] : []
|
|
1478
|
+
];
|
|
1479
|
+
dashboard = parseDashboardArgs(dashboardArgs, env);
|
|
1480
|
+
dashboard.openBrowser = values.open && canOpenBrowserHere(env);
|
|
1481
|
+
}
|
|
1482
|
+
return { cwd: resolve2(cwd, values.cwd ?? "."), dashboard };
|
|
1483
|
+
}
|
|
1484
|
+
function readPackageJson(dir) {
|
|
1485
|
+
const path = join2(dir, "package.json");
|
|
1486
|
+
if (!existsSync2(path))
|
|
1487
|
+
return void 0;
|
|
1488
|
+
try {
|
|
1489
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
1490
|
+
} catch {
|
|
1491
|
+
return void 0;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
function devScriptOf(pkg) {
|
|
1495
|
+
const scripts = pkg?.scripts ?? {};
|
|
1496
|
+
if (typeof scripts[DEV_SERVER_SCRIPT] === "string")
|
|
1497
|
+
return DEV_SERVER_SCRIPT;
|
|
1498
|
+
return typeof scripts.dev === "string" ? "dev" : void 0;
|
|
1499
|
+
}
|
|
1500
|
+
function checkDevScript(root, env = process3.env) {
|
|
1501
|
+
const pkg = readPackageJson(root);
|
|
1502
|
+
const script = devScriptOf(pkg);
|
|
1503
|
+
if (!script)
|
|
1504
|
+
throw new UsageError(`${join2(root, "package.json")} has no '${DEV_SERVER_SCRIPT}' or 'dev' script`);
|
|
1505
|
+
const command = String(pkg?.scripts?.[script] ?? "");
|
|
1506
|
+
if (env[DEV_ENV_FLAG] || RUNS_MAVONENGINE_DEV_RE.test(command)) {
|
|
1507
|
+
throw new UsageError(`the '${script}' script starts 'mavonengine dev' again. Put the command that starts the game server in a '${DEV_SERVER_SCRIPT}' script: 'mavonengine dev' runs that instead of 'dev'`);
|
|
1508
|
+
}
|
|
1509
|
+
return script;
|
|
1510
|
+
}
|
|
1511
|
+
function findProjectRoot(start) {
|
|
1512
|
+
for (let dir = resolve2(start); ; dir = dirname2(dir)) {
|
|
1513
|
+
const pkg = readPackageJson(dir);
|
|
1514
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
1515
|
+
if ("@mavonengine/core" in deps && devScriptOf(pkg))
|
|
1516
|
+
return dir;
|
|
1517
|
+
if (dirname2(dir) === dir)
|
|
1518
|
+
return void 0;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
function installedEngineVersion(root) {
|
|
1522
|
+
return readPackageJson(join2(root, "node_modules", "@mavonengine", "core"))?.version;
|
|
1523
|
+
}
|
|
1524
|
+
function detectPackageManager(root) {
|
|
1525
|
+
return LOCKFILES.find(([file]) => existsSync2(join2(root, file)))?.[1] ?? "npm";
|
|
1526
|
+
}
|
|
1527
|
+
function descendants(root, psOutput) {
|
|
1528
|
+
const children = /* @__PURE__ */ new Map();
|
|
1529
|
+
for (const line of psOutput.trim().split("\n")) {
|
|
1530
|
+
const [pid, ppid] = line.trim().split(WHITESPACE_RE).map(Number);
|
|
1531
|
+
if (!children.has(ppid))
|
|
1532
|
+
children.set(ppid, []);
|
|
1533
|
+
children.get(ppid).push(pid);
|
|
1534
|
+
}
|
|
1535
|
+
const found = [];
|
|
1536
|
+
const queue = [root];
|
|
1537
|
+
while (queue.length) {
|
|
1538
|
+
for (const child of children.get(queue.shift()) ?? []) {
|
|
1539
|
+
found.push(child);
|
|
1540
|
+
queue.push(child);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return found;
|
|
1544
|
+
}
|
|
1545
|
+
function processTree(pid) {
|
|
1546
|
+
if (process3.platform === "win32")
|
|
1547
|
+
return [];
|
|
1548
|
+
try {
|
|
1549
|
+
return [pid, ...descendants(pid, execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }))];
|
|
1550
|
+
} catch {
|
|
1551
|
+
return [pid];
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
function signalAll(pids, signal) {
|
|
1555
|
+
for (const p of pids) {
|
|
1556
|
+
try {
|
|
1557
|
+
process3.kill(p, signal);
|
|
1558
|
+
} catch {
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
function isAlive(pid) {
|
|
1563
|
+
try {
|
|
1564
|
+
process3.kill(pid, 0);
|
|
1565
|
+
return true;
|
|
1566
|
+
} catch {
|
|
1567
|
+
return false;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
async function waitForExit(pids, timeoutMs) {
|
|
1571
|
+
const deadline = Date.now() + timeoutMs;
|
|
1572
|
+
while (pids.some(isAlive) && Date.now() < deadline)
|
|
1573
|
+
await delay(50);
|
|
1574
|
+
}
|
|
1575
|
+
function killTree(pid, signal) {
|
|
1576
|
+
if (process3.platform === "win32") {
|
|
1577
|
+
try {
|
|
1578
|
+
execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
1579
|
+
} catch {
|
|
1580
|
+
}
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
signalAll(processTree(pid), signal);
|
|
1584
|
+
}
|
|
1585
|
+
function startDev(root, { capture = false } = {}) {
|
|
1586
|
+
const pm = detectPackageManager(root);
|
|
1587
|
+
const script = checkDevScript(root);
|
|
1588
|
+
const child = spawn2(pm, ["run", script], {
|
|
1589
|
+
cwd: root,
|
|
1590
|
+
env: { ...process3.env, [DEV_ENV_FLAG]: "1" },
|
|
1591
|
+
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
1592
|
+
// npm, pnpm and yarn are .cmd shims on Windows
|
|
1593
|
+
shell: process3.platform === "win32"
|
|
1594
|
+
});
|
|
1595
|
+
let output = "";
|
|
1596
|
+
const keep = (chunk) => {
|
|
1597
|
+
output = (output + chunk).slice(-MAX_CAPTURED_OUTPUT);
|
|
1598
|
+
};
|
|
1599
|
+
const announce = `mavonengine: running '${pm} run ${script}' in ${root}
|
|
1600
|
+
`;
|
|
1601
|
+
if (capture)
|
|
1602
|
+
keep(announce);
|
|
1603
|
+
else
|
|
1604
|
+
process3.stdout.write(announce);
|
|
1605
|
+
child.stdout?.setEncoding("utf8").on("data", keep);
|
|
1606
|
+
child.stderr?.setEncoding("utf8").on("data", keep);
|
|
1607
|
+
let running = true;
|
|
1608
|
+
const onInterrupt = () => {
|
|
1609
|
+
};
|
|
1610
|
+
const forward = (signal) => child.pid && killTree(child.pid, signal);
|
|
1611
|
+
const onExit = () => running && child.pid && killTree(child.pid, "SIGTERM");
|
|
1612
|
+
process3.on("SIGINT", onInterrupt);
|
|
1613
|
+
process3.on("SIGTERM", forward);
|
|
1614
|
+
process3.on("SIGHUP", forward);
|
|
1615
|
+
process3.on("exit", onExit);
|
|
1616
|
+
const exited = new Promise((resolvePromise, reject) => {
|
|
1617
|
+
const cleanup = () => {
|
|
1618
|
+
running = false;
|
|
1619
|
+
process3.off("SIGINT", onInterrupt);
|
|
1620
|
+
process3.off("SIGTERM", forward);
|
|
1621
|
+
process3.off("SIGHUP", forward);
|
|
1622
|
+
process3.off("exit", onExit);
|
|
1623
|
+
};
|
|
1624
|
+
child.on("error", (err) => {
|
|
1625
|
+
cleanup();
|
|
1626
|
+
reject(err.code === "ENOENT" ? new Error(`'${pm}' was not found on your PATH`) : err);
|
|
1627
|
+
});
|
|
1628
|
+
child.on("exit", (code, signal) => {
|
|
1629
|
+
cleanup();
|
|
1630
|
+
resolvePromise(code ?? 128 + (signal ? constants.signals[signal] : 0));
|
|
1631
|
+
});
|
|
1632
|
+
});
|
|
1633
|
+
const stop = async () => {
|
|
1634
|
+
if (running && child.pid) {
|
|
1635
|
+
if (process3.platform === "win32") {
|
|
1636
|
+
killTree(child.pid, "SIGTERM");
|
|
1637
|
+
} else {
|
|
1638
|
+
const tree = processTree(child.pid);
|
|
1639
|
+
signalAll(tree, "SIGTERM");
|
|
1640
|
+
await waitForExit(tree, STOP_TIMEOUT_MS);
|
|
1641
|
+
signalAll(tree.filter(isAlive), "SIGKILL");
|
|
1642
|
+
await waitForExit(tree, KILL_TIMEOUT_MS);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return exited;
|
|
1646
|
+
};
|
|
1647
|
+
return { exited, stop, output: () => output };
|
|
1648
|
+
}
|
|
1649
|
+
function runDev(root) {
|
|
1650
|
+
return startDev(root).exited;
|
|
1651
|
+
}
|
|
1652
|
+
async function runDevWithDashboard(root, dashboard) {
|
|
1653
|
+
const dev = startDev(root, { capture: true });
|
|
1654
|
+
const engineVersion = installedEngineVersion(root);
|
|
1655
|
+
const startup = () => ({ engineVersion, output: dev.output() });
|
|
1656
|
+
const devExited = new AbortController();
|
|
1657
|
+
dev.exited.then(() => devExited.abort(), () => devExited.abort());
|
|
1658
|
+
let quit = false;
|
|
1659
|
+
try {
|
|
1660
|
+
await runDashboard({ ...dashboard, startup }, devExited.signal);
|
|
1661
|
+
} finally {
|
|
1662
|
+
quit = !devExited.signal.aborted;
|
|
1663
|
+
if (quit)
|
|
1664
|
+
await dev.stop().catch(() => {
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
if (quit)
|
|
1668
|
+
return 0;
|
|
1669
|
+
const exitCode = await dev.exited;
|
|
1670
|
+
process3.stderr.write(`${dev.output()}
|
|
1671
|
+
mavonengine: the dev script exited with code ${exitCode}
|
|
1672
|
+
`);
|
|
1673
|
+
return exitCode;
|
|
1674
|
+
}
|
|
1675
|
+
async function runDevCommand(options) {
|
|
1676
|
+
const root = findProjectRoot(options.cwd);
|
|
1677
|
+
if (!root)
|
|
1678
|
+
throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`);
|
|
1679
|
+
checkDevScript(root);
|
|
1680
|
+
if (options.dashboard && process3.stdout.isTTY && process3.stdin.isTTY)
|
|
1681
|
+
return runDevWithDashboard(root, options.dashboard);
|
|
1682
|
+
return runDev(root);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// src/commands/build.ts
|
|
1686
|
+
var BUILD_HELP = `Usage: mavonengine build [options]
|
|
1687
|
+
|
|
1688
|
+
Build the MavonEngine project you are in for release by running its build script.
|
|
1689
|
+
Plugin assets go into the client's output under plugins/<package>/: the shared
|
|
1690
|
+
ones, and those of the prefabs listed in ${CONFIG_FILE}. The others are left out.
|
|
1691
|
+
|
|
1692
|
+
Options:
|
|
1693
|
+
--cwd <dir> Start looking for the project here (default: current directory)
|
|
1694
|
+
-h, --help Show this help
|
|
1695
|
+
`;
|
|
1696
|
+
function parseBuildArgs(args, cwd = process4.cwd()) {
|
|
1697
|
+
const { values } = parseArgs3({
|
|
1698
|
+
args,
|
|
1699
|
+
options: {
|
|
1700
|
+
cwd: { type: "string" },
|
|
1701
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1702
|
+
},
|
|
1703
|
+
strict: true,
|
|
1704
|
+
allowPositionals: false
|
|
1705
|
+
});
|
|
1706
|
+
if (values.help)
|
|
1707
|
+
return "help";
|
|
1708
|
+
return { cwd: resolve3(cwd, values.cwd ?? ".") };
|
|
1709
|
+
}
|
|
1710
|
+
function summarizeReleaseAssets(root, assets, warnings) {
|
|
1711
|
+
const plugins = readConfig(root).plugins;
|
|
1712
|
+
const enabled = Object.entries(plugins).filter(([, entry]) => entry?.enabled !== false);
|
|
1713
|
+
if (enabled.length === 0)
|
|
1714
|
+
return "";
|
|
1715
|
+
const lines = [`
|
|
1716
|
+
Plugins in ${CONFIG_FILE}:`];
|
|
1717
|
+
for (const [pkg, entry] of enabled) {
|
|
1718
|
+
const count = assets.filter((a) => a.package === pkg).length;
|
|
1719
|
+
const prefabs = entry?.prefabs?.length ? `, prefabs: ${entry.prefabs.join(", ")}` : "";
|
|
1720
|
+
lines.push(` ${pkg}: ${count} asset${count === 1 ? "" : "s"}${prefabs}`);
|
|
1721
|
+
}
|
|
1722
|
+
for (const warning of warnings)
|
|
1723
|
+
lines.push(` warning: ${warning}`);
|
|
1724
|
+
return `${lines.join("\n")}
|
|
1725
|
+
`;
|
|
1726
|
+
}
|
|
1727
|
+
function run(command, args, cwd) {
|
|
1728
|
+
return new Promise((resolvePromise, reject) => {
|
|
1729
|
+
const child = spawn3(command, args, { cwd, stdio: "inherit", shell: process4.platform === "win32" });
|
|
1730
|
+
child.on("error", (err) => {
|
|
1731
|
+
reject(err.code === "ENOENT" ? new Error(`'${command}' was not found on your PATH`) : err);
|
|
1732
|
+
});
|
|
1733
|
+
child.on("exit", (code) => resolvePromise(code ?? 1));
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
async function runBuildCommand(options) {
|
|
1737
|
+
const root = findProjectRoot(options.cwd);
|
|
1738
|
+
if (!root)
|
|
1739
|
+
throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`);
|
|
1740
|
+
const pkg = JSON.parse(readFileSync3(join3(root, "package.json"), "utf8"));
|
|
1741
|
+
if (typeof pkg.scripts?.build !== "string")
|
|
1742
|
+
throw new UsageError(`${join3(root, "package.json")} has no build script`);
|
|
1743
|
+
const pm = detectPackageManager(root);
|
|
1744
|
+
process4.stdout.write(`mavonengine: running '${pm} run build' in ${root}
|
|
1745
|
+
`);
|
|
1746
|
+
const code = await run(pm, ["run", "build"], root);
|
|
1747
|
+
if (code !== 0) {
|
|
1748
|
+
process4.stderr.write(`mavonengine: the build script exited with code ${code}
|
|
1749
|
+
`);
|
|
1750
|
+
return code;
|
|
1751
|
+
}
|
|
1752
|
+
if (existsSync3(join3(root, CONFIG_FILE))) {
|
|
1753
|
+
const { assets, warnings } = releaseAssets(root);
|
|
1754
|
+
process4.stdout.write(summarizeReleaseAssets(root, assets, warnings));
|
|
1755
|
+
}
|
|
1756
|
+
return 0;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/commands/create.ts
|
|
1760
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1761
|
+
import process6 from "process";
|
|
1762
|
+
import { createInterface } from "readline/promises";
|
|
1763
|
+
import { parseArgs as parseArgs4 } from "util";
|
|
1764
|
+
|
|
1765
|
+
// src/create/project.ts
|
|
1766
|
+
import { spawn as spawn4 } from "child_process";
|
|
1767
|
+
import { createWriteStream, existsSync as existsSync4, mkdirSync, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1768
|
+
import { tmpdir } from "os";
|
|
1769
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve4 } from "path";
|
|
1770
|
+
import process5 from "process";
|
|
1771
|
+
import { Readable, Transform } from "stream";
|
|
1772
|
+
import { pipeline } from "stream/promises";
|
|
1773
|
+
import AdmZip from "adm-zip";
|
|
1774
|
+
var TEMPLATE_URL = "https://github.com/MavonEngine/Core/archive/refs/tags/0.0.16-alpha.zip";
|
|
1775
|
+
var TEMPLATE_SUBPATH = "packages/multiplayer-template";
|
|
1776
|
+
var PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/;
|
|
1777
|
+
function projectDirFor(projectName, parentDir) {
|
|
1778
|
+
return resolve4(parentDir, projectName);
|
|
1779
|
+
}
|
|
1780
|
+
async function downloadZip(url, { report, signal, fetch = globalThis.fetch }) {
|
|
1781
|
+
const response = await fetch(url, { signal });
|
|
1782
|
+
if (!response.ok || !response.body)
|
|
1783
|
+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
1784
|
+
const total = Number(response.headers.get("content-length")) || 0;
|
|
1785
|
+
let received = 0;
|
|
1786
|
+
const progress = new Transform({
|
|
1787
|
+
transform(chunk, _encoding, callback) {
|
|
1788
|
+
received += chunk.length;
|
|
1789
|
+
report.step(`Downloading the template\u2026 ${formatBytes(received)}${total ? ` of ${formatBytes(total)}` : ""}`);
|
|
1790
|
+
callback(null, chunk);
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
const dest = join4(tmpdir(), `mavonengine-create-${process5.pid}-${Date.now()}.zip`);
|
|
1794
|
+
try {
|
|
1795
|
+
await pipeline(Readable.fromWeb(response.body), progress, createWriteStream(dest), { signal });
|
|
1796
|
+
} catch (err) {
|
|
1797
|
+
rmSync(dest, { force: true });
|
|
1798
|
+
throw err;
|
|
1799
|
+
}
|
|
1800
|
+
return dest;
|
|
1801
|
+
}
|
|
1802
|
+
function extractTemplate(zipPath, templateSubpath, destDir) {
|
|
1803
|
+
const entries = new AdmZip(zipPath).getEntries();
|
|
1804
|
+
if (entries.length === 0)
|
|
1805
|
+
throw new Error("Downloaded zip archive is empty");
|
|
1806
|
+
const rootFolder = entries[0].entryName.split("/")[0];
|
|
1807
|
+
const templatePrefix = `${rootFolder}/${templateSubpath}/`;
|
|
1808
|
+
const templateEntries = entries.filter((e) => e.entryName.startsWith(templatePrefix));
|
|
1809
|
+
if (templateEntries.length === 0)
|
|
1810
|
+
throw new Error(`Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`);
|
|
1811
|
+
const root = resolve4(destDir);
|
|
1812
|
+
let files = 0;
|
|
1813
|
+
for (const entry of templateEntries) {
|
|
1814
|
+
const relativePath = entry.entryName.slice(templatePrefix.length);
|
|
1815
|
+
if (!relativePath)
|
|
1816
|
+
continue;
|
|
1817
|
+
const outputPath = resolve4(root, relativePath);
|
|
1818
|
+
const inside = relative2(root, outputPath);
|
|
1819
|
+
if (inside.startsWith("..") || isAbsolute2(inside))
|
|
1820
|
+
throw new Error(`Refusing to extract ${entry.entryName} outside the project`);
|
|
1821
|
+
if (entry.isDirectory) {
|
|
1822
|
+
mkdirSync(outputPath, { recursive: true });
|
|
1823
|
+
} else {
|
|
1824
|
+
mkdirSync(dirname3(outputPath), { recursive: true });
|
|
1825
|
+
writeFileSync2(outputPath, entry.getData());
|
|
1826
|
+
files++;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return files;
|
|
1830
|
+
}
|
|
1831
|
+
function runCommand(command, args, cwd, { report, signal }) {
|
|
1832
|
+
return new Promise((resolvePromise, reject) => {
|
|
1833
|
+
const child = spawn4(command, args, {
|
|
1834
|
+
cwd,
|
|
1835
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1836
|
+
// npm is a .cmd shim on Windows
|
|
1837
|
+
shell: process5.platform === "win32",
|
|
1838
|
+
signal
|
|
1839
|
+
});
|
|
1840
|
+
child.stdout.setEncoding("utf8").on("data", report.output);
|
|
1841
|
+
child.stderr.setEncoding("utf8").on("data", report.output);
|
|
1842
|
+
child.on("error", (err) => {
|
|
1843
|
+
reject(err.code === "ENOENT" ? new Error(`'${command}' was not found on your PATH`) : err);
|
|
1844
|
+
});
|
|
1845
|
+
child.on("exit", (code, exitSignal) => {
|
|
1846
|
+
if (code === 0)
|
|
1847
|
+
resolvePromise();
|
|
1848
|
+
else
|
|
1849
|
+
reject(new Error(`'${command} ${args.join(" ")}' ${code === null ? `was stopped by ${exitSignal}` : `exited with code ${code}`}`));
|
|
1850
|
+
});
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
async function createProject({ projectName, parentDir, report, signal, templateUrl = TEMPLATE_URL, fetch, install = true }) {
|
|
1854
|
+
const projectDir = projectDirFor(projectName, parentDir);
|
|
1855
|
+
if (existsSync4(projectDir))
|
|
1856
|
+
throw new Error(`Directory "${projectDir}" already exists`);
|
|
1857
|
+
report.output(`Creating project "${projectName}" in ${projectDir}
|
|
1858
|
+
`);
|
|
1859
|
+
mkdirSync(projectDir, { recursive: true });
|
|
1860
|
+
report.step("Downloading the template\u2026");
|
|
1861
|
+
report.output(`Downloading the template from ${templateUrl}
|
|
1862
|
+
`);
|
|
1863
|
+
const zipPath = await downloadZip(templateUrl, { report, signal, fetch });
|
|
1864
|
+
try {
|
|
1865
|
+
report.step("Extracting the template\u2026");
|
|
1866
|
+
const files = extractTemplate(zipPath, TEMPLATE_SUBPATH, projectDir);
|
|
1867
|
+
report.output(`Extracted ${files} files
|
|
1868
|
+
`);
|
|
1869
|
+
} finally {
|
|
1870
|
+
rmSync(zipPath, { force: true });
|
|
1871
|
+
}
|
|
1872
|
+
const pkgJsonPath = join4(projectDir, "package.json");
|
|
1873
|
+
if (existsSync4(pkgJsonPath)) {
|
|
1874
|
+
const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf-8"));
|
|
1875
|
+
pkg.name = projectName;
|
|
1876
|
+
writeFileSync2(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}
|
|
1877
|
+
`);
|
|
1878
|
+
}
|
|
1879
|
+
if (writeMcpConfig(projectDir))
|
|
1880
|
+
report.output(`Wrote ${MCP_CONFIG_FILE} so AI assistants with MCP support can inspect the running game server
|
|
1881
|
+
`);
|
|
1882
|
+
if (install) {
|
|
1883
|
+
signal?.throwIfAborted();
|
|
1884
|
+
report.step("Installing dependencies\u2026");
|
|
1885
|
+
report.output("Running 'npm install'\n");
|
|
1886
|
+
await runCommand("npm", ["install"], projectDir, { report, signal });
|
|
1887
|
+
}
|
|
1888
|
+
return projectDir;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// src/commands/create.ts
|
|
1892
|
+
var CREATE_HELP = `Usage: mavonengine create [name] [options]
|
|
1893
|
+
|
|
1894
|
+
Create a MavonEngine project from the multiplayer template, install its
|
|
1895
|
+
dependencies and start it like 'mavonengine dev'. Asks for a name if none is given.
|
|
1896
|
+
|
|
1897
|
+
Options:
|
|
1898
|
+
--cwd <dir> Create the project in here (default: current directory)
|
|
1899
|
+
--no-dashboard Print the progress and the dev script's output instead
|
|
1900
|
+
--port <port> Game server HTTP port for the dashboard (default: 8050)
|
|
1901
|
+
--theme <name> Dashboard theme, see 'mavonengine dashboard --help'
|
|
1902
|
+
--transparent Let a transparent terminal background show through
|
|
1903
|
+
--no-open Don't open the game in the browser once the server is up
|
|
1904
|
+
-h, --help Show this help
|
|
1905
|
+
`;
|
|
1906
|
+
var MAX_SETUP_OUTPUT = 64 * 1024;
|
|
1907
|
+
function parseCreateArgs(args, cwd = process6.cwd(), env = process6.env) {
|
|
1908
|
+
const { values, positionals } = parseArgs4({
|
|
1909
|
+
args,
|
|
1910
|
+
options: DEV_FLAGS,
|
|
1911
|
+
strict: true,
|
|
1912
|
+
allowPositionals: true,
|
|
1913
|
+
allowNegative: true
|
|
1914
|
+
});
|
|
1915
|
+
if (values.help)
|
|
1916
|
+
return "help";
|
|
1917
|
+
if (positionals.length > 1)
|
|
1918
|
+
throw new UsageError(`expected one project name, got ${positionals.length}`);
|
|
1919
|
+
const projectName = positionals[0]?.trim() || void 0;
|
|
1920
|
+
if (projectName)
|
|
1921
|
+
checkProjectName(projectName);
|
|
1922
|
+
return { ...toDevOptions(values, cwd, env), projectName };
|
|
1923
|
+
}
|
|
1924
|
+
function checkProjectName(name) {
|
|
1925
|
+
if (!PROJECT_NAME_RE.test(name))
|
|
1926
|
+
throw new UsageError(`invalid project name '${name}'`);
|
|
1927
|
+
}
|
|
1928
|
+
async function promptProjectName() {
|
|
1929
|
+
const rl = createInterface({ input: process6.stdin, output: process6.stdout });
|
|
1930
|
+
try {
|
|
1931
|
+
return (await rl.question("Project name: ")).trim();
|
|
1932
|
+
} finally {
|
|
1933
|
+
rl.close();
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
function gatedSource(source, ready) {
|
|
1937
|
+
return {
|
|
1938
|
+
...source,
|
|
1939
|
+
fetch: () => ready() ? source.fetch() : Promise.reject(new MetricsSourceError("Creating the project", "connecting"))
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
async function runCreateWithDashboard(projectName, parentDir, dashboard) {
|
|
1943
|
+
let label = "Creating the project\u2026";
|
|
1944
|
+
let output = "";
|
|
1945
|
+
let engineVersion;
|
|
1946
|
+
let dev;
|
|
1947
|
+
const report = {
|
|
1948
|
+
step: (text) => {
|
|
1949
|
+
label = text;
|
|
1950
|
+
},
|
|
1951
|
+
output: (chunk) => {
|
|
1952
|
+
output = (output + chunk).slice(-MAX_SETUP_OUTPUT);
|
|
1953
|
+
}
|
|
1954
|
+
};
|
|
1955
|
+
const setup = new AbortController();
|
|
1956
|
+
const done = new AbortController();
|
|
1957
|
+
const work = (async () => {
|
|
1958
|
+
const dir = await createProject({ projectName, parentDir, report, signal: setup.signal });
|
|
1959
|
+
setup.signal.throwIfAborted();
|
|
1960
|
+
engineVersion = installedEngineVersion(dir);
|
|
1961
|
+
label = "Starting the game server\u2026";
|
|
1962
|
+
dev = startDev(dir, { capture: true });
|
|
1963
|
+
return dev.exited;
|
|
1964
|
+
})();
|
|
1965
|
+
work.then(() => done.abort(), () => done.abort());
|
|
1966
|
+
const startup = () => dev ? { engineVersion, output: dev.output() } : { engineVersion, label, output, outputTitle: "Setup" };
|
|
1967
|
+
let quit = false;
|
|
1968
|
+
try {
|
|
1969
|
+
await runDashboard({ ...dashboard, source: gatedSource(dashboard.source, () => dev !== void 0), startup }, done.signal);
|
|
1970
|
+
} finally {
|
|
1971
|
+
quit = !done.signal.aborted;
|
|
1972
|
+
if (quit) {
|
|
1973
|
+
setup.abort();
|
|
1974
|
+
await dev?.stop().catch(() => {
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
if (quit) {
|
|
1979
|
+
if (dev)
|
|
1980
|
+
return 0;
|
|
1981
|
+
await work.catch(() => {
|
|
1982
|
+
});
|
|
1983
|
+
process6.stderr.write(`mavonengine: stopped creating the project, what was set up so far is in ${projectDirFor(projectName, parentDir)}
|
|
1984
|
+
`);
|
|
1985
|
+
return 130;
|
|
1986
|
+
}
|
|
1987
|
+
try {
|
|
1988
|
+
const exitCode = await work;
|
|
1989
|
+
process6.stderr.write(`${dev.output()}
|
|
1990
|
+
mavonengine: the dev script exited with code ${exitCode}
|
|
1991
|
+
`);
|
|
1992
|
+
return exitCode;
|
|
1993
|
+
} catch (err) {
|
|
1994
|
+
process6.stderr.write(output);
|
|
1995
|
+
throw err;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
async function runCreatePlain(projectName, parentDir) {
|
|
1999
|
+
const report = {
|
|
2000
|
+
step: () => {
|
|
2001
|
+
},
|
|
2002
|
+
output: (chunk) => process6.stdout.write(chunk)
|
|
2003
|
+
};
|
|
2004
|
+
const dir = await createProject({ projectName, parentDir, report });
|
|
2005
|
+
process6.stdout.write(`
|
|
2006
|
+
mavonengine: your project is ready at ${dir}
|
|
2007
|
+
`);
|
|
2008
|
+
return runDev(dir);
|
|
2009
|
+
}
|
|
2010
|
+
async function runCreateCommand(options) {
|
|
2011
|
+
const interactive = !!process6.stdout.isTTY && !!process6.stdin.isTTY;
|
|
2012
|
+
let projectName = options.projectName;
|
|
2013
|
+
if (!projectName) {
|
|
2014
|
+
if (!interactive)
|
|
2015
|
+
throw new UsageError("a project name is required, e.g. mavonengine create my-game");
|
|
2016
|
+
projectName = await promptProjectName();
|
|
2017
|
+
if (!projectName)
|
|
2018
|
+
throw new UsageError("a project name is required");
|
|
2019
|
+
checkProjectName(projectName);
|
|
2020
|
+
}
|
|
2021
|
+
const dir = projectDirFor(projectName, options.cwd);
|
|
2022
|
+
if (existsSync5(dir))
|
|
2023
|
+
throw new UsageError(`${dir} already exists`);
|
|
2024
|
+
if (options.dashboard && interactive)
|
|
2025
|
+
return runCreateWithDashboard(projectName, options.cwd, options.dashboard);
|
|
2026
|
+
return runCreatePlain(projectName, options.cwd);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// src/commands/install.ts
|
|
2030
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
|
|
2031
|
+
import { join as join5, resolve as resolve5 } from "path";
|
|
2032
|
+
import process7 from "process";
|
|
2033
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
2034
|
+
import { parseArgs as parseArgs5 } from "util";
|
|
2035
|
+
var INSTALL_HELP = `Usage: mavonengine install <package...> [options]
|
|
2036
|
+
|
|
2037
|
+
Install packages into the MavonEngine project you are in. For each one that is a
|
|
2038
|
+
MavonEngine plugin, asks whether to add it to the plugins in ${CONFIG_FILE}, so it
|
|
2039
|
+
loads on the client and/or the game server and is built into the release.
|
|
2040
|
+
|
|
2041
|
+
Takes anything your package manager does: names, name@version, file: paths, git URLs.
|
|
2042
|
+
|
|
2043
|
+
Options:
|
|
2044
|
+
--cwd <dir> Start looking for the project here (default: current directory)
|
|
2045
|
+
-y, --yes Add plugins to ${CONFIG_FILE} without asking
|
|
2046
|
+
--no-register Only install, leave ${CONFIG_FILE} alone
|
|
2047
|
+
-h, --help Show this help
|
|
2048
|
+
`;
|
|
2049
|
+
var PACKAGE_SPEC_RE = /^((?:@[\w.-]+\/)?[\w.-]+)(?:@.*)?$/;
|
|
2050
|
+
var LOCAL_PATH_RE = /^(?:\.{1,2}(?:[/\\]|$)|[/~]|[a-z]:[/\\])/i;
|
|
2051
|
+
var TARGET_LABELS = {
|
|
2052
|
+
client: "client only",
|
|
2053
|
+
server: "server only",
|
|
2054
|
+
both: "client + server"
|
|
2055
|
+
};
|
|
2056
|
+
function parseInstallArgs(args, cwd = process7.cwd()) {
|
|
2057
|
+
const { values, positionals } = parseArgs5({
|
|
2058
|
+
args,
|
|
2059
|
+
options: {
|
|
2060
|
+
cwd: { type: "string" },
|
|
2061
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
2062
|
+
register: { type: "boolean", default: true },
|
|
2063
|
+
help: { type: "boolean", short: "h", default: false }
|
|
2064
|
+
},
|
|
2065
|
+
strict: true,
|
|
2066
|
+
allowPositionals: true,
|
|
2067
|
+
allowNegative: true
|
|
2068
|
+
});
|
|
2069
|
+
if (values.help)
|
|
2070
|
+
return "help";
|
|
2071
|
+
if (positionals.length === 0)
|
|
2072
|
+
throw new UsageError(`which package? e.g. mavonengine install my-plugin
|
|
2073
|
+
|
|
2074
|
+
${INSTALL_HELP}`);
|
|
2075
|
+
return { cwd: resolve5(cwd, values.cwd ?? "."), packages: positionals, yes: values.yes, register: values.register };
|
|
2076
|
+
}
|
|
2077
|
+
function packageNameFromSpec(spec, root) {
|
|
2078
|
+
const localPath = spec.startsWith("file:") ? spec.slice("file:".length) : LOCAL_PATH_RE.test(spec) ? spec : void 0;
|
|
2079
|
+
if (localPath !== void 0) {
|
|
2080
|
+
const packageJson = join5(resolve5(root, localPath), "package.json");
|
|
2081
|
+
try {
|
|
2082
|
+
return existsSync6(packageJson) ? JSON.parse(readFileSync5(packageJson, "utf8")).name : void 0;
|
|
2083
|
+
} catch {
|
|
2084
|
+
return void 0;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
if (spec.includes(":") || spec.endsWith(".tgz"))
|
|
2088
|
+
return void 0;
|
|
2089
|
+
return PACKAGE_SPEC_RE.exec(spec)?.[1];
|
|
2090
|
+
}
|
|
2091
|
+
function installArgs(pm, packages) {
|
|
2092
|
+
return [pm === "npm" ? "install" : "add", ...packages];
|
|
2093
|
+
}
|
|
2094
|
+
function dependencyNames(root) {
|
|
2095
|
+
const path = join5(root, "package.json");
|
|
2096
|
+
if (!existsSync6(path))
|
|
2097
|
+
return /* @__PURE__ */ new Map();
|
|
2098
|
+
const pkg = JSON.parse(readFileSync5(path, "utf8"));
|
|
2099
|
+
return new Map(Object.entries({ ...pkg.devDependencies, ...pkg.dependencies }));
|
|
2100
|
+
}
|
|
2101
|
+
function installedNames(specs, root, before, after) {
|
|
2102
|
+
const names = specs.map((spec) => packageNameFromSpec(spec, root)).filter((name) => !!name);
|
|
2103
|
+
for (const [name, version2] of after) {
|
|
2104
|
+
if (before.get(name) !== version2)
|
|
2105
|
+
names.push(name);
|
|
2106
|
+
}
|
|
2107
|
+
return [...new Set(names)];
|
|
2108
|
+
}
|
|
2109
|
+
function describePlugin(plugin, engineVersion) {
|
|
2110
|
+
const { manifest } = plugin;
|
|
2111
|
+
const lines = [`${plugin.package}${plugin.version ? `@${plugin.version}` : ""} is a MavonEngine plugin (${TARGET_LABELS[manifest.target]})`];
|
|
2112
|
+
const prefabs = Object.keys(manifest.assets?.prefabs ?? {});
|
|
2113
|
+
if (prefabs.length)
|
|
2114
|
+
lines.push(` Prefabs: ${prefabs.join(", ")}. Their assets ship in the release once you place them in the editor`);
|
|
2115
|
+
if (manifest.engine)
|
|
2116
|
+
lines.push(` Made for @mavonengine/core ${manifest.engine}${engineVersion ? `, you have ${engineVersion}` : ""}`);
|
|
2117
|
+
return `${lines.join("\n")}
|
|
2118
|
+
`;
|
|
2119
|
+
}
|
|
2120
|
+
function confirmOnTerminal(question) {
|
|
2121
|
+
const rl = createInterface2({ input: process7.stdin, output: process7.stdout });
|
|
2122
|
+
return rl.question(question).then((answer) => ["", "y", "yes"].includes(answer.trim().toLowerCase())).finally(() => rl.close());
|
|
2123
|
+
}
|
|
2124
|
+
var terminalIO = {
|
|
2125
|
+
run: (command, args, cwd) => runCommand(command, args, cwd, {
|
|
2126
|
+
report: { step: () => {
|
|
2127
|
+
}, output: (chunk) => process7.stdout.write(chunk) }
|
|
2128
|
+
}),
|
|
2129
|
+
confirm: confirmOnTerminal,
|
|
2130
|
+
get interactive() {
|
|
2131
|
+
return !!process7.stdin.isTTY && !!process7.stdout.isTTY;
|
|
2132
|
+
},
|
|
2133
|
+
write: (text) => process7.stdout.write(text)
|
|
2134
|
+
};
|
|
2135
|
+
async function runInstallCommand(options, io = terminalIO) {
|
|
2136
|
+
const root = findProjectRoot(options.cwd);
|
|
2137
|
+
if (!root)
|
|
2138
|
+
throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`);
|
|
2139
|
+
const config = readConfig(root);
|
|
2140
|
+
const pm = detectPackageManager(root);
|
|
2141
|
+
const args = installArgs(pm, options.packages);
|
|
2142
|
+
const before = dependencyNames(root);
|
|
2143
|
+
io.write(`mavonengine: running '${pm} ${args.join(" ")}' in ${root}
|
|
2144
|
+
`);
|
|
2145
|
+
await io.run(pm, args, root);
|
|
2146
|
+
const engineVersion = installedEngineVersion(root);
|
|
2147
|
+
let failed = false;
|
|
2148
|
+
const names = installedNames(options.packages, root, before, dependencyNames(root));
|
|
2149
|
+
if (names.length === 0)
|
|
2150
|
+
io.write(`
|
|
2151
|
+
mavonengine: couldn't tell which packages were installed. If one is a plugin, run 'mavonengine install <its name>' to register it
|
|
2152
|
+
`);
|
|
2153
|
+
for (const name of names) {
|
|
2154
|
+
let plugin;
|
|
2155
|
+
try {
|
|
2156
|
+
plugin = readPluginManifest(root, name);
|
|
2157
|
+
} catch (err) {
|
|
2158
|
+
io.write(`
|
|
2159
|
+
mavonengine: ${err.message}
|
|
2160
|
+
`);
|
|
2161
|
+
failed = true;
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
if (!plugin) {
|
|
2165
|
+
io.write(`
|
|
2166
|
+
${name} is installed. It is not a MavonEngine plugin (no "mavonengine" field in its package.json), so ${CONFIG_FILE} is left alone
|
|
2167
|
+
`);
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
io.write(`
|
|
2171
|
+
${describePlugin(plugin, engineVersion)}`);
|
|
2172
|
+
const entry = config.plugins[name];
|
|
2173
|
+
if (entry && entry.enabled !== false) {
|
|
2174
|
+
io.write(` Already in ${CONFIG_FILE}
|
|
2175
|
+
`);
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
if (!options.register)
|
|
2179
|
+
continue;
|
|
2180
|
+
if (!options.yes && !io.interactive) {
|
|
2181
|
+
io.write(` Not added to ${CONFIG_FILE}: run with --yes, or add "${name}": { "enabled": true } to its "plugins"
|
|
2182
|
+
`);
|
|
2183
|
+
continue;
|
|
2184
|
+
}
|
|
2185
|
+
if (!options.yes && !await io.confirm(`Add ${name} to the plugins in ${CONFIG_FILE}? (Y/n) `)) {
|
|
2186
|
+
io.write(` Not added. Add "${name}": { "enabled": true } to "plugins" in ${CONFIG_FILE} to use it later
|
|
2187
|
+
`);
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
addPluginToConfig(root, name);
|
|
2191
|
+
io.write(` Added to ${join5(root, CONFIG_FILE)}
|
|
2192
|
+
`);
|
|
2193
|
+
if (plugin.manifest.target !== "client")
|
|
2194
|
+
io.write(` Restart the game server ('mavonengine dev') to load it there
|
|
2195
|
+
`);
|
|
2196
|
+
}
|
|
2197
|
+
return failed ? 1 : 0;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// src/commands/mcp.ts
|
|
2201
|
+
import process8 from "process";
|
|
2202
|
+
import { parseArgs as parseArgs6 } from "util";
|
|
2203
|
+
|
|
2204
|
+
// src/mcp/GameServerClient.ts
|
|
2205
|
+
var GameServerError = class extends Error {
|
|
2206
|
+
};
|
|
2207
|
+
var DEV_UNAVAILABLE = "The dev endpoints are not available: the game server runs with NODE_ENV=production (they are never served there), or it uses a @mavonengine/core release without them";
|
|
2208
|
+
var GameServerClient = class {
|
|
2209
|
+
url;
|
|
2210
|
+
clientUrl;
|
|
2211
|
+
base;
|
|
2212
|
+
clientBase;
|
|
2213
|
+
token;
|
|
2214
|
+
timeoutMs;
|
|
2215
|
+
fetchImpl;
|
|
2216
|
+
constructor({ url, clientUrl, token, timeoutMs = 3e3, fetch = globalThis.fetch }) {
|
|
2217
|
+
this.base = new URL(url.endsWith("/") ? url : `${url}/`);
|
|
2218
|
+
this.url = this.base.href;
|
|
2219
|
+
this.clientBase = clientUrl ? new URL(clientUrl.endsWith("/") ? clientUrl : `${clientUrl}/`) : this.base;
|
|
2220
|
+
this.clientUrl = this.clientBase.href;
|
|
2221
|
+
this.token = token;
|
|
2222
|
+
this.timeoutMs = timeoutMs;
|
|
2223
|
+
this.fetchImpl = fetch;
|
|
2224
|
+
}
|
|
2225
|
+
health() {
|
|
2226
|
+
return this.get("api/game/health");
|
|
2227
|
+
}
|
|
2228
|
+
metrics() {
|
|
2229
|
+
return this.get("api/game/metrics");
|
|
2230
|
+
}
|
|
2231
|
+
logs(afterId = 0) {
|
|
2232
|
+
return this.get("api/game/logs", { after: String(afterId) }, "The server does not keep logs (metrics.logBufferSize is 0)");
|
|
2233
|
+
}
|
|
2234
|
+
requests(afterId = 0) {
|
|
2235
|
+
return this.get("api/game/requests", { after: String(afterId) }, "The server does not record requests (metrics.requestLogSize is 0)");
|
|
2236
|
+
}
|
|
2237
|
+
scene() {
|
|
2238
|
+
return this.get("api/game/dev/scene", {}, DEV_UNAVAILABLE);
|
|
2239
|
+
}
|
|
2240
|
+
entities(query = {}) {
|
|
2241
|
+
const params = {};
|
|
2242
|
+
if (query.type)
|
|
2243
|
+
params.type = query.type;
|
|
2244
|
+
if (query.near)
|
|
2245
|
+
params.near = `${query.near.x},${query.near.y},${query.near.z}`;
|
|
2246
|
+
for (const key of ["radius", "limit", "offset"]) {
|
|
2247
|
+
if (query[key] !== void 0)
|
|
2248
|
+
params[key] = String(query[key]);
|
|
2249
|
+
}
|
|
2250
|
+
return this.get("api/game/dev/entities", params, DEV_UNAVAILABLE);
|
|
2251
|
+
}
|
|
2252
|
+
entity(id) {
|
|
2253
|
+
return this.get(`api/game/dev/entities/${encodeURIComponent(id)}`, {}, DEV_UNAVAILABLE);
|
|
2254
|
+
}
|
|
2255
|
+
/** The game tabs open in a browser, through the dev bridge */
|
|
2256
|
+
clients() {
|
|
2257
|
+
return this.bridge("__mavonengine/clients", {});
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Ask one tab. `clientId` defaults to the only open tab (or the only one not running the editor)
|
|
2261
|
+
*/
|
|
2262
|
+
client(query, params = {}, clientId = "auto") {
|
|
2263
|
+
const defined = Object.fromEntries(Object.entries(params).filter(([, v]) => v !== void 0).map(([k, v]) => [k, String(v)]));
|
|
2264
|
+
const timeoutMs = query === "trace" ? Number(params.durationMs ?? 3e3) + 15e3 : query === "screenshot" ? 15e3 : query === "status" ? 1e4 : 8e3;
|
|
2265
|
+
return this.bridge(`__mavonengine/clients/${encodeURIComponent(clientId)}/${query}`, defined, timeoutMs);
|
|
2266
|
+
}
|
|
2267
|
+
async bridge(path, params, timeoutMs = 5e3) {
|
|
2268
|
+
const target = new URL(path, this.clientBase);
|
|
2269
|
+
for (const [key, value] of Object.entries(params))
|
|
2270
|
+
target.searchParams.set(key, value);
|
|
2271
|
+
let res;
|
|
2272
|
+
try {
|
|
2273
|
+
res = await this.fetchImpl(target, {
|
|
2274
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
|
2275
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2276
|
+
});
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
throw new GameServerError(`Can't reach the client dev server at ${this.clientUrl}: ${err.message}. Is it running?`);
|
|
2279
|
+
}
|
|
2280
|
+
const isJson = res.headers.get("content-type")?.includes("application/json");
|
|
2281
|
+
const body = isJson ? await res.json().catch(() => null) : null;
|
|
2282
|
+
if (!isJson || res.status === 404 && !body?.error) {
|
|
2283
|
+
throw new GameServerError(`No dev bridge at ${this.clientUrl}. The game client must be served by Vite in dev with the mavonengine Vite plugin (@mavonengine/core/vite.config). If Vite runs on its own port, pass --client-url, e.g. http://localhost:5173`);
|
|
2284
|
+
}
|
|
2285
|
+
if (res.status === 401 || res.status === 403)
|
|
2286
|
+
throw new GameServerError(`${body?.error ?? "Not allowed"}: pass --token or set MAVONENGINE_METRICS_TOKEN for both the CLI and the dev server`);
|
|
2287
|
+
if (!res.ok)
|
|
2288
|
+
throw new GameServerError(body?.error ?? `Client dev server responded ${res.status} ${res.statusText}`);
|
|
2289
|
+
return body;
|
|
2290
|
+
}
|
|
2291
|
+
async get(path, params = {}, notFound) {
|
|
2292
|
+
const target = new URL(path, this.base);
|
|
2293
|
+
for (const [key, value] of Object.entries(params))
|
|
2294
|
+
target.searchParams.set(key, value);
|
|
2295
|
+
let res;
|
|
2296
|
+
try {
|
|
2297
|
+
res = await this.fetchImpl(target, {
|
|
2298
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
|
2299
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
2300
|
+
});
|
|
2301
|
+
} catch (err) {
|
|
2302
|
+
throw new GameServerError(`Can't reach the game server at ${this.url}: ${err.message}. Is it running?`);
|
|
2303
|
+
}
|
|
2304
|
+
if (res.status === 401)
|
|
2305
|
+
throw new GameServerError("Metrics token missing or wrong: pass --token or set MAVONENGINE_METRICS_TOKEN");
|
|
2306
|
+
if (res.status === 403)
|
|
2307
|
+
throw new GameServerError("The game server only answers localhost without a token: pass --token or set MAVONENGINE_METRICS_TOKEN");
|
|
2308
|
+
if (res.status === 404)
|
|
2309
|
+
throw new GameServerError(notFound ?? "Metrics endpoints not found: they are turned off in the server options, or it runs in production without a metrics token");
|
|
2310
|
+
if (res.status === 400)
|
|
2311
|
+
throw new GameServerError(`Bad request: ${(await res.json().catch(() => null))?.error ?? res.statusText}`);
|
|
2312
|
+
if (!res.ok)
|
|
2313
|
+
throw new GameServerError(`Game server responded ${res.status} ${res.statusText}`);
|
|
2314
|
+
return res.json();
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
|
|
2318
|
+
// src/commands/mcp.ts
|
|
2319
|
+
var MCP_HELP = `Usage: mavonengine mcp [options]
|
|
2320
|
+
|
|
2321
|
+
Model Context Protocol (MCP) server over stdio, for any MCP client and model
|
|
2322
|
+
(Claude Code, Cursor, VS Code, Codex, Gemini CLI, Zed, local models, \u2026).
|
|
2323
|
+
It lets the assistant read a running game: the game server (status, metrics,
|
|
2324
|
+
players, scene, entities, logs, HTTP requests) and the game tabs open in a
|
|
2325
|
+
browser (renderer stats, entities, network, console, screenshots), and compare
|
|
2326
|
+
an entity on both sides. Every tool is read-only. Server scene and entity tools
|
|
2327
|
+
need the game server in development (NODE_ENV is not "production"); client
|
|
2328
|
+
tools need the client served by Vite in dev.
|
|
2329
|
+
|
|
2330
|
+
Register it in your client as a stdio server with this command:
|
|
2331
|
+
npx -y @mavonengine/cli mcp [options]
|
|
2332
|
+
|
|
2333
|
+
Most clients take JSON like this (projects from 'mavonengine create' get it in .mcp.json):
|
|
2334
|
+
{ "mcpServers": { "mavonengine": { "command": "npx", "args": ["-y", "@mavonengine/cli", "mcp"] } } }
|
|
2335
|
+
|
|
2336
|
+
Options:
|
|
2337
|
+
--host <host> Server host (default: localhost)
|
|
2338
|
+
--port <port> Server HTTP port (default: 8050)
|
|
2339
|
+
--url <url> Server base URL, e.g. http://localhost:8050, overrides --host and --port
|
|
2340
|
+
--client-url <url> Where Vite serves the client in dev (default: the server URL,
|
|
2341
|
+
which serves it in the template), e.g. http://localhost:5173
|
|
2342
|
+
--token <token> Metrics token (or set MAVONENGINE_METRICS_TOKEN)
|
|
2343
|
+
--docs-url <url> MCP server of the documentation for the docs_* tools
|
|
2344
|
+
(default: https://mavonengine.com/mcp)
|
|
2345
|
+
--no-docs Leave out the docs_* tools, e.g. offline
|
|
2346
|
+
-h, --help Show this help
|
|
2347
|
+
`;
|
|
2348
|
+
function parseMcpArgs(args, env = process8.env) {
|
|
2349
|
+
const { values } = parseArgs6({
|
|
2350
|
+
args,
|
|
2351
|
+
options: {
|
|
2352
|
+
"host": { type: "string", default: "localhost" },
|
|
2353
|
+
"port": { type: "string", default: "8050" },
|
|
2354
|
+
"url": { type: "string" },
|
|
2355
|
+
"client-url": { type: "string" },
|
|
2356
|
+
"token": { type: "string" },
|
|
2357
|
+
"docs-url": { type: "string" },
|
|
2358
|
+
"docs": { type: "boolean", default: true },
|
|
2359
|
+
"help": { type: "boolean", short: "h", default: false }
|
|
2360
|
+
},
|
|
2361
|
+
strict: true,
|
|
2362
|
+
allowPositionals: false,
|
|
2363
|
+
allowNegative: true
|
|
2364
|
+
});
|
|
2365
|
+
if (values.help)
|
|
2366
|
+
return "help";
|
|
2367
|
+
let url = values.url;
|
|
2368
|
+
if (!url) {
|
|
2369
|
+
const port = Number(values.port);
|
|
2370
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
2371
|
+
throw new UsageError("--port must be between 1 and 65535");
|
|
2372
|
+
const host = values.host.includes(":") && !values.host.startsWith("[") ? `[${values.host}]` : values.host;
|
|
2373
|
+
url = `http://${host}:${port}`;
|
|
2374
|
+
}
|
|
2375
|
+
for (const [flag, value] of [["--url", url], ["--client-url", values["client-url"]], ["--docs-url", values["docs-url"]]]) {
|
|
2376
|
+
try {
|
|
2377
|
+
if (value !== void 0)
|
|
2378
|
+
void new URL(value);
|
|
2379
|
+
} catch {
|
|
2380
|
+
throw new UsageError(`Invalid ${flag}: ${value}`);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return {
|
|
2384
|
+
client: new GameServerClient({ url, clientUrl: values["client-url"], token: values.token ?? env.MAVONENGINE_METRICS_TOKEN }),
|
|
2385
|
+
docsUrl: values.docs ? values["docs-url"] ?? DEFAULT_DOCS_URL : false
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
async function runMcpCommand({ client, docsUrl }) {
|
|
2389
|
+
const [{ StdioServerTransport }, { createMcpServer }, { DocsClient }] = await Promise.all([
|
|
2390
|
+
import("@modelcontextprotocol/sdk/server/stdio.js"),
|
|
2391
|
+
import("./server-35RY6IGC.js"),
|
|
2392
|
+
import("./docs-4C7X6FUQ.js")
|
|
2393
|
+
]);
|
|
2394
|
+
const docs = docsUrl ? new DocsClient(docsUrl) : void 0;
|
|
2395
|
+
const server = createMcpServer(client, docs);
|
|
2396
|
+
const transport = new StdioServerTransport();
|
|
2397
|
+
const closed = new Promise((resolve6) => {
|
|
2398
|
+
transport.onclose = resolve6;
|
|
2399
|
+
process8.stdin.on("end", resolve6);
|
|
2400
|
+
});
|
|
2401
|
+
await server.connect(transport);
|
|
2402
|
+
process8.stderr.write(`mavonengine mcp: reading the game server at ${client.url}
|
|
2403
|
+
`);
|
|
2404
|
+
await closed;
|
|
2405
|
+
await server.close().catch(() => {
|
|
2406
|
+
});
|
|
2407
|
+
await docs?.close();
|
|
2408
|
+
return 0;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// src/index.ts
|
|
2412
|
+
var HELP = `Usage: mavonengine <command> [options]
|
|
2413
|
+
|
|
2414
|
+
Commands:
|
|
2415
|
+
create Create a project from the multiplayer template and start it
|
|
2416
|
+
dashboard Live terminal dashboard for a running game server
|
|
2417
|
+
dev Run the project's dev servers (game server and client)
|
|
2418
|
+
install Install packages and register engine plugins in mavonengine.json
|
|
2419
|
+
build Build the project for release, with the plugin assets it uses
|
|
2420
|
+
mcp MCP server so AI assistants (any MCP client) can inspect a running game server
|
|
2421
|
+
|
|
2422
|
+
Options:
|
|
2423
|
+
-h, --help Show help
|
|
2424
|
+
-v, --version Show version
|
|
2425
|
+
|
|
2426
|
+
Run 'mavonengine <command> --help' for command options.
|
|
2427
|
+
`;
|
|
2428
|
+
async function main(argv) {
|
|
2429
|
+
const [command, ...args] = argv;
|
|
2430
|
+
if (!command || command === "-h" || command === "--help" || command === "help") {
|
|
2431
|
+
process9.stdout.write(HELP);
|
|
2432
|
+
return 0;
|
|
2433
|
+
}
|
|
2434
|
+
if (command === "-v" || command === "--version") {
|
|
2435
|
+
process9.stdout.write(`${version}
|
|
2436
|
+
`);
|
|
2437
|
+
return 0;
|
|
2438
|
+
}
|
|
2439
|
+
if (command === "create") {
|
|
2440
|
+
const options = parseCreateArgs(args);
|
|
2441
|
+
if (options === "help") {
|
|
2442
|
+
process9.stdout.write(CREATE_HELP);
|
|
2443
|
+
return 0;
|
|
2444
|
+
}
|
|
2445
|
+
return runCreateCommand(options);
|
|
2446
|
+
}
|
|
2447
|
+
if (command === "dashboard") {
|
|
2448
|
+
const options = parseDashboardArgs(args);
|
|
2449
|
+
if (options === "help") {
|
|
2450
|
+
process9.stdout.write(DASHBOARD_HELP);
|
|
2451
|
+
return 0;
|
|
2452
|
+
}
|
|
2453
|
+
if (!process9.stdout.isTTY || !process9.stdin.isTTY) {
|
|
2454
|
+
process9.stderr.write("mavonengine dashboard needs an interactive terminal\n");
|
|
2455
|
+
return 1;
|
|
2456
|
+
}
|
|
2457
|
+
await runDashboard(options);
|
|
2458
|
+
return 0;
|
|
2459
|
+
}
|
|
2460
|
+
if (command === "dev") {
|
|
2461
|
+
const options = parseDevArgs(args);
|
|
2462
|
+
if (options === "help") {
|
|
2463
|
+
process9.stdout.write(DEV_HELP);
|
|
2464
|
+
return 0;
|
|
2465
|
+
}
|
|
2466
|
+
return runDevCommand(options);
|
|
2467
|
+
}
|
|
2468
|
+
if (command === "install" || command === "i" || command === "add") {
|
|
2469
|
+
const options = parseInstallArgs(args);
|
|
2470
|
+
if (options === "help") {
|
|
2471
|
+
process9.stdout.write(INSTALL_HELP);
|
|
2472
|
+
return 0;
|
|
2473
|
+
}
|
|
2474
|
+
return runInstallCommand(options);
|
|
2475
|
+
}
|
|
2476
|
+
if (command === "mcp") {
|
|
2477
|
+
const options = parseMcpArgs(args);
|
|
2478
|
+
if (options === "help") {
|
|
2479
|
+
process9.stdout.write(MCP_HELP);
|
|
2480
|
+
return 0;
|
|
2481
|
+
}
|
|
2482
|
+
return runMcpCommand(options);
|
|
2483
|
+
}
|
|
2484
|
+
if (command === "build") {
|
|
2485
|
+
const options = parseBuildArgs(args);
|
|
2486
|
+
if (options === "help") {
|
|
2487
|
+
process9.stdout.write(BUILD_HELP);
|
|
2488
|
+
return 0;
|
|
2489
|
+
}
|
|
2490
|
+
return runBuildCommand(options);
|
|
2491
|
+
}
|
|
2492
|
+
throw new UsageError(`Unknown command '${command}'
|
|
2493
|
+
|
|
2494
|
+
${HELP}`);
|
|
2495
|
+
}
|
|
2496
|
+
main(process9.argv.slice(2)).then((code) => process9.exit(code)).catch((err) => {
|
|
2497
|
+
const isUsage = err instanceof UsageError || err.code?.startsWith("ERR_PARSE_ARGS");
|
|
2498
|
+
process9.stderr.write(`mavonengine: ${err.message}
|
|
2499
|
+
`);
|
|
2500
|
+
process9.exit(isUsage ? 2 : 1);
|
|
2501
|
+
});
|