@nexusgame/dev 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,32 +3,2119 @@
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/commands/start.ts
7
- import { Miniflare } from "miniflare";
6
+ // src/commands/serve.ts
7
+ import { mkdir as mkdir4 } from "fs/promises";
8
+ import path10 from "path";
9
+
10
+ // src/console-dir.ts
11
+ import { existsSync } from "fs";
8
12
  import path from "path";
9
13
  import { fileURLToPath } from "url";
10
- import { readFile } from "fs/promises";
14
+ var __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ function resolveConsoleDir() {
16
+ const fallback = path.join(__dirname, "console");
17
+ const candidates = [
18
+ process.env.NEXUS_CONSOLE_DIR,
19
+ fallback,
20
+ path.resolve(process.cwd(), "engine-console/dist"),
21
+ path.resolve(process.cwd(), "nexus-playground/engine-console/dist"),
22
+ path.resolve(process.cwd(), "../engine-console/dist"),
23
+ path.resolve(__dirname, "../../../engine-console/dist")
24
+ ].filter((candidate) => Boolean(candidate));
25
+ return candidates.find((dir) => existsSync(path.join(dir, "index.html")) && existsSync(path.join(dir, "workspace.html"))) ?? fallback;
26
+ }
27
+
28
+ // src/constants.ts
29
+ var ADMIN_SECRET = "nexus-dev-secret";
30
+ var JWT_SECRET = "nexus-dev-jwt-secret";
31
+ var FIXED_OWNER_ID = "dev-owner";
32
+ var DEFAULT_PORT = 56512;
33
+ var DEFAULT_WORKER_PORT = 8787;
34
+ var DEFAULT_WORKER_URL = `http://127.0.0.1:${DEFAULT_WORKER_PORT}`;
35
+ var DEV_VERSION = "0.4.0";
36
+
37
+ // src/console-server.ts
38
+ import http from "http";
39
+ import net from "net";
40
+ import path2 from "path";
41
+ import { createReadStream, existsSync as existsSync2 } from "fs";
42
+ import { stat } from "fs/promises";
43
+ import { createHmac } from "crypto";
44
+ var MIME_TYPES = {
45
+ ".html": "text/html; charset=utf-8",
46
+ ".js": "text/javascript; charset=utf-8",
47
+ ".mjs": "text/javascript; charset=utf-8",
48
+ ".css": "text/css; charset=utf-8",
49
+ ".json": "application/json; charset=utf-8",
50
+ ".svg": "image/svg+xml",
51
+ ".png": "image/png",
52
+ ".jpg": "image/jpeg",
53
+ ".jpeg": "image/jpeg",
54
+ ".ico": "image/x-icon",
55
+ ".woff2": "font/woff2",
56
+ ".map": "application/json; charset=utf-8",
57
+ ".txt": "text/plain; charset=utf-8"
58
+ };
59
+ function base64url(value) {
60
+ return Buffer.from(value).toString("base64url");
61
+ }
62
+ function signDevToken(userId, name, roomId) {
63
+ const now = Math.floor(Date.now() / 1e3);
64
+ const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
65
+ const payload = base64url(JSON.stringify({
66
+ sub: userId,
67
+ name,
68
+ roomId,
69
+ iat: now,
70
+ exp: now + 5 * 60
71
+ }));
72
+ const data = `${header}.${payload}`;
73
+ const signature = createHmac("sha256", JWT_SECRET).update(data).digest("base64url");
74
+ return `${data}.${signature}`;
75
+ }
76
+ function sendJson(res, status, payload) {
77
+ const body = JSON.stringify(payload);
78
+ res.writeHead(status, {
79
+ "Content-Type": "application/json; charset=utf-8",
80
+ "Content-Length": Buffer.byteLength(body),
81
+ "Cache-Control": "no-store"
82
+ });
83
+ res.end(body);
84
+ }
85
+ async function handleDevToken(url, res, isRoomAllowed) {
86
+ const roomId = url.searchParams.get("roomId") || "";
87
+ const userId = url.searchParams.get("userId") || "";
88
+ const name = url.searchParams.get("name") || userId;
89
+ if (!roomId || !await isRoomAllowed(roomId)) {
90
+ sendJson(res, 403, { error: `Room "${roomId}" is not connected to this workspace` });
91
+ return;
92
+ }
93
+ if (!userId.startsWith("dev:") || userId.length <= 4) {
94
+ sendJson(res, 403, { error: "Only dev:<roleId> users can request a dev token" });
95
+ return;
96
+ }
97
+ if (!/^[\w:.-]{1,128}$/.test(userId)) {
98
+ sendJson(res, 400, { error: "Invalid userId" });
99
+ return;
100
+ }
101
+ sendJson(res, 200, {
102
+ token: signDevToken(userId, name, roomId),
103
+ url: `/connect/${encodeURIComponent(roomId)}`,
104
+ expiresIn: 300
105
+ });
106
+ }
107
+ function proxyApi(req, res, enginePort) {
108
+ const proxyReq = http.request({
109
+ host: "127.0.0.1",
110
+ port: enginePort,
111
+ path: req.url,
112
+ method: req.method,
113
+ headers: {
114
+ ...req.headers,
115
+ host: `127.0.0.1:${enginePort}`,
116
+ authorization: `Bearer ${ADMIN_SECRET}`
117
+ }
118
+ }, (proxyRes) => {
119
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
120
+ proxyRes.pipe(res);
121
+ });
122
+ proxyReq.on("error", (error) => {
123
+ if (!res.headersSent) {
124
+ sendJson(res, 502, { error: `Engine proxy error: ${error.message}` });
125
+ } else {
126
+ res.destroy();
127
+ }
128
+ });
129
+ req.pipe(proxyReq);
130
+ }
131
+ function serveFile(req, res, filePath) {
132
+ const extension = path2.extname(filePath).toLowerCase();
133
+ const isHashedAsset = filePath.includes(`${path2.sep}assets${path2.sep}`);
134
+ res.writeHead(200, {
135
+ "Content-Type": MIME_TYPES[extension] || "application/octet-stream",
136
+ "Cache-Control": isHashedAsset ? "public, max-age=31536000, immutable" : "no-cache"
137
+ });
138
+ if (req.method === "HEAD") {
139
+ res.end();
140
+ return;
141
+ }
142
+ createReadStream(filePath).on("error", () => res.destroy()).pipe(res);
143
+ }
144
+ function missingConsolePage(appName) {
145
+ return `<!doctype html>
146
+ <html lang="zh-CN">
147
+ <head><meta charset="utf-8"><title>${appName}</title>
148
+ <style>body{font-family:system-ui,sans-serif;background:#0b1220;color:#e6edf7;padding:40px;line-height:1.6}code{background:#1d2c4a;padding:2px 6px;border-radius:4px}</style>
149
+ </head>
150
+ <body>
151
+ <h1>${appName} \u672A\u6784\u5EFA</h1>
152
+ <p>\u5148\u8FD0\u884C <code>pnpm --filter @nexusgame/engine-console run build</code>\uFF0C\u6216\u4F7F\u7528 <code>make publish-cli</code>\u3002</p>
153
+ <p>API \u4EE3\u7406\u548C <code>/dev/token</code> \u5DF2\u7ECF\u53EF\u7528\uFF0CCLI \u547D\u4EE4\u4E0D\u53D7\u5F71\u54CD\u3002</p>
154
+ </body>
155
+ </html>`;
156
+ }
157
+ function pageForPath(pathname, options) {
158
+ const rootPage = options.rootPage ?? "index.html";
159
+ if (pathname === "/" || pathname === "/dashboard" || pathname === "/games" || pathname === "/rooms" || pathname === "/rooms/") {
160
+ return { page: rootPage, isEntry: true };
161
+ }
162
+ if (options.roomPage && /^\/rooms\/[^/]+\/?$/.test(pathname)) {
163
+ return { page: options.roomPage, isEntry: true };
164
+ }
165
+ return null;
166
+ }
167
+ async function serveStatic(req, res, options, url) {
168
+ if (req.method !== "GET" && req.method !== "HEAD") {
169
+ sendJson(res, 405, { error: "Method not allowed" });
170
+ return;
171
+ }
172
+ let pathname;
173
+ try {
174
+ pathname = decodeURIComponent(url.pathname);
175
+ } catch {
176
+ sendJson(res, 400, { error: "Invalid path" });
177
+ return;
178
+ }
179
+ const resolvedDir = path2.resolve(options.consoleDir);
180
+ const rootPage = options.rootPage ?? "index.html";
181
+ const page = pageForPath(pathname, options);
182
+ const sendPage = (file, isEntry) => {
183
+ const pagePath = path2.join(resolvedDir, file);
184
+ if (existsSync2(pagePath)) {
185
+ serveFile(req, res, pagePath);
186
+ return true;
187
+ }
188
+ if (isEntry) {
189
+ res.writeHead(200, {
190
+ "Content-Type": "text/html; charset=utf-8",
191
+ "Cache-Control": "no-store"
192
+ });
193
+ res.end(missingConsolePage(options.appName ?? "Engine Console"));
194
+ return true;
195
+ }
196
+ return false;
197
+ };
198
+ if (page && sendPage(page.page, page.isEntry)) return;
199
+ const filePath = path2.resolve(resolvedDir, `.${pathname}`);
200
+ const insideDir = filePath === resolvedDir || filePath.startsWith(`${resolvedDir}${path2.sep}`);
201
+ if (!insideDir) {
202
+ sendJson(res, 403, { error: "Forbidden" });
203
+ return;
204
+ }
205
+ try {
206
+ const info = await stat(filePath);
207
+ if (info.isFile()) {
208
+ serveFile(req, res, filePath);
209
+ return;
210
+ }
211
+ } catch {
212
+ }
213
+ if (!path2.extname(pathname) && sendPage(rootPage, true)) return;
214
+ sendJson(res, 404, { error: "Not found" });
215
+ }
216
+ function handleUpgrade(req, socket, head, enginePort) {
217
+ let pathname = "";
218
+ try {
219
+ pathname = new URL(req.url || "/", "http://localhost").pathname;
220
+ } catch {
221
+ socket.destroy();
222
+ return;
223
+ }
224
+ if (!pathname.startsWith("/connect/")) {
225
+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
226
+ socket.destroy();
227
+ return;
228
+ }
229
+ const upstream = net.connect({ host: "127.0.0.1", port: enginePort }, () => {
230
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
231
+ for (let index = 0; index < req.rawHeaders.length; index += 2) {
232
+ lines.push(`${req.rawHeaders[index]}: ${req.rawHeaders[index + 1]}`);
233
+ }
234
+ upstream.write(`${lines.join("\r\n")}\r
235
+ \r
236
+ `);
237
+ if (head.length > 0) upstream.write(head);
238
+ upstream.pipe(socket);
239
+ socket.pipe(upstream);
240
+ });
241
+ upstream.on("error", () => socket.destroy());
242
+ socket.on("error", () => upstream.destroy());
243
+ }
244
+ async function startConsoleServer(options) {
245
+ const host2 = options.host || "127.0.0.1";
246
+ const enginePort = options.enginePort;
247
+ const server = http.createServer((req, res) => {
248
+ void (async () => {
249
+ const url = new URL(req.url || "/", `http://${req.headers.host || host2}`);
250
+ if (options.handleApi) {
251
+ try {
252
+ if (await options.handleApi(req, res, url)) return;
253
+ } catch (error) {
254
+ if (!res.headersSent) {
255
+ sendJson(res, 500, {
256
+ error: error instanceof Error ? error.message : "Internal error"
257
+ });
258
+ } else {
259
+ res.destroy();
260
+ }
261
+ return;
262
+ }
263
+ }
264
+ if (url.pathname === "/dev/token") {
265
+ if (req.method !== "GET") {
266
+ sendJson(res, 405, { error: "Method not allowed" });
267
+ return;
268
+ }
269
+ await handleDevToken(url, res, options.isRoomAllowed);
270
+ return;
271
+ }
272
+ if (url.pathname === "/api/engine" || url.pathname.startsWith("/api/engine/")) {
273
+ proxyApi(req, res, enginePort);
274
+ return;
275
+ }
276
+ if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
277
+ sendJson(res, 404, { error: "Not found" });
278
+ return;
279
+ }
280
+ await serveStatic(req, res, options, url);
281
+ })();
282
+ });
283
+ server.on("upgrade", (req, socket, head) => {
284
+ handleUpgrade(req, socket, head, enginePort);
285
+ });
286
+ await new Promise((resolve, reject) => {
287
+ server.once("error", reject);
288
+ server.listen(options.publicPort, host2, () => {
289
+ server.off("error", reject);
290
+ resolve();
291
+ });
292
+ });
293
+ return {
294
+ url: `http://${host2}:${options.publicPort}`,
295
+ close: async () => {
296
+ server.closeAllConnections?.();
297
+ await new Promise((resolve) => server.close(() => resolve()));
298
+ }
299
+ };
300
+ }
301
+
302
+ // ../engine-client/src/routes.ts
303
+ var ENGINE_ADMIN_PREFIX = "/api/engine";
304
+ var ENGINE_ROOM_PREFIX = `${ENGINE_ADMIN_PREFIX}/rooms`;
305
+ function engineRoomPath(roomId, suffix = "") {
306
+ return `${ENGINE_ROOM_PREFIX}/${encodeURIComponent(roomId)}${suffix}`;
307
+ }
308
+ var ENGINE_ROUTES = {
309
+ createRoom: `${ENGINE_ADMIN_PREFIX}/create`,
310
+ roomState: (roomId) => engineRoomPath(roomId),
311
+ roomDebug: (roomId) => engineRoomPath(roomId, "/debug"),
312
+ roomPerspective: (roomId, roleId) => {
313
+ const query = roleId ? `?roleId=${encodeURIComponent(roleId)}` : "";
314
+ return engineRoomPath(roomId, `/perspective${query}`);
315
+ },
316
+ roleAction: (roomId) => engineRoomPath(roomId, "/role-action"),
317
+ setGame: (roomId) => engineRoomPath(roomId, "/set-game"),
318
+ startGame: (roomId) => engineRoomPath(roomId, "/start-game"),
319
+ stopGame: (roomId) => engineRoomPath(roomId, "/stop-game"),
320
+ restartGame: (roomId) => engineRoomPath(roomId, "/restart-game"),
321
+ pauseGame: (roomId) => engineRoomPath(roomId, "/pause-game"),
322
+ resumeGame: (roomId) => engineRoomPath(roomId, "/resume-game"),
323
+ backtrack: (roomId) => engineRoomPath(roomId, "/backtrack"),
324
+ players: (roomId) => engineRoomPath(roomId, "/players"),
325
+ player: (roomId, userId) => engineRoomPath(roomId, `/players/${encodeURIComponent(userId)}`),
326
+ assignRole: (roomId) => engineRoomPath(roomId, "/assign-role"),
327
+ addBot: (roomId) => engineRoomPath(roomId, "/add-bot")
328
+ };
329
+
330
+ // ../engine-client/src/admin.ts
331
+ var EngineAdminClient = class {
332
+ baseUrl;
333
+ token;
334
+ fetchImpl;
335
+ constructor(options) {
336
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
337
+ this.token = options.token;
338
+ this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
339
+ }
340
+ // ─── Room creation ─────────────────────────────────────
341
+ async createRoom(roomId, ownerId) {
342
+ return this.request("POST", ENGINE_ROUTES.createRoom, { roomId, ownerId });
343
+ }
344
+ // ─── Read endpoints ────────────────────────────────────
345
+ async getState(roomId) {
346
+ return this.request("GET", ENGINE_ROUTES.roomState(roomId));
347
+ }
348
+ async getDebug(roomId) {
349
+ return this.request("GET", ENGINE_ROUTES.roomDebug(roomId));
350
+ }
351
+ async getPerspective(roomId, roleId) {
352
+ return this.request("GET", ENGINE_ROUTES.roomPerspective(roomId, roleId));
353
+ }
354
+ // ─── Commands ──────────────────────────────────────────
355
+ async submitAction(roomId, roleId, action) {
356
+ return this.request("POST", ENGINE_ROUTES.roleAction(roomId), { roleId, action });
357
+ }
358
+ async setGame(roomId, request) {
359
+ return this.request("POST", ENGINE_ROUTES.setGame(roomId), request);
360
+ }
361
+ async startGame(roomId) {
362
+ return this.request("POST", ENGINE_ROUTES.startGame(roomId));
363
+ }
364
+ async stopGame(roomId) {
365
+ return this.request("POST", ENGINE_ROUTES.stopGame(roomId));
366
+ }
367
+ async restartGame(roomId) {
368
+ return this.request("POST", ENGINE_ROUTES.restartGame(roomId));
369
+ }
370
+ async pauseGame(roomId) {
371
+ return this.request("POST", ENGINE_ROUTES.pauseGame(roomId));
372
+ }
373
+ async resumeGame(roomId) {
374
+ return this.request("POST", ENGINE_ROUTES.resumeGame(roomId));
375
+ }
376
+ async backtrack(roomId, index) {
377
+ return this.request("POST", ENGINE_ROUTES.backtrack(roomId), { index });
378
+ }
379
+ async createPlayer(roomId, request) {
380
+ return this.request("POST", ENGINE_ROUTES.players(roomId), request);
381
+ }
382
+ async removePlayer(roomId, userId) {
383
+ return this.request("DELETE", ENGINE_ROUTES.player(roomId, userId));
384
+ }
385
+ async assignRole(roomId, roleId, userId) {
386
+ return this.request("POST", ENGINE_ROUTES.assignRole(roomId), { roleId, userId });
387
+ }
388
+ async addBot(roomId, request) {
389
+ return this.request("POST", ENGINE_ROUTES.addBot(roomId), request);
390
+ }
391
+ // ─── Transport ─────────────────────────────────────────
392
+ async request(method, path18, body) {
393
+ const headers = {
394
+ "Content-Type": "application/json"
395
+ };
396
+ const token = typeof this.token === "function" ? await this.token() : this.token;
397
+ if (token) headers.Authorization = `Bearer ${token}`;
398
+ let response;
399
+ try {
400
+ response = await this.fetchImpl(`${this.baseUrl}${path18}`, {
401
+ method,
402
+ headers,
403
+ body: body === void 0 ? void 0 : JSON.stringify(body)
404
+ });
405
+ } catch (error) {
406
+ return {
407
+ ok: false,
408
+ error: error instanceof Error ? error.message : "Request failed"
409
+ };
410
+ }
411
+ const text = await response.text();
412
+ let payload = null;
413
+ try {
414
+ payload = text ? JSON.parse(text) : null;
415
+ } catch {
416
+ payload = text;
417
+ }
418
+ if (!response.ok) {
419
+ return {
420
+ ok: false,
421
+ error: payload?.error || `HTTP ${response.status}`,
422
+ status: response.status,
423
+ data: payload
424
+ };
425
+ }
426
+ if (payload && typeof payload === "object" && typeof payload.ok === "boolean") {
427
+ return payload.ok ? { ok: true, data: payload.data } : { ok: false, error: payload.error || "Command failed", data: payload };
428
+ }
429
+ return { ok: true, data: payload };
430
+ }
431
+ };
432
+
433
+ // src/workspace/config.ts
434
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
435
+ import path4 from "path";
436
+
437
+ // src/workspace/paths.ts
438
+ import path3 from "path";
439
+ var STATE_DIR_NAME = ".nexus";
440
+ var GAME_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
441
+ function isSafeGameId(id) {
442
+ return GAME_ID_PATTERN.test(id) && id !== "." && id !== "..";
443
+ }
444
+ function workspaceStateDir(root) {
445
+ return path3.join(root, STATE_DIR_NAME);
446
+ }
447
+ function logsDir(root) {
448
+ return path3.join(workspaceStateDir(root), "logs");
449
+ }
450
+ function workspaceConfigFile(root) {
451
+ return path3.join(workspaceStateDir(root), "workspace.json");
452
+ }
453
+ function hostRecordFile(root) {
454
+ return path3.join(workspaceStateDir(root), "host.json");
455
+ }
456
+ function hostLogFile(root) {
457
+ return path3.join(logsDir(root), "host.log");
458
+ }
459
+ function workersDir(root) {
460
+ return path3.join(workspaceStateDir(root), "workers");
461
+ }
462
+ function workerRecordFile(root, gameId) {
463
+ return path3.join(workersDir(root), `${gameId}.json`);
464
+ }
465
+ function workerLogFile(root, gameId) {
466
+ return path3.join(logsDir(root), `${gameId}.worker.log`);
467
+ }
468
+ function hostEngineStateDir(root) {
469
+ return path3.join(workspaceStateDir(root), "engine");
470
+ }
471
+
472
+ // src/workspace/process.ts
473
+ import { open, stat as stat2 } from "fs/promises";
474
+ function isProcessAlive(pid) {
475
+ if (!Number.isInteger(pid) || pid <= 0) return false;
476
+ try {
477
+ process.kill(pid, 0);
478
+ return true;
479
+ } catch (error) {
480
+ return error.code === "EPERM";
481
+ }
482
+ }
483
+ async function fileSize(file) {
484
+ try {
485
+ return (await stat2(file)).size;
486
+ } catch {
487
+ return 0;
488
+ }
489
+ }
490
+ async function tailFile(file, lineCount = 200) {
491
+ try {
492
+ const info = await stat2(file);
493
+ if (info.size <= 0) return "";
494
+ const length = Math.min(info.size, 128 * 1024);
495
+ const handle = await open(file, "r");
496
+ try {
497
+ const buffer = Buffer.alloc(length);
498
+ await handle.read(buffer, 0, length, info.size - length);
499
+ const lines = buffer.toString("utf8").split(/\r?\n/);
500
+ return lines.slice(-Math.max(1, lineCount)).join("\n");
501
+ } finally {
502
+ await handle.close();
503
+ }
504
+ } catch (error) {
505
+ if (error.code === "ENOENT") return "";
506
+ throw error;
507
+ }
508
+ }
509
+ async function followLogs(targets, options = {}) {
510
+ const initialLines = options.initialLines ?? 20;
511
+ const offsets = /* @__PURE__ */ new Map();
512
+ const prefix = targets.length > 1;
513
+ for (const target of targets) {
514
+ const text = await tailFile(target.file, initialLines);
515
+ if (text.trim()) {
516
+ process.stdout.write(`--- ${target.label} \u6700\u8FD1\u65E5\u5FD7 ---
517
+ `);
518
+ process.stdout.write(text.endsWith("\n") ? text : `${text}
519
+ `);
520
+ }
521
+ offsets.set(target.file, await fileSize(target.file));
522
+ }
523
+ const readGrowth = async (target) => {
524
+ const size = await fileSize(target.file);
525
+ let offset = offsets.get(target.file) ?? 0;
526
+ if (size < offset) offset = 0;
527
+ if (size === offset) {
528
+ offsets.set(target.file, size);
529
+ return;
530
+ }
531
+ const length = size - offset;
532
+ const handle = await open(target.file, "r");
533
+ try {
534
+ const buffer = Buffer.alloc(length);
535
+ await handle.read(buffer, 0, length, offset);
536
+ const text = buffer.toString("utf8");
537
+ if (prefix) {
538
+ for (const line of text.split(/\r?\n/)) {
539
+ if (line) process.stdout.write(`[${target.label}] ${line}
540
+ `);
541
+ }
542
+ } else {
543
+ process.stdout.write(text);
544
+ }
545
+ } finally {
546
+ await handle.close();
547
+ }
548
+ offsets.set(target.file, size);
549
+ };
550
+ let busy = false;
551
+ const timer = setInterval(() => {
552
+ if (busy) return;
553
+ busy = true;
554
+ void Promise.all(targets.map(readGrowth)).catch(() => void 0).finally(() => {
555
+ busy = false;
556
+ });
557
+ }, 400);
558
+ return () => clearInterval(timer);
559
+ }
560
+
561
+ // src/workspace/config.ts
562
+ function isRecord(value) {
563
+ return typeof value === "object" && value !== null && !Array.isArray(value);
564
+ }
565
+ function stringField(value, field, source) {
566
+ if (typeof value !== "string" || !value.trim()) {
567
+ throw new Error(`${source}: \u5B57\u6BB5 "${field}" \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
568
+ }
569
+ return value;
570
+ }
571
+ function parseConnection(value, source) {
572
+ if (!isRecord(value)) throw new Error(`${source}: connection \u5FC5\u987B\u662F\u5BF9\u8C61`);
573
+ const roleIds = Array.isArray(value.roleIds) ? value.roleIds.filter((role) => typeof role === "string") : [];
574
+ return {
575
+ roomId: stringField(value.roomId, "roomId", source),
576
+ gameId: stringField(value.gameId, "gameId", source),
577
+ name: typeof value.name === "string" && value.name.trim() ? value.name : stringField(value.gameId, "gameId", source),
578
+ gameDir: stringField(value.gameDir, "gameDir", source),
579
+ workerUrl: stringField(value.workerUrl, "workerUrl", source),
580
+ players: typeof value.players === "number" && Number.isInteger(value.players) && value.players > 0 ? value.players : void 0,
581
+ roleIds,
582
+ createdAt: stringField(value.createdAt, "createdAt", source),
583
+ updatedAt: stringField(value.updatedAt, "updatedAt", source)
584
+ };
585
+ }
586
+ async function writeJsonAtomic(file, value) {
587
+ await mkdir(path4.dirname(file), { recursive: true });
588
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
589
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}
590
+ `, "utf8");
591
+ await rename(temporary, file);
592
+ }
593
+ function emptyWorkspaceConfig(root) {
594
+ return {
595
+ version: 1,
596
+ root: path4.resolve(root),
597
+ connections: []
598
+ };
599
+ }
600
+ async function readWorkspaceConfig(root) {
601
+ const file = workspaceConfigFile(root);
602
+ let raw;
603
+ try {
604
+ raw = await readFile(file, "utf8");
605
+ } catch (error) {
606
+ if (error.code === "ENOENT") {
607
+ return emptyWorkspaceConfig(root);
608
+ }
609
+ throw new Error(`\u65E0\u6CD5\u8BFB\u53D6\u5DE5\u4F5C\u533A\u914D\u7F6E ${file}: ${error.message}`);
610
+ }
611
+ let parsed;
612
+ try {
613
+ parsed = JSON.parse(raw);
614
+ } catch (error) {
615
+ throw new Error(`\u5DE5\u4F5C\u533A\u914D\u7F6E ${file} \u4E0D\u662F\u5408\u6CD5 JSON: ${error.message}`);
616
+ }
617
+ if (!isRecord(parsed)) throw new Error(`\u5DE5\u4F5C\u533A\u914D\u7F6E ${file} \u5FC5\u987B\u662F JSON \u5BF9\u8C61`);
618
+ if (parsed.version !== 1) throw new Error(`\u5DE5\u4F5C\u533A\u914D\u7F6E ${file} \u7684 version \u5FC5\u987B\u4E3A 1`);
619
+ const connections = Array.isArray(parsed.connections) ? parsed.connections.map((connection, index) => parseConnection(connection, `${file} connections[${index}]`)) : [];
620
+ return {
621
+ version: 1,
622
+ root: typeof parsed.root === "string" && parsed.root ? parsed.root : path4.resolve(root),
623
+ connections
624
+ };
625
+ }
626
+ async function writeWorkspaceConfig(root, config) {
627
+ await writeJsonAtomic(workspaceConfigFile(root), {
628
+ ...config,
629
+ version: 1,
630
+ root: path4.resolve(root)
631
+ });
632
+ }
633
+ async function readHostRecord(root) {
634
+ const file = hostRecordFile(root);
635
+ let raw;
636
+ try {
637
+ raw = await readFile(file, "utf8");
638
+ } catch (error) {
639
+ if (error.code === "ENOENT") return null;
640
+ throw new Error(`\u65E0\u6CD5\u8BFB\u53D6 host \u8BB0\u5F55 ${file}: ${error.message}`);
641
+ }
642
+ try {
643
+ const parsed = JSON.parse(raw);
644
+ if (parsed.version !== 1 || typeof parsed.pid !== "number" || typeof parsed.port !== "number" || typeof parsed.enginePort !== "number" || typeof parsed.root !== "string" || typeof parsed.url !== "string" || typeof parsed.startedAt !== "string") {
645
+ return null;
646
+ }
647
+ return parsed;
648
+ } catch {
649
+ return null;
650
+ }
651
+ }
652
+ async function writeHostRecord(root, record) {
653
+ await writeJsonAtomic(hostRecordFile(root), record);
654
+ }
655
+ async function removeHostRecord(root, pid) {
656
+ if (pid !== void 0) {
657
+ const existing = await readHostRecord(root);
658
+ if (existing && existing.pid !== pid) return;
659
+ }
660
+ await rm(hostRecordFile(root), { force: true });
661
+ }
662
+ function isHostRecordAlive(record) {
663
+ return isProcessAlive(record.pid);
664
+ }
665
+ function findConnection(config, selector) {
666
+ return config.connections.find(
667
+ (connection) => connection.roomId === selector || connection.gameId === selector
668
+ );
669
+ }
670
+
671
+ // src/workspace/scan.ts
672
+ import { readdir, readFile as readFile2, realpath, stat as stat3 } from "fs/promises";
673
+ import path5 from "path";
674
+ var MANIFEST_NAME = "nexus.game.json";
675
+ var GAME_DIR_NAMES = /* @__PURE__ */ new Set(["games", "nexus-games"]);
676
+ var SKIPPED_DIRS = /* @__PURE__ */ new Set([
677
+ "node_modules",
678
+ "dist",
679
+ "build",
680
+ "coverage",
681
+ "public"
682
+ ]);
683
+ async function isDirectory(target) {
684
+ try {
685
+ return (await stat3(target)).isDirectory();
686
+ } catch {
687
+ return false;
688
+ }
689
+ }
690
+ async function childDirs(dir) {
691
+ try {
692
+ const entries = await readdir(dir, { withFileTypes: true });
693
+ return entries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink()).map((entry) => entry.name).filter((name) => !name.startsWith(".") && !SKIPPED_DIRS.has(name)).map((name) => path5.join(dir, name));
694
+ } catch {
695
+ return [];
696
+ }
697
+ }
698
+ async function scanGames(rootInput = process.cwd()) {
699
+ const root = path5.resolve(rootInput);
700
+ const warnings = [];
701
+ const candidates = /* @__PURE__ */ new Set();
702
+ candidates.add(root);
703
+ const direct = await childDirs(root);
704
+ for (const dir of direct) {
705
+ candidates.add(dir);
706
+ if (GAME_DIR_NAMES.has(path5.basename(dir))) {
707
+ for (const nested of await childDirs(dir)) candidates.add(nested);
708
+ }
709
+ }
710
+ const games = [];
711
+ const seenDirs = /* @__PURE__ */ new Set();
712
+ for (const dir of candidates) {
713
+ if (!await isDirectory(dir)) continue;
714
+ let realDir;
715
+ try {
716
+ realDir = await realpath(dir);
717
+ } catch {
718
+ realDir = dir;
719
+ }
720
+ if (seenDirs.has(realDir)) continue;
721
+ seenDirs.add(realDir);
722
+ const manifestPath = path5.join(dir, MANIFEST_NAME);
723
+ let raw;
724
+ try {
725
+ raw = await readFile2(manifestPath, "utf8");
726
+ } catch (error) {
727
+ const code = error.code;
728
+ if (code === "ENOENT") continue;
729
+ warnings.push(`${manifestPath}: \u65E0\u6CD5\u8BFB\u53D6 (${error.message})`);
730
+ continue;
731
+ }
732
+ let parsed;
733
+ try {
734
+ parsed = JSON.parse(raw);
735
+ } catch (error) {
736
+ warnings.push(`${manifestPath}: JSON \u89E3\u6790\u5931\u8D25 (${error.message})`);
737
+ continue;
738
+ }
739
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
740
+ warnings.push(`${manifestPath}: \u5185\u5BB9\u5FC5\u987B\u662F JSON \u5BF9\u8C61`);
741
+ continue;
742
+ }
743
+ const manifest = parsed;
744
+ const id = typeof manifest.id === "string" ? manifest.id.trim() : "";
745
+ if (!id) {
746
+ warnings.push(`${manifestPath}: \u7F3A\u5C11\u5B57\u7B26\u4E32\u5B57\u6BB5 "id"`);
747
+ continue;
748
+ }
749
+ if (!isSafeGameId(id)) {
750
+ warnings.push(`${manifestPath}: id "${id}" \u975E\u6CD5\uFF08\u53EA\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001. _ -\uFF0C\u4E14\u4E0D\u80FD\u662F . \u6216 ..\uFF09`);
751
+ continue;
752
+ }
753
+ const name = typeof manifest.name === "string" && manifest.name.trim() ? manifest.name.trim() : id;
754
+ games.push({
755
+ id,
756
+ name,
757
+ dir: path5.resolve(dir),
758
+ manifestPath,
759
+ manifest,
760
+ duplicate: false
761
+ });
762
+ }
763
+ const counts = /* @__PURE__ */ new Map();
764
+ for (const game2 of games) counts.set(game2.id, (counts.get(game2.id) ?? 0) + 1);
765
+ for (const game2 of games) game2.duplicate = (counts.get(game2.id) ?? 0) > 1;
766
+ for (const [id, count] of counts) {
767
+ if (count > 1) {
768
+ warnings.push(`\u6E38\u620F id "${id}" \u51FA\u73B0\u5728 ${count} \u4E2A\u76EE\u5F55\u4E2D\uFF1B\u7528 id \u542F\u52A8\u4F1A\u62A5\u6B67\u4E49\uFF0C\u8BF7\u6539\u7528\u76EE\u5F55\u8DEF\u5F84\u3002`);
769
+ }
770
+ }
771
+ games.sort((a, b) => a.id.localeCompare(b.id) || a.dir.localeCompare(b.dir));
772
+ return { root, games, warnings };
773
+ }
774
+ function resolveGame(games, selector) {
775
+ const asPath = path5.resolve(selector);
776
+ const byPath = games.find(
777
+ (game2) => path5.resolve(game2.dir) === asPath || path5.resolve(game2.manifestPath) === asPath
778
+ );
779
+ if (byPath) return byPath;
780
+ const byId = games.filter((game2) => game2.id === selector);
781
+ if (byId.length === 1) return byId[0];
782
+ if (byId.length > 1) {
783
+ throw new Error(
784
+ `\u6E38\u620F id "${selector}" \u5BF9\u5E94\u591A\u4E2A\u76EE\u5F55:
785
+ ${byId.map((game2) => ` - ${game2.dir}`).join("\n")}
786
+ \u8BF7\u6539\u7528\u76EE\u5F55\u8DEF\u5F84\u6307\u5B9A\u3002`
787
+ );
788
+ }
789
+ const byName = games.filter((game2) => game2.name === selector);
790
+ if (byName.length === 1) return byName[0];
791
+ if (byName.length > 1) {
792
+ throw new Error(
793
+ `\u6E38\u620F\u540D "${selector}" \u5BF9\u5E94\u591A\u4E2A\u76EE\u5F55:
794
+ ${byName.map((game2) => ` - ${game2.dir}`).join("\n")}
795
+ \u8BF7\u6539\u7528 id \u6216\u76EE\u5F55\u8DEF\u5F84\u6307\u5B9A\u3002`
796
+ );
797
+ }
798
+ const available = games.map((game2) => game2.id).join(", ") || "(\u65E0)";
799
+ throw new Error(`\u627E\u4E0D\u5230\u6E38\u620F "${selector}"\u3002\u53EF\u7528\u6E38\u620F: ${available}`);
800
+ }
801
+
802
+ // src/host/worker-manager.ts
803
+ import { spawn, spawnSync } from "child_process";
804
+ import {
805
+ appendFile,
806
+ mkdir as mkdir2,
807
+ open as open2,
808
+ readFile as readFile4,
809
+ readdir as readdir2,
810
+ rename as rename2,
811
+ rm as rm2,
812
+ writeFile as writeFile2
813
+ } from "fs/promises";
814
+ import path7 from "path";
815
+
816
+ // src/workspace/package.ts
817
+ import { access, readFile as readFile3 } from "fs/promises";
818
+ import path6 from "path";
819
+ async function pathExists(target) {
820
+ try {
821
+ await access(target);
822
+ return true;
823
+ } catch {
824
+ return false;
825
+ }
826
+ }
827
+ function parsePackageManager(value) {
828
+ if (typeof value !== "string") return void 0;
829
+ const name = value.split("@")[0]?.trim();
830
+ return name === "pnpm" || name === "npm" || name === "yarn" || name === "bun" ? name : void 0;
831
+ }
832
+ async function detectPackageManager(dir, pkg) {
833
+ const declared = parsePackageManager(pkg.packageManager);
834
+ if (declared) return declared;
835
+ if (await pathExists(path6.join(dir, "pnpm-lock.yaml"))) return "pnpm";
836
+ if (await pathExists(path6.join(dir, "yarn.lock"))) return "yarn";
837
+ if (await pathExists(path6.join(dir, "bun.lockb"))) return "bun";
838
+ if (await pathExists(path6.join(dir, "bun.lock"))) return "bun";
839
+ if (await pathExists(path6.join(dir, "package-lock.json"))) return "npm";
840
+ return "pnpm";
841
+ }
842
+ async function readPackageJson(dir) {
843
+ const file = path6.join(dir, "package.json");
844
+ const parsed = JSON.parse(await readFile3(file, "utf8"));
845
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
846
+ throw new Error(`${file} \u5FC5\u987B\u662F JSON \u5BF9\u8C61`);
847
+ }
848
+ return parsed;
849
+ }
850
+
851
+ // src/workspace/ports.ts
852
+ import net2 from "net";
853
+ function isPortFree(port, host2 = "127.0.0.1") {
854
+ return new Promise((resolve) => {
855
+ const server = net2.createServer();
856
+ server.unref();
857
+ server.once("error", () => resolve(false));
858
+ server.listen({ port, host: host2, exclusive: true }, () => {
859
+ server.close(() => resolve(true));
860
+ });
861
+ });
862
+ }
863
+
864
+ // src/host/worker-manager.ts
865
+ var START_TIMEOUT_MS = 3e4;
866
+ var STOP_TIMEOUT_MS = 5e3;
867
+ var PROBE_TIMEOUT_MS = 2500;
868
+ var PROBE_CACHE_MS = 2e3;
869
+ var CONFIG_CACHE_MS = 5e3;
870
+ var PORT_SEARCH_LIMIT = 50;
871
+ var WorkerManagerError = class extends Error {
872
+ constructor(status, message) {
873
+ super(message);
874
+ this.status = status;
875
+ this.name = "WorkerManagerError";
876
+ }
877
+ };
878
+ function errorMessage(error) {
879
+ return error instanceof Error ? error.message : String(error);
880
+ }
881
+ function sleep(ms) {
882
+ return new Promise((resolve) => setTimeout(resolve, ms));
883
+ }
884
+ function workerUrl(port) {
885
+ return `http://127.0.0.1:${port}`;
886
+ }
887
+ function isRecord2(value) {
888
+ return typeof value === "object" && value !== null && !Array.isArray(value);
889
+ }
890
+ async function writeJsonAtomic2(file, value) {
891
+ await mkdir2(path7.dirname(file), { recursive: true });
892
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
893
+ await writeFile2(temporary, `${JSON.stringify(value, null, 2)}
894
+ `, "utf8");
895
+ await rename2(temporary, file);
896
+ }
897
+ function parseWranglerPort(toml) {
898
+ let section = "";
899
+ for (const line of toml.split(/\r?\n/)) {
900
+ const heading = /^\s*\[([^\]]+)\]\s*$/.exec(line);
901
+ if (heading) {
902
+ section = heading[1].trim();
903
+ continue;
904
+ }
905
+ if (section !== "dev") continue;
906
+ const match = /^\s*port\s*=\s*(\d+)\s*(?:#.*)?$/.exec(line);
907
+ if (match) return Number.parseInt(match[1], 10);
908
+ }
909
+ return void 0;
910
+ }
911
+ function parseScriptPort(script) {
912
+ const match = /--port(?:=|\s+)(\d+)/.exec(script);
913
+ return match ? Number.parseInt(match[1], 10) : void 0;
914
+ }
915
+ async function readWranglerPort(dir) {
916
+ for (const candidate of [
917
+ path7.join(dir, "worker", "wrangler.toml"),
918
+ path7.join(dir, "wrangler.toml")
919
+ ]) {
920
+ if (!await pathExists(candidate)) continue;
921
+ try {
922
+ const port = parseWranglerPort(await readFile4(candidate, "utf8"));
923
+ if (port) return port;
924
+ } catch {
925
+ }
926
+ }
927
+ return void 0;
928
+ }
929
+ function readRecord(value) {
930
+ if (!isRecord2(value) || value.version !== 1) return null;
931
+ const gameId = typeof value.gameId === "string" ? value.gameId : "";
932
+ const dir = typeof value.dir === "string" ? value.dir : "";
933
+ const pid = typeof value.pid === "number" ? value.pid : 0;
934
+ const port = typeof value.port === "number" ? value.port : 0;
935
+ const url = typeof value.url === "string" ? value.url : "";
936
+ const logFile = typeof value.logFile === "string" ? value.logFile : "";
937
+ const startedAt = typeof value.startedAt === "string" ? value.startedAt : "";
938
+ const command = typeof value.command === "string" ? value.command : "";
939
+ if (!isSafeGameId(gameId) || !dir || !Number.isInteger(pid) || pid <= 0) return null;
940
+ if (!Number.isInteger(port) || port <= 0 || !url || !logFile || !startedAt) return null;
941
+ return { version: 1, gameId, dir, pid, port, url, logFile, startedAt, command };
942
+ }
943
+ function workerArgs(config, port) {
944
+ const args = ["run", "dev"];
945
+ if (!config.wrangler) return args;
946
+ if (config.packageManager === "npm") {
947
+ args.push("--", "--port", String(port));
948
+ } else {
949
+ args.push("--port", String(port));
950
+ }
951
+ return args;
952
+ }
953
+ function killProcessTree(pid, signal) {
954
+ if (process.platform === "win32") {
955
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
956
+ windowsHide: true,
957
+ stdio: "ignore"
958
+ });
959
+ return;
960
+ }
961
+ try {
962
+ process.kill(-pid, signal);
963
+ } catch {
964
+ try {
965
+ process.kill(pid, signal);
966
+ } catch {
967
+ }
968
+ }
969
+ }
970
+ var WorkerManager = class _WorkerManager {
971
+ constructor(root, logger) {
972
+ this.root = root;
973
+ this.logger = logger;
974
+ }
975
+ records = /* @__PURE__ */ new Map();
976
+ children = /* @__PURE__ */ new Map();
977
+ configCache = /* @__PURE__ */ new Map();
978
+ probeCache = /* @__PURE__ */ new Map();
979
+ static async create(root, logger) {
980
+ const manager = new _WorkerManager(root, logger);
981
+ await manager.init();
982
+ return manager;
983
+ }
984
+ async status(gameId, dir, force = false) {
985
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
986
+ const logFile = workerLogFile(this.root, gameId);
987
+ const configResult = await this.getConfig(dir);
988
+ const record = await this.getRecord(gameId);
989
+ const port = record?.port ?? configResult.config?.basePort ?? DEFAULT_WORKER_PORT;
990
+ const url = record?.url ?? workerUrl(port);
991
+ const probe = await this.probe(url, force);
992
+ if (probe.reachable && (!probe.metadataId || probe.metadataId === gameId)) {
993
+ const owned = Boolean(record);
994
+ return {
995
+ state: owned ? "running" : "external",
996
+ reachable: true,
997
+ url,
998
+ port,
999
+ pid: owned ? record?.pid : void 0,
1000
+ owned,
1001
+ minPlayers: probe.minPlayers,
1002
+ maxPlayers: probe.maxPlayers,
1003
+ logFile: record?.logFile ?? logFile,
1004
+ startedAt: record?.startedAt,
1005
+ checkedAt
1006
+ };
1007
+ }
1008
+ if (probe.reachable && probe.metadataId && probe.metadataId !== gameId) {
1009
+ return {
1010
+ state: "error",
1011
+ reachable: false,
1012
+ url,
1013
+ port,
1014
+ owned: false,
1015
+ logFile,
1016
+ checkedAt,
1017
+ error: `\u7AEF\u53E3 ${port} \u4E0A\u7684 Worker \u5C5E\u4E8E "${probe.metadataId}"\uFF0C\u4E0D\u662F "${gameId}"\uFF1B\u7528 --worker-port \u6307\u5B9A\u5176\u4ED6\u7AEF\u53E3\u3002`
1018
+ };
1019
+ }
1020
+ if (record) {
1021
+ if (isProcessAlive(record.pid)) {
1022
+ const age = Date.now() - Date.parse(record.startedAt);
1023
+ const state = Number.isFinite(age) && age > START_TIMEOUT_MS + 15e3 ? "error" : "starting";
1024
+ return {
1025
+ state,
1026
+ reachable: false,
1027
+ url,
1028
+ port,
1029
+ pid: record.pid,
1030
+ owned: true,
1031
+ logFile: record.logFile,
1032
+ startedAt: record.startedAt,
1033
+ checkedAt,
1034
+ error: `Worker \u8FDB\u7A0B ${record.pid} \u6B63\u5728\u8FD0\u884C\uFF0C\u4F46 ${url}/metadata \u65E0\u54CD\u5E94\u3002\u67E5\u770B\u65E5\u5FD7\uFF1Anpgame-dev game logs ${gameId}`
1035
+ };
1036
+ }
1037
+ await this.removeRecord(gameId);
1038
+ }
1039
+ return {
1040
+ state: configResult.error ? "error" : "stopped",
1041
+ reachable: false,
1042
+ owned: false,
1043
+ logFile,
1044
+ checkedAt,
1045
+ error: configResult.error
1046
+ };
1047
+ }
1048
+ async probe(url, force = false) {
1049
+ const cached = this.probeCache.get(url);
1050
+ if (!force && cached && Date.now() - cached.at < PROBE_CACHE_MS) {
1051
+ return cached.result;
1052
+ }
1053
+ let result;
1054
+ try {
1055
+ const response = await fetch(`${url}/metadata`, {
1056
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
1057
+ headers: { accept: "application/json" }
1058
+ });
1059
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1060
+ const metadata = await response.json();
1061
+ if (typeof metadata?.id !== "string" || !metadata.id) {
1062
+ throw new Error("\u54CD\u5E94\u7F3A\u5C11\u6E38\u620F id");
1063
+ }
1064
+ result = {
1065
+ reachable: true,
1066
+ metadataId: metadata.id,
1067
+ minPlayers: typeof metadata.minPlayers === "number" ? metadata.minPlayers : void 0,
1068
+ maxPlayers: typeof metadata.maxPlayers === "number" ? metadata.maxPlayers : void 0
1069
+ };
1070
+ } catch (error) {
1071
+ result = { reachable: false, error: errorMessage(error) };
1072
+ }
1073
+ this.probeCache.set(url, { at: Date.now(), result });
1074
+ return result;
1075
+ }
1076
+ async start(game2, options = {}) {
1077
+ const configResult = await this.getConfig(game2.dir);
1078
+ if (!configResult.config) {
1079
+ throw new WorkerManagerError(400, configResult.error ?? "\u65E0\u6CD5\u8BFB\u53D6\u6E38\u620F\u7684 dev \u914D\u7F6E");
1080
+ }
1081
+ const config = configResult.config;
1082
+ const existing = await this.status(game2.id, game2.dir, true);
1083
+ if (existing.state === "running" || existing.state === "external") {
1084
+ return existing;
1085
+ }
1086
+ const nodeModules = path7.join(game2.dir, "node_modules");
1087
+ if (!await pathExists(nodeModules)) {
1088
+ throw new WorkerManagerError(
1089
+ 400,
1090
+ `\u6E38\u620F\u4F9D\u8D56\u672A\u5B89\u88C5\uFF1A${nodeModules} \u4E0D\u5B58\u5728\u3002
1091
+ \u8BF7\u5148\u8FD0\u884C: npgame-dev game init ${game2.id}`
1092
+ );
1093
+ }
1094
+ const record = await this.getRecord(game2.id);
1095
+ if (record && isProcessAlive(record.pid)) {
1096
+ throw new WorkerManagerError(
1097
+ 409,
1098
+ `Worker \u8FDB\u7A0B ${record.pid} \u5DF2\u5B58\u5728\u4F46\u5C1A\u672A\u5C31\u7EEA\u3002
1099
+ \u67E5\u770B\u65E5\u5FD7: npgame-dev game logs ${game2.id}
1100
+ \u9700\u8981\u91CD\u542F\u65F6\u5148\u8FD0\u884C: npgame-dev game stop ${game2.id}`
1101
+ );
1102
+ }
1103
+ const port = await this.choosePort(options.port ?? config.basePort, options.port !== void 0);
1104
+ const url = workerUrl(port);
1105
+ const logFile = workerLogFile(this.root, game2.id);
1106
+ const args = workerArgs(config, port);
1107
+ const command = [config.packageManager, ...args].join(" ");
1108
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1109
+ await mkdir2(path7.dirname(logFile), { recursive: true });
1110
+ await appendFile(
1111
+ logFile,
1112
+ `
1113
+ ===== ${startedAt} npgame-dev game start ${game2.id} =====
1114
+ $ ${command} (cwd: ${game2.dir})
1115
+ `,
1116
+ "utf8"
1117
+ );
1118
+ const handle = await open2(logFile, "a");
1119
+ let child;
1120
+ try {
1121
+ child = spawn(config.packageManager, args, {
1122
+ cwd: game2.dir,
1123
+ env: {
1124
+ ...process.env,
1125
+ PORT: String(port),
1126
+ FORCE_COLOR: "1"
1127
+ },
1128
+ detached: true,
1129
+ stdio: ["ignore", handle.fd, handle.fd],
1130
+ shell: process.platform === "win32",
1131
+ windowsHide: true
1132
+ });
1133
+ } catch (error) {
1134
+ await handle.close();
1135
+ throw new WorkerManagerError(502, `\u65E0\u6CD5\u542F\u52A8 ${config.packageManager}: ${errorMessage(error)}`);
1136
+ }
1137
+ await handle.close();
1138
+ if (!child.pid) {
1139
+ throw new WorkerManagerError(
1140
+ 502,
1141
+ `\u65E0\u6CD5\u542F\u52A8 ${config.packageManager}\uFF08\u627E\u4E0D\u5230\u547D\u4EE4\u6216\u8FDB\u7A0B\u521B\u5EFA\u5931\u8D25\uFF09\u3002
1142
+ \u65E5\u5FD7\uFF1A${logFile}`
1143
+ );
1144
+ }
1145
+ const pid = child.pid;
1146
+ const recordValue = {
1147
+ version: 1,
1148
+ gameId: game2.id,
1149
+ dir: game2.dir,
1150
+ pid,
1151
+ port,
1152
+ url,
1153
+ logFile,
1154
+ startedAt,
1155
+ command
1156
+ };
1157
+ await writeJsonAtomic2(workerRecordFile(this.root, game2.id), recordValue);
1158
+ this.records.set(game2.id, recordValue);
1159
+ this.children.set(game2.id, child);
1160
+ this.probeCache.delete(url);
1161
+ child.once("exit", (code, signal) => {
1162
+ this.children.delete(game2.id);
1163
+ this.probeCache.delete(url);
1164
+ const current = this.records.get(game2.id);
1165
+ if (current?.pid !== pid) return;
1166
+ this.records.delete(game2.id);
1167
+ void rm2(workerRecordFile(this.root, game2.id), { force: true });
1168
+ this.logger.info(
1169
+ `worker ${game2.id} exited (pid ${pid}, code ${code ?? "-"}, signal ${signal ?? "-"})`
1170
+ );
1171
+ });
1172
+ child.once("error", (error) => {
1173
+ this.logger.error(`worker ${game2.id} spawn error: ${error.message}`);
1174
+ });
1175
+ this.logger.info(`worker ${game2.id} starting: ${command} (pid ${pid}, ${url})`);
1176
+ const deadline = Date.now() + START_TIMEOUT_MS;
1177
+ while (Date.now() < deadline) {
1178
+ if (!isProcessAlive(pid)) {
1179
+ const tail2 = await tailFile(logFile, 40);
1180
+ await this.removeRecord(game2.id);
1181
+ throw new WorkerManagerError(
1182
+ 502,
1183
+ `Worker \u8FDB\u7A0B\u63D0\u524D\u9000\u51FA\u3002
1184
+ ${tail2.trim() || `\u65E5\u5FD7\uFF1A${logFile}`}`
1185
+ );
1186
+ }
1187
+ const probe = await this.probe(url, true);
1188
+ if (probe.reachable && (!probe.metadataId || probe.metadataId === game2.id)) {
1189
+ this.logger.info(`worker ${game2.id} ready at ${url}`);
1190
+ return this.status(game2.id, game2.dir, true);
1191
+ }
1192
+ await sleep(250);
1193
+ }
1194
+ const tail = await tailFile(logFile, 40);
1195
+ await this.stop(game2.id, game2.dir, true).catch(() => void 0);
1196
+ throw new WorkerManagerError(
1197
+ 504,
1198
+ `Worker \u542F\u52A8\u8D85\u65F6\uFF08${START_TIMEOUT_MS / 1e3}s\uFF09\u3002
1199
+ ${tail.trim() || `\u65E5\u5FD7\uFF1A${logFile}`}`
1200
+ );
1201
+ }
1202
+ async stop(gameId, dir, force = false) {
1203
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
1204
+ const record = await this.getRecord(gameId);
1205
+ if (!record) {
1206
+ const status = await this.status(gameId, dir, true);
1207
+ if (status.state === "external" || status.reachable) {
1208
+ throw new WorkerManagerError(
1209
+ 409,
1210
+ `Worker \u4E0D\u662F\u7531 host \u542F\u52A8\u7684\uFF0Chost \u4E0D\u4F1A\u505C\u6B62\u5B83\u3002
1211
+ \u8BF7\u5728\u542F\u52A8\u5B83\u7684\u7EC8\u7AEF\u6309 Ctrl+C\uFF1B--force \u53EA\u80FD\u8DF3\u8FC7\u201C\u623F\u95F4\u6B63\u5728\u8FD0\u884C\u201D\u7684\u68C0\u67E5\u3002`
1212
+ );
1213
+ }
1214
+ return {
1215
+ state: "stopped",
1216
+ reachable: false,
1217
+ owned: false,
1218
+ logFile: status.logFile,
1219
+ checkedAt
1220
+ };
1221
+ }
1222
+ if (!isProcessAlive(record.pid)) {
1223
+ await this.removeRecord(gameId);
1224
+ return {
1225
+ state: "stopped",
1226
+ reachable: false,
1227
+ owned: false,
1228
+ logFile: record.logFile,
1229
+ startedAt: record.startedAt,
1230
+ checkedAt
1231
+ };
1232
+ }
1233
+ this.logger.info(`worker ${gameId} stopping (pid ${record.pid})`);
1234
+ killProcessTree(record.pid, "SIGTERM");
1235
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
1236
+ while (Date.now() < deadline && isProcessAlive(record.pid)) {
1237
+ await sleep(100);
1238
+ }
1239
+ if (isProcessAlive(record.pid)) {
1240
+ killProcessTree(record.pid, "SIGKILL");
1241
+ await sleep(200);
1242
+ }
1243
+ await this.removeRecord(gameId);
1244
+ this.probeCache.delete(record.url);
1245
+ if (!force) this.logger.info(`worker ${gameId} stopped`);
1246
+ return {
1247
+ state: "stopped",
1248
+ reachable: false,
1249
+ owned: false,
1250
+ logFile: record.logFile,
1251
+ startedAt: record.startedAt,
1252
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
1253
+ };
1254
+ }
1255
+ async logs(gameId, lines) {
1256
+ const record = await this.getRecord(gameId);
1257
+ const logFile = record?.logFile ?? workerLogFile(this.root, gameId);
1258
+ return { logFile, text: await tailFile(logFile, lines) };
1259
+ }
1260
+ async shutdown() {
1261
+ const records = [...this.records.values()];
1262
+ if (records.length === 0) return;
1263
+ this.logger.info(`stopping ${records.length} host-owned worker(s)`);
1264
+ for (const record of records) {
1265
+ if (isProcessAlive(record.pid)) {
1266
+ killProcessTree(record.pid, "SIGTERM");
1267
+ }
1268
+ }
1269
+ await sleep(400);
1270
+ for (const record of records) {
1271
+ if (isProcessAlive(record.pid)) {
1272
+ killProcessTree(record.pid, "SIGKILL");
1273
+ }
1274
+ await this.removeRecord(record.gameId);
1275
+ }
1276
+ this.children.clear();
1277
+ }
1278
+ async choosePort(basePort, explicit) {
1279
+ if (await isPortFree(basePort)) return basePort;
1280
+ if (explicit) {
1281
+ throw new WorkerManagerError(409, `\u7AEF\u53E3 ${basePort} \u5DF2\u88AB\u5360\u7528`);
1282
+ }
1283
+ for (let offset = 1; offset <= PORT_SEARCH_LIMIT; offset += 1) {
1284
+ const candidate = basePort + offset;
1285
+ if (candidate > 65535) break;
1286
+ if (await isPortFree(candidate)) {
1287
+ this.logger.info(`worker port ${basePort} busy; using ${candidate}`);
1288
+ return candidate;
1289
+ }
1290
+ }
1291
+ throw new WorkerManagerError(
1292
+ 409,
1293
+ `\u7AEF\u53E3 ${basePort}-${basePort + PORT_SEARCH_LIMIT} \u5747\u88AB\u5360\u7528\uFF1B\u7528 --worker-port \u6307\u5B9A\u7AEF\u53E3\u3002`
1294
+ );
1295
+ }
1296
+ async getConfig(dir) {
1297
+ const cached = this.configCache.get(dir);
1298
+ if (cached && Date.now() - cached.at < CONFIG_CACHE_MS) return cached.result;
1299
+ const result = await this.readConfig(dir);
1300
+ this.configCache.set(dir, { at: Date.now(), result });
1301
+ return result;
1302
+ }
1303
+ async readConfig(dir) {
1304
+ const packageJsonPath = path7.join(dir, "package.json");
1305
+ let pkg;
1306
+ try {
1307
+ const parsed = JSON.parse(await readFile4(packageJsonPath, "utf8"));
1308
+ if (!isRecord2(parsed)) throw new Error("package.json \u5FC5\u987B\u662F JSON \u5BF9\u8C61");
1309
+ pkg = parsed;
1310
+ } catch (error) {
1311
+ return {
1312
+ error: `\u65E0\u6CD5\u8BFB\u53D6 ${packageJsonPath}: ${errorMessage(error)}`
1313
+ };
1314
+ }
1315
+ const scripts = isRecord2(pkg.scripts) ? pkg.scripts : {};
1316
+ const script = typeof scripts.dev === "string" ? scripts.dev : "";
1317
+ if (!script) {
1318
+ return {
1319
+ error: `${packageJsonPath} \u7F3A\u5C11 "scripts.dev"\uFF0C\u65E0\u6CD5\u542F\u52A8 Worker`
1320
+ };
1321
+ }
1322
+ const wrangler = /\bwrangler\b/.test(script);
1323
+ const wranglerPort = await readWranglerPort(dir);
1324
+ const scriptPort = parseScriptPort(script);
1325
+ return {
1326
+ config: {
1327
+ packageManager: await detectPackageManager(dir, pkg),
1328
+ script,
1329
+ wrangler,
1330
+ basePort: wranglerPort ?? scriptPort ?? DEFAULT_WORKER_PORT
1331
+ }
1332
+ };
1333
+ }
1334
+ async getRecord(gameId) {
1335
+ const cached = this.records.get(gameId);
1336
+ if (cached) {
1337
+ if (isProcessAlive(cached.pid)) return cached;
1338
+ await this.removeRecord(gameId);
1339
+ return null;
1340
+ }
1341
+ const file = workerRecordFile(this.root, gameId);
1342
+ let raw;
1343
+ try {
1344
+ raw = await readFile4(file, "utf8");
1345
+ } catch (error) {
1346
+ if (error.code === "ENOENT") return null;
1347
+ this.logger.error(`\u65E0\u6CD5\u8BFB\u53D6 Worker \u8BB0\u5F55 ${file}: ${errorMessage(error)}`);
1348
+ return null;
1349
+ }
1350
+ let parsed;
1351
+ try {
1352
+ parsed = JSON.parse(raw);
1353
+ } catch {
1354
+ await rm2(file, { force: true });
1355
+ return null;
1356
+ }
1357
+ const record = readRecord(parsed);
1358
+ if (!record || record.gameId !== gameId) {
1359
+ await rm2(file, { force: true });
1360
+ return null;
1361
+ }
1362
+ if (!isProcessAlive(record.pid)) {
1363
+ await rm2(file, { force: true });
1364
+ return null;
1365
+ }
1366
+ this.records.set(gameId, record);
1367
+ return record;
1368
+ }
1369
+ async removeRecord(gameId) {
1370
+ this.records.delete(gameId);
1371
+ this.children.delete(gameId);
1372
+ await rm2(workerRecordFile(this.root, gameId), { force: true });
1373
+ }
1374
+ async init() {
1375
+ await mkdir2(workersDir(this.root), { recursive: true });
1376
+ let entries;
1377
+ try {
1378
+ entries = await readdir2(workersDir(this.root));
1379
+ } catch {
1380
+ return;
1381
+ }
1382
+ for (const entry of entries) {
1383
+ if (!entry.endsWith(".json")) continue;
1384
+ const gameId = entry.slice(0, -".json".length);
1385
+ if (!isSafeGameId(gameId)) continue;
1386
+ const record = await this.getRecord(gameId);
1387
+ if (record) {
1388
+ this.logger.info(
1389
+ `adopted worker ${record.gameId} (pid ${record.pid}, ${record.url})`
1390
+ );
1391
+ }
1392
+ }
1393
+ }
1394
+ };
1395
+
1396
+ // src/host/service.ts
1397
+ var HttpError = class extends Error {
1398
+ constructor(status, message) {
1399
+ super(message);
1400
+ this.status = status;
1401
+ this.name = "HttpError";
1402
+ }
1403
+ };
1404
+ function errorMessage2(error) {
1405
+ return error instanceof Error ? error.message : String(error);
1406
+ }
1407
+ function normalizeWorkerUrl(input) {
1408
+ let url;
1409
+ try {
1410
+ url = new URL(input);
1411
+ } catch {
1412
+ throw new HttpError(400, `\u65E0\u6548\u7684 Worker URL: ${input}`);
1413
+ }
1414
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1415
+ throw new HttpError(400, "Worker URL \u5FC5\u987B\u662F http(s)");
1416
+ }
1417
+ return input.replace(/\/+$/, "");
1418
+ }
1419
+ function sameWorkerUrl(left, right) {
1420
+ if (!right) return false;
1421
+ return left.replace(/\/+$/, "") === right.replace(/\/+$/, "");
1422
+ }
1423
+ function roomStateOf(state, roomId) {
1424
+ if (!state) return void 0;
1425
+ return {
1426
+ roomId: state.roomId || roomId,
1427
+ phase: state.phase ?? "lobby",
1428
+ gameId: state.gameConfig?.gameId,
1429
+ roleIds: state.gameConfig?.roleIds ?? [],
1430
+ playerCount: state.players ? Object.keys(state.players).length : 0,
1431
+ stateIndex: state.stateIndex
1432
+ };
1433
+ }
1434
+ function capabilitiesFor(connected, worker, phase) {
1435
+ const roomActive = phase === "playing" || phase === "paused";
1436
+ return {
1437
+ startWorker: worker.state === "stopped" || worker.state === "error",
1438
+ stopWorker: worker.owned && !roomActive,
1439
+ logWorker: true,
1440
+ connect: !connected && worker.reachable,
1441
+ disconnect: connected,
1442
+ startRoom: connected && worker.reachable && (phase === "lobby" || phase === "finished"),
1443
+ stopRoom: connected && roomActive,
1444
+ openConsole: connected
1445
+ };
1446
+ }
1447
+ var WorkspaceService = class _WorkspaceService {
1448
+ constructor(options, config, workers) {
1449
+ this.options = options;
1450
+ this.workers = workers;
1451
+ this.config = config;
1452
+ this.client = new EngineAdminClient({
1453
+ baseUrl: `http://127.0.0.1:${options.enginePort}`,
1454
+ token: ADMIN_SECRET
1455
+ });
1456
+ }
1457
+ config;
1458
+ client;
1459
+ static async create(options) {
1460
+ const config = await readWorkspaceConfig(options.root);
1461
+ const workers = await WorkerManager.create(options.root, options.logger);
1462
+ return new _WorkspaceService(options, config, workers);
1463
+ }
1464
+ get root() {
1465
+ return this.options.root;
1466
+ }
1467
+ meta() {
1468
+ return {
1469
+ root: this.options.root,
1470
+ pid: process.pid,
1471
+ port: this.options.port,
1472
+ enginePort: this.options.enginePort,
1473
+ url: `http://127.0.0.1:${this.options.port}`,
1474
+ startedAt: this.options.startedAt,
1475
+ version: DEV_VERSION
1476
+ };
1477
+ }
1478
+ isRoomAllowed(roomId) {
1479
+ return this.config.connections.some((connection) => connection.roomId === roomId);
1480
+ }
1481
+ async shutdown() {
1482
+ await this.workers.shutdown();
1483
+ }
1484
+ async listGames() {
1485
+ const scan = await scanGames(this.options.root);
1486
+ const byGame = new Map(this.config.connections.map((connection) => [connection.gameId, connection]));
1487
+ const games = await Promise.all(scan.games.map(
1488
+ (game2) => this.describe(game2.id, game2.name, game2.dir, byGame.get(game2.id))
1489
+ ));
1490
+ const scanned = new Set(scan.games.map((game2) => game2.id));
1491
+ for (const connection of this.config.connections) {
1492
+ if (scanned.has(connection.gameId)) continue;
1493
+ games.push(await this.describe(
1494
+ connection.gameId,
1495
+ connection.name,
1496
+ connection.gameDir,
1497
+ connection,
1498
+ "\u6E38\u620F\u76EE\u5F55\u4E0D\u5728\u626B\u63CF\u8303\u56F4"
1499
+ ));
1500
+ }
1501
+ return {
1502
+ root: this.options.root,
1503
+ games: games.sort((a, b) => {
1504
+ if (a.worker.reachable !== b.worker.reachable) return a.worker.reachable ? -1 : 1;
1505
+ if (a.connected !== b.connected) return a.connected ? -1 : 1;
1506
+ return a.id.localeCompare(b.id);
1507
+ }),
1508
+ warnings: scan.warnings
1509
+ };
1510
+ }
1511
+ async listRooms() {
1512
+ const rooms = await Promise.all(this.config.connections.map(async (connection) => {
1513
+ const [worker, roomResult] = await Promise.all([
1514
+ this.workers.status(connection.gameId, connection.gameDir),
1515
+ this.client.getState(connection.roomId)
1516
+ ]);
1517
+ const state = roomResult.ok ? roomResult.data : void 0;
1518
+ const summary = roomStateOf(state, connection.roomId);
1519
+ return {
1520
+ roomId: connection.roomId,
1521
+ gameId: connection.gameId,
1522
+ name: connection.name,
1523
+ gameDir: connection.gameDir,
1524
+ phase: summary?.phase,
1525
+ roleIds: summary?.roleIds ?? connection.roleIds,
1526
+ playerCount: summary?.playerCount,
1527
+ stateIndex: summary?.stateIndex,
1528
+ worker,
1529
+ error: roomResult.ok ? void 0 : roomResult.status === 404 ? `\u623F\u95F4 "${connection.roomId}" \u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0 connect` : `\u8BFB\u53D6\u623F\u95F4\u72B6\u6001\u5931\u8D25: ${roomResult.error}`
1530
+ };
1531
+ }));
1532
+ return {
1533
+ root: this.options.root,
1534
+ rooms: rooms.sort((a, b) => a.roomId.localeCompare(b.roomId)),
1535
+ warnings: []
1536
+ };
1537
+ }
1538
+ async connect(selector, input) {
1539
+ const scan = await scanGames(this.options.root);
1540
+ let game2;
1541
+ try {
1542
+ game2 = resolveGame(scan.games, selector);
1543
+ } catch (error) {
1544
+ throw new HttpError(404, errorMessage2(error));
1545
+ }
1546
+ const existing = this.config.connections.find((connection2) => connection2.gameId === game2.id);
1547
+ const roomId = input.roomId?.trim() || existing?.roomId || game2.id;
1548
+ if (!isSafeGameId(roomId)) {
1549
+ throw new HttpError(400, `\u975E\u6CD5\u623F\u95F4 id "${roomId}"\uFF08\u53EA\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001. _ -\uFF09`);
1550
+ }
1551
+ const duplicate = this.config.connections.find(
1552
+ (connection2) => connection2.roomId === roomId && connection2.gameId !== game2.id
1553
+ );
1554
+ if (duplicate) {
1555
+ throw new HttpError(409, `\u623F\u95F4 "${roomId}" \u5DF2\u88AB\u6E38\u620F "${duplicate.gameId}" \u4F7F\u7528`);
1556
+ }
1557
+ let workerUrl2;
1558
+ if (input.workerUrl?.trim()) {
1559
+ workerUrl2 = normalizeWorkerUrl(input.workerUrl.trim());
1560
+ } else {
1561
+ const worker = await this.workers.status(game2.id, game2.dir, true);
1562
+ workerUrl2 = normalizeWorkerUrl(
1563
+ worker.reachable && worker.url ? worker.url : existing?.workerUrl ?? DEFAULT_WORKER_URL
1564
+ );
1565
+ }
1566
+ const probe = await this.workers.probe(workerUrl2, true);
1567
+ if (!probe.reachable) {
1568
+ throw new HttpError(
1569
+ 502,
1570
+ `Worker \u4E0D\u53EF\u8FBE (${workerUrl2}): ${probe.error ?? "unknown error"}
1571
+ \u5148\u8FD0\u884C npgame-dev game start ${game2.id}\uFF0C\u6216\u7528 --worker-url \u6307\u5B9A\u5730\u5740\u3002`
1572
+ );
1573
+ }
1574
+ if (probe.metadataId && probe.metadataId !== game2.id) {
1575
+ throw new HttpError(
1576
+ 409,
1577
+ `Worker URL ${workerUrl2} \u4E0A\u7684\u6E38\u620F\u662F "${probe.metadataId}"\uFF0C\u4E0D\u662F "${game2.id}"\u3002`
1578
+ );
1579
+ }
1580
+ let state = await this.getRoomState(roomId);
1581
+ if (!state || !state.roomId) {
1582
+ const created = await this.client.createRoom(roomId, FIXED_OWNER_ID);
1583
+ if (!created.ok) {
1584
+ throw new HttpError(502, `\u521B\u5EFA\u623F\u95F4\u5931\u8D25: ${created.error}`);
1585
+ }
1586
+ state = await this.getRoomState(roomId);
1587
+ if (!state?.roomId) throw new HttpError(502, "\u521B\u5EFA\u623F\u95F4\u540E\u65E0\u6CD5\u8BFB\u53D6\u72B6\u6001");
1588
+ }
1589
+ if (state.phase !== "lobby") {
1590
+ const stopped = await this.client.stopGame(roomId);
1591
+ if (!stopped.ok) {
1592
+ throw new HttpError(409, `\u623F\u95F4\u6B63\u5728 ${state.phase}\uFF0C\u505C\u6B62\u5931\u8D25: ${stopped.error}`);
1593
+ }
1594
+ }
1595
+ const set = await this.client.setGame(roomId, {
1596
+ gameWorkerUrl: workerUrl2,
1597
+ selectedPlayerCount: input.players ?? existing?.players
1598
+ });
1599
+ if (!set.ok) {
1600
+ throw new HttpError(502, `\u8FDE\u63A5\u6E38\u620F\u5931\u8D25: ${set.error}`);
1601
+ }
1602
+ if (existing && existing.roomId !== roomId) {
1603
+ const oldState = await this.getRoomState(existing.roomId);
1604
+ if (oldState?.roomId && oldState.phase !== "lobby") {
1605
+ const stopped = await this.client.stopGame(existing.roomId);
1606
+ if (!stopped.ok) {
1607
+ throw new HttpError(409, `\u505C\u6B62\u65E7\u623F\u95F4 ${existing.roomId} \u5931\u8D25: ${stopped.error}`);
1608
+ }
1609
+ }
1610
+ }
1611
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1612
+ const connection = {
1613
+ roomId,
1614
+ gameId: game2.id,
1615
+ name: game2.name,
1616
+ gameDir: game2.dir,
1617
+ workerUrl: workerUrl2,
1618
+ players: input.players ?? existing?.players,
1619
+ roleIds: set.data.roleIds,
1620
+ createdAt: existing?.createdAt ?? now,
1621
+ updatedAt: now
1622
+ };
1623
+ this.config.connections = [
1624
+ ...this.config.connections.filter(
1625
+ (item) => item.gameId !== game2.id && item.roomId !== roomId
1626
+ ),
1627
+ connection
1628
+ ];
1629
+ await writeWorkspaceConfig(this.options.root, this.config);
1630
+ this.options.logger.info(
1631
+ `connect ${game2.id} -> room ${roomId} (worker ${workerUrl2}, roles ${connection.roleIds.join(", ") || "-"})`
1632
+ );
1633
+ return this.describe(game2.id, game2.name, game2.dir, connection);
1634
+ }
1635
+ async disconnect(selector) {
1636
+ const connection = findConnection(this.config, selector);
1637
+ if (!connection) {
1638
+ throw new HttpError(404, `\u6E38\u620F\u6216\u623F\u95F4 "${selector}" \u672A\u8FDE\u63A5`);
1639
+ }
1640
+ const state = await this.getRoomState(connection.roomId);
1641
+ if (state && state.phase !== "lobby") {
1642
+ const stopped = await this.client.stopGame(connection.roomId);
1643
+ if (!stopped.ok) {
1644
+ throw new HttpError(409, `\u505C\u6B62\u623F\u95F4\u5931\u8D25: ${stopped.error}`);
1645
+ }
1646
+ }
1647
+ this.config.connections = this.config.connections.filter(
1648
+ (item) => item.roomId !== connection.roomId
1649
+ );
1650
+ await writeWorkspaceConfig(this.options.root, this.config);
1651
+ this.options.logger.info(`disconnect ${connection.gameId} (room ${connection.roomId})`);
1652
+ }
1653
+ async startWorker(selector, port) {
1654
+ const game2 = await this.resolveScannedGame(selector);
1655
+ try {
1656
+ const worker = await this.workers.start(game2, { port });
1657
+ return { gameId: game2.id, worker };
1658
+ } catch (error) {
1659
+ throw this.workerHttpError(error);
1660
+ }
1661
+ }
1662
+ async stopWorker(selector, force = false) {
1663
+ const target = await this.resolveWorkerTarget(selector);
1664
+ if (!force && target.connection) {
1665
+ const state = await this.getRoomState(target.connection.roomId);
1666
+ if (state && (state.phase === "playing" || state.phase === "paused")) {
1667
+ throw new HttpError(
1668
+ 409,
1669
+ `\u623F\u95F4 "${target.connection.roomId}" \u6B63\u5728 ${state.phase}\uFF0C\u5148\u8FD0\u884C npgame-dev room stop ${target.connection.roomId}\uFF0C\u6216\u52A0 --force\u3002`
1670
+ );
1671
+ }
1672
+ }
1673
+ try {
1674
+ const worker = await this.workers.stop(target.id, target.dir, force);
1675
+ return { gameId: target.id, worker };
1676
+ } catch (error) {
1677
+ throw this.workerHttpError(error);
1678
+ }
1679
+ }
1680
+ async workerLogs(selector, lines) {
1681
+ const target = await this.resolveWorkerTarget(selector);
1682
+ const { logFile, text } = await this.workers.logs(target.id, lines);
1683
+ return { gameId: target.id, logFile, lines, text };
1684
+ }
1685
+ async startRoom(selector) {
1686
+ const connection = findConnection(this.config, selector);
1687
+ if (!connection) {
1688
+ throw new HttpError(404, `\u6E38\u620F\u6216\u623F\u95F4 "${selector}" \u672A\u8FDE\u63A5\uFF0C\u8BF7\u5148\u8FD0\u884C connect`);
1689
+ }
1690
+ let workerUrl2 = connection.workerUrl;
1691
+ let probe = await this.workers.probe(workerUrl2, true);
1692
+ if (!probe.reachable || probe.metadataId && probe.metadataId !== connection.gameId) {
1693
+ const worker = await this.workers.status(connection.gameId, connection.gameDir, true);
1694
+ if (worker.reachable && worker.url) {
1695
+ workerUrl2 = worker.url;
1696
+ probe = await this.workers.probe(workerUrl2, true);
1697
+ if (probe.reachable) {
1698
+ this.options.logger.info(
1699
+ `room start: worker moved ${connection.workerUrl} -> ${workerUrl2}`
1700
+ );
1701
+ connection.workerUrl = workerUrl2;
1702
+ }
1703
+ }
1704
+ }
1705
+ if (!probe.reachable) {
1706
+ throw new HttpError(
1707
+ 502,
1708
+ `Worker \u4E0D\u53EF\u8FBE (${workerUrl2}): ${probe.error ?? "unknown error"}
1709
+ \u5148\u8FD0\u884C npgame-dev game start ${connection.gameId}\u3002`
1710
+ );
1711
+ }
1712
+ if (probe.metadataId && probe.metadataId !== connection.gameId) {
1713
+ throw new HttpError(
1714
+ 409,
1715
+ `Worker URL ${workerUrl2} \u4E0A\u7684\u6E38\u620F\u662F "${probe.metadataId}"\uFF0C\u4E0D\u662F "${connection.gameId}"\u3002`
1716
+ );
1717
+ }
1718
+ let state = await this.getRoomState(connection.roomId);
1719
+ if (!state) {
1720
+ throw new HttpError(409, `\u623F\u95F4 "${connection.roomId}" \u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0 connect`);
1721
+ }
1722
+ if (state.phase !== "lobby") {
1723
+ const stopped = await this.client.stopGame(connection.roomId);
1724
+ if (!stopped.ok) {
1725
+ throw new HttpError(409, `\u505C\u6B62\u5F53\u524D\u6E38\u620F\u5931\u8D25: ${stopped.error}`);
1726
+ }
1727
+ state = await this.getRoomState(connection.roomId);
1728
+ }
1729
+ if (!state?.gameConfig?.roleIds?.length || !sameWorkerUrl(workerUrl2, state.gameConfig.gameWorkerUrl)) {
1730
+ const set = await this.client.setGame(connection.roomId, {
1731
+ gameWorkerUrl: workerUrl2,
1732
+ selectedPlayerCount: connection.players
1733
+ });
1734
+ if (!set.ok) throw new HttpError(502, `\u8BBE\u7F6E\u6E38\u620F\u5931\u8D25: ${set.error}`);
1735
+ state = await this.getRoomState(connection.roomId);
1736
+ }
1737
+ const roleIds = state?.gameConfig?.roleIds ?? connection.roleIds;
1738
+ if (roleIds.length === 0) {
1739
+ throw new HttpError(502, "\u6E38\u620F\u6CA1\u6709\u89D2\u8272\uFF0C\u65E0\u6CD5\u5206\u914D\u73A9\u5BB6");
1740
+ }
1741
+ for (const [index, roleId] of roleIds.entries()) {
1742
+ const player = await this.client.createPlayer(connection.roomId, {
1743
+ roleId,
1744
+ displayName: `dev${index + 1}`
1745
+ });
1746
+ if (!player.ok) throw new HttpError(502, `\u521B\u5EFA\u73A9\u5BB6\u5931\u8D25: ${player.error}`);
1747
+ const assigned = await this.client.assignRole(
1748
+ connection.roomId,
1749
+ roleId,
1750
+ player.data.userId
1751
+ );
1752
+ if (!assigned.ok) throw new HttpError(502, `\u5206\u914D\u89D2\u8272\u5931\u8D25: ${assigned.error}`);
1753
+ }
1754
+ const started = await this.client.startGame(connection.roomId);
1755
+ if (!started.ok) throw new HttpError(502, `\u5F00\u59CB\u6E38\u620F\u5931\u8D25: ${started.error}`);
1756
+ connection.roleIds = roleIds;
1757
+ connection.workerUrl = workerUrl2;
1758
+ connection.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1759
+ await writeWorkspaceConfig(this.options.root, this.config);
1760
+ this.options.logger.info(
1761
+ `room start ${connection.roomId} (${connection.gameId}, roles ${roleIds.join(", ")})`
1762
+ );
1763
+ const finalState = await this.getRoomState(connection.roomId);
1764
+ return {
1765
+ roomId: connection.roomId,
1766
+ phase: finalState?.phase ?? "playing",
1767
+ roleIds
1768
+ };
1769
+ }
1770
+ async stopRoom(selector) {
1771
+ const connection = findConnection(this.config, selector);
1772
+ if (!connection) {
1773
+ throw new HttpError(404, `\u6E38\u620F\u6216\u623F\u95F4 "${selector}" \u672A\u8FDE\u63A5`);
1774
+ }
1775
+ const state = await this.getRoomState(connection.roomId);
1776
+ if (!state) {
1777
+ throw new HttpError(409, `\u623F\u95F4 "${connection.roomId}" \u4E0D\u5B58\u5728`);
1778
+ }
1779
+ if (state.phase === "lobby") {
1780
+ return { roomId: connection.roomId, phase: "lobby", roleIds: connection.roleIds };
1781
+ }
1782
+ const stopped = await this.client.stopGame(connection.roomId);
1783
+ if (!stopped.ok) throw new HttpError(409, `\u505C\u6B62\u6E38\u620F\u5931\u8D25: ${stopped.error}`);
1784
+ this.options.logger.info(`room stop ${connection.roomId} (${connection.gameId})`);
1785
+ return {
1786
+ roomId: connection.roomId,
1787
+ phase: "lobby",
1788
+ roleIds: connection.roleIds
1789
+ };
1790
+ }
1791
+ async describe(id, name, dir, connection, orphanError) {
1792
+ const worker = await this.workers.status(id, dir);
1793
+ let room2;
1794
+ let roomError = orphanError;
1795
+ if (connection) {
1796
+ const roomResult = await this.client.getState(connection.roomId);
1797
+ if (roomResult.ok) {
1798
+ room2 = roomStateOf(roomResult.data, connection.roomId);
1799
+ } else {
1800
+ roomError = roomResult.status === 404 ? `\u623F\u95F4 "${connection.roomId}" \u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0 connect` : `\u8BFB\u53D6\u623F\u95F4\u72B6\u6001\u5931\u8D25: ${roomResult.error}`;
1801
+ }
1802
+ }
1803
+ return {
1804
+ id,
1805
+ name,
1806
+ dir,
1807
+ worker,
1808
+ connected: Boolean(connection),
1809
+ connection,
1810
+ room: room2,
1811
+ roomError,
1812
+ capabilities: capabilitiesFor(Boolean(connection), worker, room2?.phase)
1813
+ };
1814
+ }
1815
+ async resolveScannedGame(selector) {
1816
+ const scan = await scanGames(this.options.root);
1817
+ try {
1818
+ return resolveGame(scan.games, selector);
1819
+ } catch (error) {
1820
+ throw new HttpError(404, errorMessage2(error));
1821
+ }
1822
+ }
1823
+ async resolveWorkerTarget(selector) {
1824
+ const scan = await scanGames(this.options.root);
1825
+ try {
1826
+ const game2 = resolveGame(scan.games, selector);
1827
+ return {
1828
+ id: game2.id,
1829
+ name: game2.name,
1830
+ dir: game2.dir,
1831
+ connection: this.config.connections.find((item) => item.gameId === game2.id)
1832
+ };
1833
+ } catch {
1834
+ const connection = findConnection(this.config, selector);
1835
+ if (connection) {
1836
+ return {
1837
+ id: connection.gameId,
1838
+ name: connection.name,
1839
+ dir: connection.gameDir,
1840
+ connection
1841
+ };
1842
+ }
1843
+ throw new HttpError(404, `\u627E\u4E0D\u5230\u6E38\u620F\u6216\u623F\u95F4 "${selector}"`);
1844
+ }
1845
+ }
1846
+ workerHttpError(error) {
1847
+ if (error instanceof HttpError) return error;
1848
+ if (error instanceof WorkerManagerError) {
1849
+ return new HttpError(error.status, error.message);
1850
+ }
1851
+ return new HttpError(500, errorMessage2(error));
1852
+ }
1853
+ async getRoomState(roomId) {
1854
+ const result = await this.client.getState(roomId);
1855
+ if (result.ok) return result.data;
1856
+ if (result.status === 404) return null;
1857
+ throw new HttpError(502, `\u8BFB\u53D6\u623F\u95F4\u72B6\u6001\u5931\u8D25: ${result.error}`);
1858
+ }
1859
+ };
11
1860
 
12
- // src/constants.ts
13
- var ADMIN_SECRET = "nexus-dev-secret";
14
- var JWT_SECRET = "nexus-dev-jwt-secret";
15
- var FIXED_ROOM_ID = "dev-room";
16
- var FIXED_OWNER_ID = "dev-owner";
17
- var DEFAULT_PORT = 56512;
1861
+ // src/host/api.ts
1862
+ var MAX_BODY_BYTES = 64 * 1024;
1863
+ async function readJsonBody(req) {
1864
+ const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
1865
+ if (!contentType.startsWith("application/json")) {
1866
+ throw new HttpError(415, "Content-Type \u5FC5\u987B\u662F application/json");
1867
+ }
1868
+ const chunks = [];
1869
+ let size = 0;
1870
+ for await (const chunk of req) {
1871
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1872
+ size += buffer.length;
1873
+ if (size > MAX_BODY_BYTES) throw new HttpError(413, "\u8BF7\u6C42\u4F53\u8FC7\u5927");
1874
+ chunks.push(buffer);
1875
+ }
1876
+ if (size === 0) return {};
1877
+ let parsed;
1878
+ try {
1879
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1880
+ } catch {
1881
+ throw new HttpError(400, "\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON");
1882
+ }
1883
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1884
+ throw new HttpError(400, "\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
1885
+ }
1886
+ return parsed;
1887
+ }
1888
+ function stringField2(body, field) {
1889
+ const value = body[field];
1890
+ if (value === void 0) return void 0;
1891
+ if (typeof value !== "string" || !value.trim()) {
1892
+ throw new HttpError(400, `\u5B57\u6BB5 "${field}" \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
1893
+ }
1894
+ return value.trim();
1895
+ }
1896
+ function positiveIntField(body, field) {
1897
+ const value = body[field];
1898
+ if (value === void 0) return void 0;
1899
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
1900
+ throw new HttpError(400, `\u5B57\u6BB5 "${field}" \u5FC5\u987B\u662F\u6B63\u6574\u6570`);
1901
+ }
1902
+ return value;
1903
+ }
1904
+ function booleanField(body, field) {
1905
+ const value = body[field];
1906
+ if (value === void 0) return void 0;
1907
+ if (typeof value !== "boolean") {
1908
+ throw new HttpError(400, `\u5B57\u6BB5 "${field}" \u5FC5\u987B\u662F\u5E03\u5C14\u503C`);
1909
+ }
1910
+ return value;
1911
+ }
1912
+ function queryInt(url, field, fallback, max) {
1913
+ const raw = url.searchParams.get(field);
1914
+ if (raw === null) return fallback;
1915
+ const value = Number.parseInt(raw, 10);
1916
+ if (!Number.isInteger(value) || value <= 0 || value > max) {
1917
+ throw new HttpError(400, `\u67E5\u8BE2\u53C2\u6570 "${field}" \u5FC5\u987B\u662F 1-${max} \u7684\u6574\u6570`);
1918
+ }
1919
+ return value;
1920
+ }
1921
+ function decodeSegment(value) {
1922
+ try {
1923
+ return decodeURIComponent(value);
1924
+ } catch {
1925
+ throw new HttpError(400, "\u975E\u6CD5\u8DEF\u5F84\u53C2\u6570");
1926
+ }
1927
+ }
1928
+ function isLoopbackOrigin(origin) {
1929
+ try {
1930
+ const url = new URL(origin);
1931
+ return url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname);
1932
+ } catch {
1933
+ return false;
1934
+ }
1935
+ }
1936
+ function originAllowed(req) {
1937
+ const origin = req.headers.origin;
1938
+ if (!origin) return true;
1939
+ return isLoopbackOrigin(origin);
1940
+ }
1941
+ function createWorkspaceApiHandler(service) {
1942
+ return async (req, res, url) => {
1943
+ if (url.pathname !== "/api/workspace" && !url.pathname.startsWith("/api/workspace/")) {
1944
+ return false;
1945
+ }
1946
+ try {
1947
+ if (!originAllowed(req)) {
1948
+ throw new HttpError(403, "Origin not allowed");
1949
+ }
1950
+ const method = req.method ?? "GET";
1951
+ if (url.pathname === "/api/workspace/meta") {
1952
+ if (method !== "GET") throw new HttpError(405, "Method not allowed");
1953
+ sendJson(res, 200, service.meta());
1954
+ return true;
1955
+ }
1956
+ if (url.pathname === "/api/workspace/games") {
1957
+ if (method !== "GET") throw new HttpError(405, "Method not allowed");
1958
+ sendJson(res, 200, await service.listGames());
1959
+ return true;
1960
+ }
1961
+ if (url.pathname === "/api/workspace/rooms") {
1962
+ if (method !== "GET") throw new HttpError(405, "Method not allowed");
1963
+ sendJson(res, 200, await service.listRooms());
1964
+ return true;
1965
+ }
1966
+ let match = /^\/api\/workspace\/games\/([^/]+)\/worker\/(start|stop|logs)$/.exec(url.pathname);
1967
+ if (match) {
1968
+ const gameId = decodeSegment(match[1]);
1969
+ const action = match[2];
1970
+ if (action === "logs") {
1971
+ if (method !== "GET") throw new HttpError(405, "Method not allowed");
1972
+ const lines = queryInt(url, "lines", 200, 5e3);
1973
+ sendJson(res, 200, await service.workerLogs(gameId, lines));
1974
+ return true;
1975
+ }
1976
+ if (method !== "POST") throw new HttpError(405, "Method not allowed");
1977
+ const body = await readJsonBody(req);
1978
+ if (action === "start") {
1979
+ sendJson(res, 200, await service.startWorker(
1980
+ gameId,
1981
+ positiveIntField(body, "port")
1982
+ ));
1983
+ return true;
1984
+ }
1985
+ sendJson(res, 200, await service.stopWorker(
1986
+ gameId,
1987
+ booleanField(body, "force") ?? false
1988
+ ));
1989
+ return true;
1990
+ }
1991
+ match = /^\/api\/workspace\/games\/([^/]+)$/.exec(url.pathname);
1992
+ if (match) {
1993
+ if (method !== "GET") throw new HttpError(405, "Method not allowed");
1994
+ const gameId = decodeSegment(match[1]);
1995
+ const list = await service.listGames();
1996
+ const game2 = list.games.find((item) => item.id === gameId);
1997
+ if (!game2) throw new HttpError(404, `\u627E\u4E0D\u5230\u6E38\u620F "${gameId}"`);
1998
+ sendJson(res, 200, game2);
1999
+ return true;
2000
+ }
2001
+ match = /^\/api\/workspace\/games\/([^/]+)\/connect$/.exec(url.pathname);
2002
+ if (match) {
2003
+ if (method !== "POST") throw new HttpError(405, "Method not allowed");
2004
+ const gameId = decodeSegment(match[1]);
2005
+ const body = await readJsonBody(req);
2006
+ const game2 = await service.connect(gameId, {
2007
+ workerUrl: stringField2(body, "workerUrl"),
2008
+ players: positiveIntField(body, "players"),
2009
+ roomId: stringField2(body, "roomId")
2010
+ });
2011
+ sendJson(res, 200, game2);
2012
+ return true;
2013
+ }
2014
+ match = /^\/api\/workspace\/games\/([^/]+)\/disconnect$/.exec(url.pathname);
2015
+ if (match) {
2016
+ if (method !== "POST") throw new HttpError(405, "Method not allowed");
2017
+ const gameId = decodeSegment(match[1]);
2018
+ await service.disconnect(gameId);
2019
+ sendJson(res, 200, { ok: true });
2020
+ return true;
2021
+ }
2022
+ match = /^\/api\/workspace\/rooms\/([^/]+)\/(start|stop)$/.exec(url.pathname);
2023
+ if (match) {
2024
+ if (method !== "POST") throw new HttpError(405, "Method not allowed");
2025
+ const roomId = decodeSegment(match[1]);
2026
+ const action = match[2];
2027
+ const result = action === "start" ? await service.startRoom(roomId) : await service.stopRoom(roomId);
2028
+ sendJson(res, 200, result);
2029
+ return true;
2030
+ }
2031
+ throw new HttpError(404, "Not found");
2032
+ } catch (error) {
2033
+ if (res.headersSent) {
2034
+ res.destroy();
2035
+ return true;
2036
+ }
2037
+ const status = error instanceof HttpError ? error.status : 500;
2038
+ const message = error instanceof Error ? error.message : "Internal error";
2039
+ sendJson(res, status, { error: message });
2040
+ return true;
2041
+ }
2042
+ };
2043
+ }
18
2044
 
19
- // src/commands/start.ts
20
- var __dirname = path.dirname(fileURLToPath(import.meta.url));
21
- async function startCommand(options) {
22
- const port = parseInt(options.port || String(DEFAULT_PORT), 10);
23
- const persistDir = path.resolve(process.cwd(), ".nexus");
24
- console.log("\u{1F680} \u542F\u52A8\u661F\u67A2\u6C99\u76D2\u5F15\u64CE...");
25
- const scriptPath = path.join(__dirname, "engine-worker.js");
26
- const contents = await readFile(scriptPath, "utf8");
2045
+ // src/host/server.ts
2046
+ async function startHostServer(options) {
2047
+ return startConsoleServer({
2048
+ publicPort: options.port,
2049
+ enginePort: options.enginePort,
2050
+ consoleDir: options.consoleDir,
2051
+ rootPage: "workspace.html",
2052
+ roomPage: "index.html",
2053
+ isRoomAllowed: (roomId) => options.service.isRoomAllowed(roomId),
2054
+ handleApi: createWorkspaceApiHandler(options.service),
2055
+ appName: "Workspace Host"
2056
+ });
2057
+ }
2058
+
2059
+ // src/host/logger.ts
2060
+ import { appendFile as appendFile2, mkdir as mkdir3 } from "fs/promises";
2061
+ import path8 from "path";
2062
+ var HostLogger = class _HostLogger {
2063
+ constructor(file) {
2064
+ this.file = file;
2065
+ }
2066
+ queue = Promise.resolve();
2067
+ static async create(file) {
2068
+ await mkdir3(path8.dirname(file), { recursive: true });
2069
+ return new _HostLogger(file);
2070
+ }
2071
+ info(message) {
2072
+ this.write("INFO", message, false);
2073
+ }
2074
+ error(message) {
2075
+ this.write("ERROR", message, true);
2076
+ }
2077
+ async flush() {
2078
+ await this.queue;
2079
+ }
2080
+ write(level, message, toStderr) {
2081
+ const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${level} ${message}`;
2082
+ if (toStderr) console.error(line);
2083
+ else console.log(line);
2084
+ this.queue = this.queue.then(() => appendFile2(this.file, `${line}
2085
+ `, "utf8")).catch(() => void 0);
2086
+ }
2087
+ };
2088
+
2089
+ // src/local-engine.ts
2090
+ import { existsSync as existsSync3 } from "fs";
2091
+ import { readFile as readFile5 } from "fs/promises";
2092
+ import path9 from "path";
2093
+ import { fileURLToPath as fileURLToPath2 } from "url";
2094
+ import { Miniflare } from "miniflare";
2095
+ var __dirname2 = path9.dirname(fileURLToPath2(import.meta.url));
2096
+ function resolveEngineWorker() {
2097
+ const candidates = [
2098
+ process.env.NEXUS_ENGINE_WORKER,
2099
+ path9.join(__dirname2, "engine-worker.js"),
2100
+ path9.resolve(process.cwd(), "nexus-engine/dist/engine-worker.js"),
2101
+ path9.resolve(process.cwd(), "../nexus-engine/dist/engine-worker.js"),
2102
+ path9.resolve(__dirname2, "../../../nexus-engine/dist/engine-worker.js")
2103
+ ].filter((candidate) => Boolean(candidate));
2104
+ const found = candidates.find((candidate) => existsSync3(candidate));
2105
+ if (!found) {
2106
+ throw new Error(
2107
+ "\u627E\u4E0D\u5230 engine-worker.js\u3002\u8BF7\u5148\u8FD0\u884C make build-cli-engine\uFF0C\u6216\u8BBE\u7F6E NEXUS_ENGINE_WORKER\u3002"
2108
+ );
2109
+ }
2110
+ return found;
2111
+ }
2112
+ async function createLocalEngine(options) {
2113
+ const contents = await readFile5(resolveEngineWorker(), "utf8");
27
2114
  const mf = new Miniflare({
28
2115
  modules: [{ type: "ESModule", path: "engine-worker.js", contents }],
29
- port,
2116
+ port: options.enginePort,
30
2117
  durableObjects: { GAME_DO: "GameDO" },
31
- durableObjectsPersist: path.join(persistDir, "do"),
2118
+ durableObjectsPersist: path9.join(options.persistDir, "do"),
32
2119
  bindings: {
33
2120
  ADMIN_SECRET,
34
2121
  JWT_SECRET
@@ -44,258 +2131,1005 @@ async function startCommand(options) {
44
2131
  });
45
2132
  }
46
2133
  });
47
- const url = await mf.ready;
48
- console.log(`\u2705 Engine \u5DF2\u542F\u52A8: ${url}`);
49
- console.log(`\u{1F4C2} \u6301\u4E45\u5316\u76EE\u5F55: ${persistDir}`);
50
- console.log(`
51
- \u6309 Ctrl+C \u505C\u6B62\u5F15\u64CE
52
- `);
53
- const shutdown = async () => {
54
- console.log("\n\u{1F6D1} \u6B63\u5728\u5173\u95ED\u5F15\u64CE...");
55
- await mf.dispose();
56
- console.log("\u{1F44B} \u5F15\u64CE\u5DF2\u5173\u95ED");
2134
+ await mf.ready;
2135
+ return mf;
2136
+ }
2137
+
2138
+ // src/commands/serve.ts
2139
+ function parsePort(value, fallback, flag) {
2140
+ if (value === void 0) return fallback;
2141
+ const parsed = Number.parseInt(value, 10);
2142
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
2143
+ throw new Error(`${flag} \u5FC5\u987B\u662F 1-65535 \u7684\u7AEF\u53E3\uFF0C\u6536\u5230 "${value}"`);
2144
+ }
2145
+ return parsed;
2146
+ }
2147
+ async function hostResponds(port) {
2148
+ try {
2149
+ const response = await fetch(`http://127.0.0.1:${port}/api/workspace/meta`, {
2150
+ signal: AbortSignal.timeout(1e3),
2151
+ headers: { accept: "application/json" }
2152
+ });
2153
+ await response.body?.cancel().catch(() => void 0);
2154
+ return response.ok;
2155
+ } catch {
2156
+ return false;
2157
+ }
2158
+ }
2159
+ async function serveCommand(options) {
2160
+ const root = path10.resolve(options.root ?? process.cwd());
2161
+ const port = parsePort(options.port, DEFAULT_PORT, "--port");
2162
+ const enginePort = parsePort(options.enginePort, port + 1, "--engine-port");
2163
+ if (port === enginePort) {
2164
+ throw new Error("--port \u548C --engine-port \u4E0D\u80FD\u76F8\u540C");
2165
+ }
2166
+ const existing = await readHostRecord(root);
2167
+ if (existing) {
2168
+ if (isHostRecordAlive(existing)) {
2169
+ if (await hostResponds(existing.port)) {
2170
+ throw new Error(
2171
+ `Workspace host \u5DF2\u5728\u8FD0\u884C: ${existing.url}
2172
+ \u7BA1\u7406\u9875: ${existing.url}/`
2173
+ );
2174
+ }
2175
+ throw new Error(
2176
+ `Host \u8BB0\u5F55\u663E\u793A\u8FDB\u7A0B ${existing.pid} \u4ECD\u5B58\u5728\uFF0C\u4F46 ${existing.url} \u65E0\u54CD\u5E94\u3002
2177
+ \u5982\u786E\u8BA4\u5DF2\u5361\u6B7B\uFF0C\u5220\u9664 ${hostRecordFile(root)} \u540E\u91CD\u8BD5\u3002`
2178
+ );
2179
+ }
2180
+ await removeHostRecord(root);
2181
+ }
2182
+ if (!await isPortFree(port)) {
2183
+ throw new Error(`\u7AEF\u53E3 ${port} \u5DF2\u88AB\u5360\u7528`);
2184
+ }
2185
+ if (!await isPortFree(enginePort)) {
2186
+ throw new Error(`\u5F15\u64CE\u7AEF\u53E3 ${enginePort} \u5DF2\u88AB\u5360\u7528`);
2187
+ }
2188
+ const logger = await HostLogger.create(hostLogFile(root));
2189
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2190
+ logger.info(`workspace host starting: root=${root} port=${port} enginePort=${enginePort}`);
2191
+ await mkdir4(hostEngineStateDir(root), { recursive: true });
2192
+ let mf;
2193
+ let server;
2194
+ let service;
2195
+ let shuttingDown = false;
2196
+ const cleanup = async () => {
2197
+ if (shuttingDown) return;
2198
+ shuttingDown = true;
2199
+ await removeHostRecord(root, process.pid).catch(() => void 0);
2200
+ logger.info("workspace host stopping");
2201
+ await service?.shutdown().catch(() => void 0);
2202
+ await server?.close().catch(() => void 0);
2203
+ await mf?.dispose().catch(() => void 0);
2204
+ await logger.flush();
2205
+ };
2206
+ try {
2207
+ mf = await createLocalEngine({
2208
+ enginePort,
2209
+ persistDir: hostEngineStateDir(root)
2210
+ });
2211
+ service = await WorkspaceService.create({
2212
+ root,
2213
+ port,
2214
+ enginePort,
2215
+ startedAt,
2216
+ logger
2217
+ });
2218
+ const consoleDir = resolveConsoleDir();
2219
+ server = await startHostServer({
2220
+ service,
2221
+ consoleDir,
2222
+ port,
2223
+ enginePort
2224
+ });
2225
+ await writeHostRecord(root, {
2226
+ version: 1,
2227
+ pid: process.pid,
2228
+ root,
2229
+ port,
2230
+ enginePort,
2231
+ url: server.url,
2232
+ startedAt
2233
+ });
2234
+ } catch (error) {
2235
+ await cleanup();
2236
+ throw error;
2237
+ }
2238
+ if (!server) {
2239
+ await cleanup();
2240
+ throw new Error("Host server \u672A\u80FD\u542F\u52A8");
2241
+ }
2242
+ console.log("");
2243
+ console.log("\u2705 Workspace host \u5DF2\u542F\u52A8");
2244
+ console.log(` \u7BA1\u7406\u9875: ${server.url}/`);
2245
+ console.log(` \u5F15\u64CE: 127.0.0.1:${enginePort}`);
2246
+ console.log(` \u6839\u76EE\u5F55: ${root}`);
2247
+ console.log(` \u8FDE\u63A5\u914D\u7F6E: ${workspaceConfigFile(root)}`);
2248
+ console.log(` \u65E5\u5FD7: ${hostLogFile(root)}`);
2249
+ console.log("");
2250
+ console.log("\u4E0B\u4E00\u6B65\uFF1A");
2251
+ console.log(" 1. npgame-dev game start <game> # \u542F\u52A8\u6E38\u620F Worker");
2252
+ console.log(" 2. npgame-dev connect <game> # \u5199\u5165\u623F\u95F4\u914D\u7F6E");
2253
+ console.log(" 3. npgame-dev room start <game> # \u5206\u914D dev \u73A9\u5BB6\u5E76\u5F00\u5C40");
2254
+ console.log("");
2255
+ console.log("\u6309 Ctrl+C \u505C\u6B62 host \u53CA\u5B83\u542F\u52A8\u7684 Worker\uFF1B\u5916\u90E8\u542F\u52A8\u7684 Worker \u4E0D\u53D7\u5F71\u54CD");
2256
+ console.log("");
2257
+ const shutdown = async (signal) => {
2258
+ logger.info(`received ${signal}`);
2259
+ await cleanup();
57
2260
  process.exit(0);
58
2261
  };
59
- process.on("SIGINT", shutdown);
60
- process.on("SIGTERM", shutdown);
2262
+ process.removeAllListeners("SIGINT");
2263
+ process.removeAllListeners("SIGTERM");
2264
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2265
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
61
2266
  await new Promise(() => {
62
2267
  });
63
2268
  }
64
2269
 
65
- // src/client.ts
66
- var EngineClient = class {
2270
+ // src/commands/connect.ts
2271
+ import path11 from "path";
2272
+ import chalk from "chalk";
2273
+
2274
+ // ../workspace-client/src/index.ts
2275
+ var WorkspaceApiError = class extends Error {
2276
+ status;
2277
+ data;
2278
+ constructor(message, status, data) {
2279
+ super(message);
2280
+ this.name = "WorkspaceApiError";
2281
+ this.status = status;
2282
+ this.data = data;
2283
+ }
2284
+ };
2285
+ var WorkspaceClient = class {
67
2286
  baseUrl;
68
- constructor(port = DEFAULT_PORT) {
69
- this.baseUrl = `http://localhost:${port}`;
2287
+ fetchImpl;
2288
+ constructor(options = {}) {
2289
+ this.baseUrl = (options.baseUrl ?? "").replace(/\/+$/, "");
2290
+ this.fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
70
2291
  }
71
- get headers() {
72
- return {
73
- "Content-Type": "application/json",
74
- Authorization: `Bearer ${ADMIN_SECRET}`
75
- };
2292
+ meta() {
2293
+ return this.request("GET", "/api/workspace/meta");
76
2294
  }
77
- async createRoom() {
78
- const res = await fetch(`${this.baseUrl}/api/engine/create`, {
79
- method: "POST",
80
- headers: this.headers,
81
- body: JSON.stringify({
82
- roomId: FIXED_ROOM_ID,
83
- ownerId: FIXED_OWNER_ID
84
- })
85
- });
86
- return res.json();
2295
+ listGames() {
2296
+ return this.request("GET", "/api/workspace/games");
87
2297
  }
88
- async getState() {
89
- const res = await fetch(
90
- `${this.baseUrl}/api/monitor/room/${FIXED_ROOM_ID}`,
91
- { headers: this.headers }
2298
+ listRooms() {
2299
+ return this.request("GET", "/api/workspace/rooms");
2300
+ }
2301
+ connect(gameId, request = {}) {
2302
+ return this.request(
2303
+ "POST",
2304
+ `/api/workspace/games/${encodeURIComponent(gameId)}/connect`,
2305
+ request
92
2306
  );
93
- return res.json();
94
2307
  }
95
- async getPerspective(roleId) {
96
- const res = await fetch(
97
- `${this.baseUrl}/api/monitor/room/${FIXED_ROOM_ID}/perspective?roleId=${encodeURIComponent(roleId)}`,
98
- { headers: this.headers }
2308
+ disconnect(gameId) {
2309
+ return this.request(
2310
+ "POST",
2311
+ `/api/workspace/games/${encodeURIComponent(gameId)}/disconnect`
99
2312
  );
100
- return res.json();
101
2313
  }
102
- async submitAction(roleId, action) {
103
- const res = await fetch(
104
- `${this.baseUrl}/api/monitor/room/${FIXED_ROOM_ID}/action`,
105
- {
106
- method: "POST",
107
- headers: this.headers,
108
- body: JSON.stringify({ roleId, action })
109
- }
2314
+ startWorker(gameId, request = {}) {
2315
+ return this.request(
2316
+ "POST",
2317
+ `/api/workspace/games/${encodeURIComponent(gameId)}/worker/start`,
2318
+ request
110
2319
  );
111
- return res.json();
112
2320
  }
113
- async setGame(gameWorkerUrl, gameId, players) {
114
- const res = await fetch(
115
- `${this.baseUrl}/api/monitor/room/${FIXED_ROOM_ID}/set-game`,
116
- {
117
- method: "POST",
118
- headers: this.headers,
119
- body: JSON.stringify({ gameWorkerUrl, gameId, selectedPlayerCount: players })
120
- }
2321
+ stopWorker(gameId, request = {}) {
2322
+ return this.request(
2323
+ "POST",
2324
+ `/api/workspace/games/${encodeURIComponent(gameId)}/worker/stop`,
2325
+ request
121
2326
  );
122
- return res.json();
123
2327
  }
124
- async startGame() {
125
- const res = await fetch(
126
- `${this.baseUrl}/api/monitor/room/${FIXED_ROOM_ID}/start-game`,
127
- { method: "POST", headers: this.headers, body: "{}" }
2328
+ workerLogs(gameId, lines = 200) {
2329
+ const query = new URLSearchParams({ lines: String(lines) });
2330
+ return this.request(
2331
+ "GET",
2332
+ `/api/workspace/games/${encodeURIComponent(gameId)}/worker/logs?${query}`
128
2333
  );
129
- return res.json();
130
2334
  }
131
- async stopGame() {
132
- const res = await fetch(
133
- `${this.baseUrl}/api/monitor/room/${FIXED_ROOM_ID}/stop-game`,
134
- { method: "POST", headers: this.headers, body: "{}" }
2335
+ startRoom(roomId) {
2336
+ return this.request(
2337
+ "POST",
2338
+ `/api/workspace/rooms/${encodeURIComponent(roomId)}/start`
135
2339
  );
136
- return res.json();
137
2340
  }
138
- async restartGame() {
139
- const res = await fetch(
140
- `${this.baseUrl}/api/monitor/room/${FIXED_ROOM_ID}/restart-game`,
141
- { method: "POST", headers: this.headers, body: "{}" }
2341
+ stopRoom(roomId) {
2342
+ return this.request(
2343
+ "POST",
2344
+ `/api/workspace/rooms/${encodeURIComponent(roomId)}/stop`
142
2345
  );
143
- return res.json();
2346
+ }
2347
+ async request(method, path18, body) {
2348
+ let response;
2349
+ try {
2350
+ response = await this.fetchImpl(`${this.baseUrl}${path18}`, {
2351
+ method,
2352
+ headers: body === void 0 ? { accept: "application/json" } : { accept: "application/json", "content-type": "application/json" },
2353
+ body: body === void 0 ? void 0 : JSON.stringify(body)
2354
+ });
2355
+ } catch (error) {
2356
+ throw new WorkspaceApiError(
2357
+ error instanceof Error ? error.message : "Workspace host is unreachable"
2358
+ );
2359
+ }
2360
+ const text = await response.text();
2361
+ let payload = null;
2362
+ if (text) {
2363
+ try {
2364
+ payload = JSON.parse(text);
2365
+ } catch {
2366
+ payload = text;
2367
+ }
2368
+ }
2369
+ if (!response.ok) {
2370
+ const message = typeof payload === "object" && payload !== null && typeof payload.error === "string" ? payload.error : `HTTP ${response.status}`;
2371
+ throw new WorkspaceApiError(message, response.status, payload);
2372
+ }
2373
+ return payload;
144
2374
  }
145
2375
  };
146
2376
 
147
- // src/commands/setup.ts
148
- async function setupCommand(options) {
149
- const port = parseInt(options.port || String(DEFAULT_PORT), 10);
150
- const client = new EngineClient(port);
2377
+ // src/workspace/util.ts
2378
+ function errorMessage3(error) {
2379
+ return error instanceof Error ? error.message : String(error);
2380
+ }
2381
+ var WIDE_CHAR = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/;
2382
+ function displayWidth(value) {
2383
+ let width = 0;
2384
+ for (const char of value) width += WIDE_CHAR.test(char) ? 2 : 1;
2385
+ return width;
2386
+ }
2387
+ function padDisplay(value, width) {
2388
+ return value + " ".repeat(Math.max(0, width - displayWidth(value)));
2389
+ }
2390
+
2391
+ // src/host/client.ts
2392
+ function parsePortOption(value, flag = "--port") {
2393
+ if (value === void 0) return void 0;
2394
+ const parsed = Number.parseInt(value, 10);
2395
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
2396
+ throw new Error(`${flag} \u5FC5\u987B\u662F 1-65535 \u7684\u7AEF\u53E3\uFF0C\u6536\u5230 "${value}"`);
2397
+ }
2398
+ return parsed;
2399
+ }
2400
+ async function resolveHost(root, portOption) {
2401
+ const record = await readHostRecord(root);
2402
+ const explicitPort = parsePortOption(portOption);
2403
+ const port = explicitPort ?? (record && isHostRecordAlive(record) ? record.port : DEFAULT_PORT);
2404
+ const client = new WorkspaceClient({ baseUrl: `http://127.0.0.1:${port}` });
151
2405
  try {
152
- console.log("\u{1F50D} \u68C0\u67E5\u623F\u95F4\u72B6\u6001...");
153
- let roomExists = false;
154
- let currentPhase = "";
155
- try {
156
- const stateRes = await client.getState();
157
- if (stateRes?.data?.roomId) {
158
- roomExists = true;
159
- currentPhase = stateRes.data.phase;
160
- console.log(` \u623F\u95F4\u5DF2\u5B58\u5728, phase: ${currentPhase}`);
2406
+ const meta = await client.meta();
2407
+ return { client, meta };
2408
+ } catch (error) {
2409
+ const detail = error instanceof WorkspaceApiError ? error.message : errorMessage3(error);
2410
+ throw new Error(
2411
+ `\u672A\u627E\u5230\u8FD0\u884C\u4E2D\u7684 Workspace host (127.0.0.1:${port})\u3002
2412
+ \u5148\u8FD0\u884C: npgame-dev serve -r ${root}
2413
+ \u539F\u56E0: ${detail}`
2414
+ );
2415
+ }
2416
+ }
2417
+ function roomConsoleUrl(hostUrl, roomId) {
2418
+ const encoded = encodeURIComponent(roomId);
2419
+ return `${hostUrl.replace(/\/+$/, "")}/rooms/${encoded}`;
2420
+ }
2421
+ async function resolveRoomTarget(root, portOption, explicitRoom) {
2422
+ const { client, meta } = await resolveHost(root, portOption);
2423
+ const response = await client.listGames();
2424
+ const connected = response.games.filter((game2) => game2.connection);
2425
+ if (explicitRoom) {
2426
+ const match = connected.find((game2) => game2.connection?.roomId === explicitRoom || game2.id === explicitRoom);
2427
+ if (!match?.connection) {
2428
+ throw new Error(`\u623F\u95F4 "${explicitRoom}" \u672A\u8FDE\u63A5`);
2429
+ }
2430
+ return { roomId: match.connection.roomId, port: meta.port };
2431
+ }
2432
+ const rooms = [...new Set(connected.map((game2) => game2.connection.roomId))];
2433
+ if (rooms.length === 1) return { roomId: rooms[0], port: meta.port };
2434
+ if (rooms.length === 0) {
2435
+ throw new Error("\u6CA1\u6709\u5DF2\u8FDE\u63A5\u623F\u95F4\u3002\u5148\u8FD0\u884C npgame-dev connect <game>");
2436
+ }
2437
+ throw new Error(`\u6709\u591A\u4E2A\u5DF2\u8FDE\u63A5\u623F\u95F4\uFF0C\u8BF7\u7528 --room \u6307\u5B9A: ${rooms.join(", ")}`);
2438
+ }
2439
+
2440
+ // src/commands/connect.ts
2441
+ function optionalPlayers(value) {
2442
+ if (value === void 0) return void 0;
2443
+ const parsed = Number.parseInt(value, 10);
2444
+ if (!Number.isInteger(parsed) || parsed <= 0) {
2445
+ throw new Error(`--players \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF0C\u6536\u5230 "${value}"`);
2446
+ }
2447
+ return parsed;
2448
+ }
2449
+ async function connectCommand(selector, options) {
2450
+ const root = path11.resolve(options.root ?? process.cwd());
2451
+ try {
2452
+ const scan = await scanGames(root);
2453
+ const game2 = resolveGame(scan.games, selector);
2454
+ const { client } = await resolveHost(root, options.port);
2455
+ const entry = await client.connect(game2.id, {
2456
+ workerUrl: options.workerUrl,
2457
+ players: optionalPlayers(options.players),
2458
+ roomId: options.room
2459
+ });
2460
+ if (options.json) {
2461
+ console.log(JSON.stringify(entry, null, 2));
2462
+ return;
2463
+ }
2464
+ console.log("");
2465
+ console.log(`\u2705 \u5DF2\u8FDE\u63A5 ${chalk.bold(entry.name)} (${entry.id})`);
2466
+ console.log(` \u623F\u95F4: ${entry.connection?.roomId ?? game2.id}`);
2467
+ console.log(` Worker: ${entry.connection?.workerUrl ?? entry.worker.url ?? "(\u9ED8\u8BA4)"}`);
2468
+ console.log(` \u89D2\u8272: ${entry.connection?.roleIds.join(", ") || "(\u65E0)"}`);
2469
+ console.log(` phase: ${entry.room?.phase ?? "lobby"}\uFF08\u5C1A\u672A\u5F00\u59CB\uFF09`);
2470
+ console.log("");
2471
+ console.log(`\u4E0B\u4E00\u6B65\u5F00\u59CB\u623F\u95F4: npgame-dev room start ${entry.connection?.roomId ?? game2.id}`);
2472
+ } catch (error) {
2473
+ console.error(`\u274C ${errorMessage3(error)}`);
2474
+ process.exitCode = 1;
2475
+ }
2476
+ }
2477
+
2478
+ // src/commands/disconnect.ts
2479
+ import path12 from "path";
2480
+ import chalk2 from "chalk";
2481
+ async function disconnectCommand(selector, options) {
2482
+ const root = path12.resolve(options.root ?? process.cwd());
2483
+ try {
2484
+ const { client } = await resolveHost(root, options.port);
2485
+ const response = await client.listGames();
2486
+ const game2 = response.games.find((item) => item.connection && (item.connection.roomId === selector || item.connection.gameId === selector || item.id === selector));
2487
+ if (!game2?.connection) {
2488
+ throw new Error(`\u6E38\u620F\u6216\u623F\u95F4 "${selector}" \u672A\u8FDE\u63A5`);
2489
+ }
2490
+ await client.disconnect(game2.id);
2491
+ if (options.json) {
2492
+ console.log(JSON.stringify({ ok: true, gameId: game2.id, roomId: game2.connection.roomId }, null, 2));
2493
+ return;
2494
+ }
2495
+ console.log(
2496
+ `\u2705 \u5DF2\u65AD\u5F00 ${chalk2.bold(game2.name)} (${game2.id})\uFF0C\u623F\u95F4 ${game2.connection.roomId} \u7684\u914D\u7F6E\u5DF2\u79FB\u9664`
2497
+ );
2498
+ console.log(chalk2.dim("Worker \u4E0D\u4F1A\u88AB\u505C\u6B62\uFF1B\u9700\u8981\u65F6\u8FD0\u884C npgame-dev game stop " + game2.id));
2499
+ } catch (error) {
2500
+ console.error(`\u274C ${errorMessage3(error)}`);
2501
+ process.exitCode = 1;
2502
+ }
2503
+ }
2504
+
2505
+ // src/commands/room.ts
2506
+ import path14 from "path";
2507
+ import chalk4 from "chalk";
2508
+
2509
+ // src/commands/ls.ts
2510
+ import path13 from "path";
2511
+ import chalk3 from "chalk";
2512
+ var WORKER_LABEL = {
2513
+ unknown: "\u672A\u77E5",
2514
+ stopped: "\u672A\u542F\u52A8",
2515
+ starting: "\u542F\u52A8\u4E2D",
2516
+ running: "\u8FD0\u884C\u4E2D",
2517
+ external: "\u5916\u90E8\u8FD0\u884C",
2518
+ error: "\u5F02\u5E38"
2519
+ };
2520
+ function workerMark(state) {
2521
+ switch (state) {
2522
+ case "running":
2523
+ return chalk3.green("\u25CF");
2524
+ case "external":
2525
+ return chalk3.cyan("\u25CF");
2526
+ case "starting":
2527
+ return chalk3.yellow("\u25D0");
2528
+ case "error":
2529
+ return chalk3.red("\u2715");
2530
+ case "stopped":
2531
+ return chalk3.dim("\u25CB");
2532
+ default:
2533
+ return chalk3.dim("?");
2534
+ }
2535
+ }
2536
+ function workerLabel(state) {
2537
+ switch (state) {
2538
+ case "running":
2539
+ return chalk3.green(WORKER_LABEL[state]);
2540
+ case "external":
2541
+ return chalk3.cyan(WORKER_LABEL[state]);
2542
+ case "starting":
2543
+ return chalk3.yellow(WORKER_LABEL[state]);
2544
+ case "error":
2545
+ return chalk3.red(WORKER_LABEL[state]);
2546
+ default:
2547
+ return chalk3.dim(WORKER_LABEL[state]);
2548
+ }
2549
+ }
2550
+ function roomPhaseLabel(phase) {
2551
+ switch (phase) {
2552
+ case "playing":
2553
+ return chalk3.green("playing");
2554
+ case "paused":
2555
+ return chalk3.yellow("paused");
2556
+ case "finished":
2557
+ return chalk3.magenta("finished");
2558
+ case "lobby":
2559
+ return chalk3.dim("lobby");
2560
+ default:
2561
+ return chalk3.dim("\u672A\u8FDE\u63A5");
2562
+ }
2563
+ }
2564
+ function offlineEntry(game2) {
2565
+ return {
2566
+ id: game2.id,
2567
+ name: game2.name,
2568
+ dir: game2.dir,
2569
+ worker: {
2570
+ state: "unknown",
2571
+ reachable: false,
2572
+ owned: false,
2573
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
2574
+ },
2575
+ connected: false,
2576
+ capabilities: {
2577
+ startWorker: false,
2578
+ stopWorker: false,
2579
+ logWorker: false,
2580
+ connect: false,
2581
+ disconnect: false,
2582
+ startRoom: false,
2583
+ stopRoom: false,
2584
+ openConsole: false
2585
+ }
2586
+ };
2587
+ }
2588
+ async function gameLsCommand(options) {
2589
+ const root = path13.resolve(options.root ?? process.cwd());
2590
+ let host2;
2591
+ try {
2592
+ host2 = await resolveHost(root, options.port);
2593
+ } catch {
2594
+ }
2595
+ let games;
2596
+ let warnings = [];
2597
+ if (host2) {
2598
+ const response = await host2.client.listGames();
2599
+ games = response.games;
2600
+ warnings = response.warnings;
2601
+ } else {
2602
+ const scan = await scanGames(root);
2603
+ games = scan.games.map(offlineEntry);
2604
+ warnings = scan.warnings;
2605
+ }
2606
+ for (const warning of warnings) console.warn(`\u26A0\uFE0F ${warning}`);
2607
+ if (options.json) {
2608
+ console.log(JSON.stringify({
2609
+ root,
2610
+ host: host2 ? host2.meta.url : null,
2611
+ games,
2612
+ warnings
2613
+ }, null, 2));
2614
+ return;
2615
+ }
2616
+ if (games.length === 0) {
2617
+ console.log("\u672A\u53D1\u73B0\u4EFB\u4F55 nexus.game.json\u3002");
2618
+ console.log("");
2619
+ console.log("\u626B\u63CF\u8303\u56F4\uFF1A\u5F53\u524D\u76EE\u5F55\u3001\u76F4\u63A5\u5B50\u76EE\u5F55\u3001games/ \u4E0E nexus-games/ \u7684\u5B50\u76EE\u5F55\u3002");
2620
+ console.log("\u5982\u679C\u6E38\u620F\u5728 ../nexus-games/\uFF0C\u8BF7\u5148 cd \u5230\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55\uFF0C\u6216\u4F7F\u7528 --root <\u76EE\u5F55>\u3002");
2621
+ return;
2622
+ }
2623
+ const idWidth = Math.max(2, ...games.map((game2) => game2.id.length)) + 2;
2624
+ const nameWidth = Math.max(4, ...games.map((game2) => displayWidth(game2.name))) + 2;
2625
+ console.log(chalk3.dim(`${" ".repeat(2)}${padDisplay("ID", idWidth)}${padDisplay("\u540D\u79F0", nameWidth)}Worker / \u623F\u95F4`));
2626
+ for (const game2 of games) {
2627
+ const worker = game2.worker.state === "error" ? chalk3.red(game2.worker.error ?? "\u5F02\u5E38") : `${workerLabel(game2.worker.state)}${game2.worker.url ? chalk3.dim(` ${game2.worker.url}`) : ""}`;
2628
+ const room2 = game2.connection ? `${chalk3.dim(" \u2192 ")}${game2.connection.roomId} ${roomPhaseLabel(game2.room?.phase ?? "lobby")}` : "";
2629
+ console.log(
2630
+ `${workerMark(game2.worker.state)} ${padDisplay(game2.id, idWidth)}${padDisplay(game2.name, nameWidth)}${worker}${room2}`
2631
+ );
2632
+ }
2633
+ const workers = games.filter((game2) => game2.worker.state === "running" || game2.worker.state === "external").length;
2634
+ const connected = games.filter((game2) => game2.connected).length;
2635
+ const playing = games.filter((game2) => game2.room?.phase === "playing").length;
2636
+ console.log("");
2637
+ if (host2) {
2638
+ console.log(chalk3.dim(
2639
+ `\u5171 ${games.length} \u4E2A\u6E38\u620F\uFF0CWorker \u8FD0\u884C ${workers} \u4E2A\uFF0C\u5DF2\u8FDE\u63A5\u623F\u95F4 ${connected} \u4E2A\uFF0C\u8FDB\u884C\u4E2D ${playing} \u4E2A\u3002Host: ${host2.meta.url}`
2640
+ ));
2641
+ } else {
2642
+ console.log(chalk3.dim(
2643
+ `\u5171 ${games.length} \u4E2A\u6E38\u620F\u3002Host \u672A\u8FD0\u884C\uFF0CWorker \u72B6\u6001\u672A\u77E5\uFF1B\u8FD0\u884C npgame-dev serve \u540E\u67E5\u770B\u771F\u5B9E\u72B6\u6001\u3002`
2644
+ ));
2645
+ }
2646
+ }
2647
+
2648
+ // src/commands/room.ts
2649
+ function phaseMark(room2) {
2650
+ switch (room2.phase) {
2651
+ case "playing":
2652
+ return chalk4.green("\u25CF");
2653
+ case "paused":
2654
+ return chalk4.yellow("\u25D0");
2655
+ case "finished":
2656
+ return chalk4.magenta("\u2713");
2657
+ case "lobby":
2658
+ return chalk4.dim("\u25CB");
2659
+ default:
2660
+ return chalk4.red("\u2715");
2661
+ }
2662
+ }
2663
+ function printRoom(room2, hostUrl) {
2664
+ console.log(
2665
+ `${phaseMark(room2)} ${chalk4.bold(room2.roomId)} ${room2.name} ${room2.phase ? roomPhaseLabel(room2.phase) : chalk4.red("\u72B6\u6001\u4E0D\u53EF\u7528")}`
2666
+ );
2667
+ console.log(` \u6E38\u620F: ${room2.gameId} ${chalk4.dim(room2.gameDir)}`);
2668
+ console.log(
2669
+ ` Worker: ${room2.worker.url ?? "(\u672A\u77E5)"} ${workerMark(room2.worker.state)} ${workerLabel(room2.worker.state)}${room2.worker.pid ? ` \xB7 pid ${room2.worker.pid}` : ""}`
2670
+ );
2671
+ if (room2.roleIds.length > 0) {
2672
+ console.log(` \u89D2\u8272: ${room2.roleIds.join(", ")}`);
2673
+ }
2674
+ if (room2.playerCount !== void 0) {
2675
+ console.log(` \u73A9\u5BB6: ${room2.playerCount}${room2.stateIndex !== void 0 ? ` \xB7 state #${room2.stateIndex}` : ""}`);
2676
+ }
2677
+ console.log(` \u63A7\u5236\u53F0: ${roomConsoleUrl(hostUrl, room2.roomId)}`);
2678
+ if (room2.error) console.log(` \u9519\u8BEF: ${chalk4.yellow(room2.error)}`);
2679
+ }
2680
+ async function roomStatusCommand(selector, options) {
2681
+ const root = path14.resolve(options.root ?? process.cwd());
2682
+ try {
2683
+ const { client, meta } = await resolveHost(root, options.port);
2684
+ const response = await client.listRooms();
2685
+ if (selector) {
2686
+ const room2 = response.rooms.find((item) => item.roomId === selector || item.gameId === selector || item.name === selector);
2687
+ if (!room2) {
2688
+ throw new Error(`\u627E\u4E0D\u5230\u5DF2\u8FDE\u63A5\u623F\u95F4 "${selector}"`);
161
2689
  }
162
- } catch {
2690
+ if (options.json) {
2691
+ console.log(JSON.stringify(room2, null, 2));
2692
+ return;
2693
+ }
2694
+ printRoom(room2, meta.url);
2695
+ return;
163
2696
  }
164
- if (roomExists && (currentPhase === "playing" || currentPhase === "paused")) {
165
- console.log("\u23F9\uFE0F \u505C\u6B62\u5F53\u524D\u6E38\u620F...");
166
- const stopRes = await client.stopGame();
167
- if (stopRes.error) {
168
- console.error(`\u274C \u505C\u6B62\u5931\u8D25: ${stopRes.error}`);
169
- process.exit(1);
170
- }
171
- console.log(" \u6E38\u620F\u5DF2\u505C\u6B62");
172
- }
173
- if (!roomExists) {
174
- console.log("\u{1F4E6} \u521B\u5EFA\u623F\u95F4...");
175
- const createRes = await client.createRoom();
176
- if (createRes.error) {
177
- console.error(`\u274C \u521B\u5EFA\u5931\u8D25: ${createRes.error}`);
178
- process.exit(1);
179
- }
180
- console.log(` \u623F\u95F4\u5DF2\u521B\u5EFA: ${createRes.roomId}`);
181
- }
182
- console.log(`\u{1F3AE} \u8BBE\u7F6E\u6E38\u620F (${options.workerUrl})...`);
183
- const players = options.players ? parseInt(options.players, 10) : void 0;
184
- const setRes = await client.setGame(options.workerUrl, void 0, players);
185
- if (setRes.error) {
186
- console.error(`\u274C \u8BBE\u7F6E\u6E38\u620F\u5931\u8D25: ${setRes.error}`);
187
- process.exit(1);
188
- }
189
- console.log(` \u6E38\u620F: ${setRes.gameId} (\u89D2\u8272: ${setRes.roleIds?.join(", ")})`);
190
- console.log("\u25B6\uFE0F \u5F00\u59CB\u6E38\u620F...");
191
- const startRes = await client.startGame();
192
- if (startRes.error) {
193
- console.error(`\u274C \u542F\u52A8\u5931\u8D25: ${startRes.error}`);
194
- process.exit(1);
195
- }
196
- console.log(" \u6E38\u620F\u5DF2\u5F00\u59CB!");
197
- console.log("\n\u53EF\u7528\u547D\u4EE4:");
198
- console.log(` npx @nexusgame/dev state \u67E5\u770B\u623F\u95F4\u72B6\u6001`);
199
- console.log(` npx @nexusgame/dev perspective <roleId> \u67E5\u770B\u89D2\u8272\u89C6\u89D2`);
200
- console.log(` npx @nexusgame/dev action <roleId> '<action_json>' \u6267\u884C\u884C\u52A8`);
201
- } catch (e) {
202
- if (e.cause?.code === "ECONNREFUSED") {
203
- console.error(`\u274C \u65E0\u6CD5\u8FDE\u63A5\u5230\u5F15\u64CE (localhost:${port})\u3002\u8BF7\u5148\u8FD0\u884C: npx @nexusgame/dev start`);
204
- } else {
205
- console.error(`\u274C \u9519\u8BEF: ${e.message}`);
2697
+ if (options.json) {
2698
+ console.log(JSON.stringify(response.rooms, null, 2));
2699
+ return;
2700
+ }
2701
+ if (response.rooms.length === 0) {
2702
+ console.log("\u6CA1\u6709\u5DF2\u8FDE\u63A5\u623F\u95F4\u3002");
2703
+ console.log("\u5148\u8FD0\u884C npgame-dev connect <game>\u3002");
2704
+ return;
206
2705
  }
207
- process.exit(1);
2706
+ for (const [index, room2] of response.rooms.entries()) {
2707
+ if (index > 0) console.log("");
2708
+ printRoom(room2, meta.url);
2709
+ }
2710
+ } catch (error) {
2711
+ console.error(`\u274C ${errorMessage3(error)}`);
2712
+ process.exitCode = 1;
2713
+ }
2714
+ }
2715
+ async function roomStartCommand(selector, options) {
2716
+ const root = path14.resolve(options.root ?? process.cwd());
2717
+ try {
2718
+ const { client, meta } = await resolveHost(root, options.port);
2719
+ const result = await client.startRoom(selector);
2720
+ if (options.json) {
2721
+ console.log(JSON.stringify(result, null, 2));
2722
+ return;
2723
+ }
2724
+ console.log("");
2725
+ console.log(`\u2705 \u623F\u95F4 ${chalk4.bold(result.roomId)} \u5DF2\u5F00\u59CB (${result.phase})`);
2726
+ console.log(` \u63A7\u5236\u53F0: ${roomConsoleUrl(meta.url, result.roomId)}`);
2727
+ console.log(` \u89D2\u8272: ${result.roleIds.join(", ") || "(\u65E0)"}`);
2728
+ } catch (error) {
2729
+ console.error(`\u274C ${errorMessage3(error)}`);
2730
+ process.exitCode = 1;
2731
+ }
2732
+ }
2733
+ async function roomStopCommand(selector, options) {
2734
+ const root = path14.resolve(options.root ?? process.cwd());
2735
+ try {
2736
+ const { client } = await resolveHost(root, options.port);
2737
+ const result = await client.stopRoom(selector);
2738
+ if (options.json) {
2739
+ console.log(JSON.stringify(result, null, 2));
2740
+ return;
2741
+ }
2742
+ console.log(`\u2705 \u623F\u95F4 ${chalk4.bold(result.roomId)} \u5DF2\u505C\u6B62 (${result.phase})`);
2743
+ } catch (error) {
2744
+ console.error(`\u274C ${errorMessage3(error)}`);
2745
+ process.exitCode = 1;
208
2746
  }
209
2747
  }
210
2748
 
2749
+ // src/client.ts
2750
+ function createEngineClient(port = DEFAULT_PORT) {
2751
+ return new EngineAdminClient({
2752
+ baseUrl: `http://127.0.0.1:${port}`
2753
+ });
2754
+ }
2755
+
211
2756
  // src/commands/state.ts
212
2757
  async function stateCommand(options) {
213
- const port = parseInt(options.port || String(DEFAULT_PORT), 10);
214
- const client = new EngineClient(port);
215
- try {
216
- const result = await client.getState();
217
- console.log(JSON.stringify(result, null, 2));
218
- } catch (e) {
219
- if (e.cause?.code === "ECONNREFUSED") {
220
- console.error(`\u274C \u65E0\u6CD5\u8FDE\u63A5\u5230\u5F15\u64CE (localhost:${port})\u3002\u8BF7\u5148\u8FD0\u884C: npx @nexusgame/dev start`);
221
- } else {
222
- console.error(`\u274C \u9519\u8BEF: ${e.message}`);
2758
+ try {
2759
+ const { roomId, port } = await resolveRoomTarget(process.cwd(), options.port, options.room);
2760
+ const client = createEngineClient(port);
2761
+ const result = await client.getState(roomId);
2762
+ if (!result.ok) {
2763
+ console.error(`\u274C \u83B7\u53D6\u72B6\u6001\u5931\u8D25: ${result.error}`);
2764
+ process.exitCode = 1;
2765
+ return;
223
2766
  }
224
- process.exit(1);
2767
+ console.log(JSON.stringify(result.data, null, 2));
2768
+ } catch (error) {
2769
+ console.error(`\u274C ${errorMessage3(error)}`);
2770
+ process.exitCode = 1;
225
2771
  }
226
2772
  }
227
2773
 
228
2774
  // src/commands/perspective.ts
229
2775
  async function perspectiveCommand(roleId, options) {
230
- const port = parseInt(options.port || String(DEFAULT_PORT), 10);
231
- const client = new EngineClient(port);
232
2776
  try {
233
- const result = await client.getPerspective(roleId);
234
- console.log(JSON.stringify(result, null, 2));
235
- try {
236
- const stateRes = await client.getState();
237
- const uiUrl = stateRes?.data?.gameConfig?.gameWorkerUrl;
238
- if (uiUrl) {
239
- console.log(`
240
- \u{1F4FA} \u8C03\u8BD5 GUI \u9875\u9762:`);
241
- const ADMIN_SECRET2 = process.env.ADMIN_SECRET || "nexus-dev-secret";
242
- const FIXED_ROOM_ID2 = "dev-room";
243
- const stateUrl = encodeURIComponent(`http://127.0.0.1:${port}/api/monitor/room/${FIXED_ROOM_ID2}/perspective?roleId=${roleId}`);
244
- const actionUrl = encodeURIComponent(`http://127.0.0.1:${port}/api/monitor/room/${FIXED_ROOM_ID2}/action`);
245
- const token = encodeURIComponent(ADMIN_SECRET2);
246
- console.log(`${uiUrl}/game-ui.html?stateUrl=${stateUrl}&actionUrl=${actionUrl}&token=${token}`);
247
- }
248
- } catch (ignored) {
249
- }
250
- } catch (e) {
251
- if (e.cause?.code === "ECONNREFUSED") {
252
- console.error(`\u274C \u65E0\u6CD5\u8FDE\u63A5\u5230\u5F15\u64CE (localhost:${port})\u3002\u8BF7\u5148\u8FD0\u884C: npx @nexusgame/dev start`);
253
- } else {
254
- console.error(`\u274C \u9519\u8BEF: ${e.message}`);
2777
+ const { roomId, port } = await resolveRoomTarget(process.cwd(), options.port, options.room);
2778
+ const client = createEngineClient(port);
2779
+ const result = await client.getPerspective(roomId, roleId);
2780
+ if (!result.ok) {
2781
+ console.error(`\u274C \u83B7\u53D6\u89C6\u89D2\u5931\u8D25: ${result.error}`);
2782
+ process.exitCode = 1;
2783
+ return;
255
2784
  }
256
- process.exit(1);
2785
+ console.log(JSON.stringify(result.data, null, 2));
2786
+ } catch (error) {
2787
+ console.error(`\u274C ${errorMessage3(error)}`);
2788
+ process.exitCode = 1;
257
2789
  }
258
2790
  }
259
2791
 
260
2792
  // src/commands/action.ts
261
2793
  async function actionCommand(roleId, actionJson, options) {
262
- const port = parseInt(options.port || String(DEFAULT_PORT), 10);
263
- const client = new EngineClient(port);
264
2794
  let action;
265
2795
  try {
266
2796
  action = JSON.parse(actionJson);
267
2797
  } catch {
268
2798
  console.error(`\u274C \u65E0\u6548\u7684 JSON: ${actionJson}`);
269
- process.exit(1);
2799
+ process.exitCode = 1;
2800
+ return;
270
2801
  }
271
2802
  if (!action.action_id) {
272
- console.error(`\u274C action_json \u5FC5\u987B\u5305\u542B action_id \u5B57\u6BB5`);
273
- process.exit(1);
2803
+ console.error("\u274C action_json \u5FC5\u987B\u5305\u542B action_id \u5B57\u6BB5");
2804
+ process.exitCode = 1;
2805
+ return;
274
2806
  }
275
2807
  try {
276
- const result = await client.submitAction(roleId, action);
277
- if (result.success) {
2808
+ const { roomId, port } = await resolveRoomTarget(process.cwd(), options.port, options.room);
2809
+ const client = createEngineClient(port);
2810
+ const result = await client.submitAction(roomId, roleId, action);
2811
+ if (result.ok) {
278
2812
  console.log("\u2705 \u884C\u52A8\u6210\u529F");
279
- console.log(JSON.stringify(result, null, 2));
2813
+ console.log(JSON.stringify(result.data ?? {}, null, 2));
280
2814
  } else {
281
2815
  console.error(`\u274C \u884C\u52A8\u5931\u8D25: ${result.error}`);
2816
+ process.exitCode = 1;
2817
+ }
2818
+ } catch (error) {
2819
+ console.error(`\u274C ${errorMessage3(error)}`);
2820
+ process.exitCode = 1;
2821
+ }
2822
+ }
2823
+
2824
+ // src/commands/init.ts
2825
+ import { spawnSync as spawnSync2 } from "child_process";
2826
+ import path15 from "path";
2827
+ import chalk5 from "chalk";
2828
+ function runPackageManager(packageManager, args, cwd) {
2829
+ const printable = [packageManager, ...args].join(" ");
2830
+ console.log(chalk5.dim(`$ ${printable} (cwd: ${cwd})`));
2831
+ const result = spawnSync2(packageManager, args, {
2832
+ cwd,
2833
+ stdio: "inherit",
2834
+ shell: process.platform === "win32"
2835
+ });
2836
+ if (result.error) {
2837
+ throw new Error(`\u65E0\u6CD5\u8FD0\u884C ${packageManager}: ${result.error.message}`);
2838
+ }
2839
+ if (result.status !== 0) {
2840
+ throw new Error(`\u547D\u4EE4\u6267\u884C\u5931\u8D25\uFF08exit ${result.status ?? "-"}\uFF09: ${printable}`);
2841
+ }
2842
+ }
2843
+ async function gameInitCommand(selector, options) {
2844
+ const root = path15.resolve(options.root ?? process.cwd());
2845
+ try {
2846
+ const scan = await scanGames(root);
2847
+ const game2 = resolveGame(scan.games, selector);
2848
+ const pkg = await readPackageJson(game2.dir);
2849
+ const packageManager = await detectPackageManager(game2.dir, pkg);
2850
+ const scripts = typeof pkg.scripts === "object" && pkg.scripts !== null ? pkg.scripts : {};
2851
+ const nodeModules = path15.join(game2.dir, "node_modules");
2852
+ const uiAsset = path15.join(game2.dir, "worker", "public", "_ui.js");
2853
+ const hasDependencies = await pathExists(nodeModules);
2854
+ const hasUiAsset = await pathExists(uiAsset);
2855
+ console.log(`${chalk5.bold(game2.name)} (${game2.id})`);
2856
+ console.log(` \u76EE\u5F55: ${game2.dir}`);
2857
+ console.log(` \u5305\u7BA1\u7406\u5668: ${packageManager}`);
2858
+ console.log("");
2859
+ let didWork = false;
2860
+ if (options.install === false) {
2861
+ console.log(chalk5.dim("\u8DF3\u8FC7\u4F9D\u8D56\u5B89\u88C5 (--no-install)"));
2862
+ } else if (hasDependencies && !options.force) {
2863
+ console.log(chalk5.dim("\u2713 \u4F9D\u8D56\u5DF2\u5B89\u88C5"));
2864
+ } else {
2865
+ console.log(chalk5.blue(`\u{1F4E6} \u5B89\u88C5\u4F9D\u8D56 (${packageManager})...`));
2866
+ runPackageManager(packageManager, ["install"], game2.dir);
2867
+ didWork = true;
2868
+ }
2869
+ if (options.build === false) {
2870
+ console.log(chalk5.dim("\u8DF3\u8FC7\u6784\u5EFA (--no-build)"));
2871
+ } else if (typeof scripts.build !== "string") {
2872
+ console.log(chalk5.yellow("\u26A0\uFE0F package.json \u7F3A\u5C11 scripts.build\uFF0C\u8DF3\u8FC7\u6784\u5EFA"));
2873
+ } else if (hasUiAsset && !options.force) {
2874
+ console.log(chalk5.dim("\u2713 UI \u8D44\u6E90\u5DF2\u6784\u5EFA (worker/public/_ui.js)"));
2875
+ } else {
2876
+ console.log(chalk5.blue("\u{1F528} \u6784\u5EFA\u6E38\u620F\uFF08\u751F\u6210 worker/public/_ui.js\uFF09..."));
2877
+ runPackageManager(packageManager, ["run", "build"], game2.dir);
2878
+ didWork = true;
2879
+ }
2880
+ console.log("");
2881
+ console.log(chalk5.green(didWork ? "\u2705 \u521D\u59CB\u5316\u5B8C\u6210" : "\u2705 \u6E38\u620F\u5DF2\u7ECF\u5904\u4E8E\u53EF\u8FD0\u884C\u72B6\u6001"));
2882
+ console.log(`\u4E0B\u4E00\u6B65: npgame-dev game start ${game2.id}`);
2883
+ } catch (error) {
2884
+ console.error(`\u274C ${errorMessage3(error)}`);
2885
+ process.exitCode = 1;
2886
+ }
2887
+ }
2888
+
2889
+ // src/commands/game.ts
2890
+ import path16 from "path";
2891
+ import chalk6 from "chalk";
2892
+ var resolveRoot = (root) => path16.resolve(root ?? process.cwd());
2893
+ function optionalInt(value, flag) {
2894
+ if (value === void 0) return void 0;
2895
+ const parsed = Number.parseInt(value, 10);
2896
+ if (!Number.isInteger(parsed) || parsed <= 0) {
2897
+ throw new Error(`${flag} \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF0C\u6536\u5230 "${value}"`);
2898
+ }
2899
+ return parsed;
2900
+ }
2901
+ async function findGame(games, selector) {
2902
+ return games.find((game2) => game2.id === selector || game2.name === selector || game2.connection?.roomId === selector);
2903
+ }
2904
+ function printWorker(game2, hostUrl) {
2905
+ console.log(`${workerMark(game2.worker.state)} ${chalk6.bold(game2.id)} ${game2.name} ${workerLabel(game2.worker.state)}`);
2906
+ console.log(` \u76EE\u5F55: ${game2.dir}`);
2907
+ if (game2.worker.url) {
2908
+ const owner = game2.worker.owned ? chalk6.dim("host \u6258\u7BA1") : game2.worker.state === "external" ? chalk6.dim("\u5916\u90E8\u8FDB\u7A0B") : "";
2909
+ console.log(` Worker: ${game2.worker.url}${game2.worker.pid ? ` \xB7 pid ${game2.worker.pid}` : ""} ${owner}`.trimEnd());
2910
+ }
2911
+ if (game2.worker.logFile) console.log(` \u65E5\u5FD7: ${game2.worker.logFile}`);
2912
+ if (game2.connected && game2.connection) {
2913
+ console.log(` \u623F\u95F4: ${game2.connection.roomId} \xB7 ${roomPhaseLabel(game2.room?.phase ?? "lobby")}`);
2914
+ console.log(` \u63A7\u5236\u53F0: ${roomConsoleUrl(hostUrl, game2.connection.roomId)}`);
2915
+ } else {
2916
+ console.log(" \u623F\u95F4: \u672A\u8FDE\u63A5");
2917
+ }
2918
+ if (game2.worker.error) console.log(` \u9519\u8BEF: ${chalk6.yellow(game2.worker.error)}`);
2919
+ if (game2.roomError) console.log(` \u623F\u95F4\u9519\u8BEF: ${chalk6.yellow(game2.roomError)}`);
2920
+ }
2921
+ async function gameStartCommand(selector, options) {
2922
+ const root = resolveRoot(options.root);
2923
+ try {
2924
+ const scan = await scanGames(root);
2925
+ const game2 = resolveGame(scan.games, selector);
2926
+ const { client } = await resolveHost(root, options.port);
2927
+ const worker = await client.startWorker(game2.id, {
2928
+ port: optionalInt(options.workerPort, "--worker-port")
2929
+ });
2930
+ if (options.json) {
2931
+ console.log(JSON.stringify(worker, null, 2));
2932
+ return;
2933
+ }
2934
+ console.log("");
2935
+ if (worker.worker.state === "external") {
2936
+ console.log(`\u2139\uFE0F ${chalk6.bold(game2.name)} \u7684 Worker \u5DF2\u7ECF\u5728\u8FD0\u884C\uFF0C\u4F46\u4E0D\u662F\u7531 host \u542F\u52A8\u7684\u3002`);
2937
+ } else {
2938
+ console.log(`\u2705 ${chalk6.bold(game2.name)} (${game2.id}) \u7684 Worker \u5DF2\u542F\u52A8`);
2939
+ }
2940
+ if (worker.worker.url) console.log(` URL: ${worker.worker.url}`);
2941
+ if (worker.worker.pid) console.log(` PID: ${worker.worker.pid}`);
2942
+ if (worker.worker.logFile) console.log(` \u65E5\u5FD7: ${worker.worker.logFile}`);
2943
+ console.log("");
2944
+ console.log(`\u4E0B\u4E00\u6B65: npgame-dev connect ${game2.id}`);
2945
+ console.log(` npgame-dev room start ${game2.id}`);
2946
+ if (worker.worker.owned) console.log(`\u505C\u6B62: npgame-dev game stop ${game2.id}`);
2947
+ } catch (error) {
2948
+ console.error(`\u274C ${errorMessage3(error)}`);
2949
+ process.exitCode = 1;
2950
+ }
2951
+ }
2952
+ async function gameStatusCommand(selector, options) {
2953
+ const root = resolveRoot(options.root);
2954
+ try {
2955
+ const { client, meta } = await resolveHost(root, options.port);
2956
+ const response = await client.listGames();
2957
+ if (selector) {
2958
+ const game2 = await findGame(response.games, selector);
2959
+ if (!game2) {
2960
+ throw new Error(`\u627E\u4E0D\u5230\u6E38\u620F\u6216\u623F\u95F4 "${selector}"`);
2961
+ }
2962
+ if (options.json) {
2963
+ console.log(JSON.stringify(game2, null, 2));
2964
+ return;
2965
+ }
2966
+ printWorker(game2, meta.url);
2967
+ return;
282
2968
  }
283
- } catch (e) {
284
- if (e.cause?.code === "ECONNREFUSED") {
285
- console.error(`\u274C \u65E0\u6CD5\u8FDE\u63A5\u5230\u5F15\u64CE (localhost:${port})\u3002\u8BF7\u5148\u8FD0\u884C: npx @nexusgame/dev start`);
2969
+ if (options.json) {
2970
+ console.log(JSON.stringify(response.games, null, 2));
2971
+ return;
2972
+ }
2973
+ if (response.games.length === 0) {
2974
+ console.log("\u6CA1\u6709\u53D1\u73B0\u6E38\u620F\u3002");
2975
+ return;
2976
+ }
2977
+ for (const [index, game2] of response.games.entries()) {
2978
+ if (index > 0) console.log("");
2979
+ printWorker(game2, meta.url);
2980
+ }
2981
+ } catch (error) {
2982
+ console.error(`\u274C ${errorMessage3(error)}`);
2983
+ process.exitCode = 1;
2984
+ }
2985
+ }
2986
+ async function gameStopCommand(selector, options) {
2987
+ const root = resolveRoot(options.root);
2988
+ try {
2989
+ const { client } = await resolveHost(root, options.port);
2990
+ const response = await client.listGames();
2991
+ let targets;
2992
+ if (options.all) {
2993
+ targets = response.games.filter((game2) => game2.worker.owned);
2994
+ } else if (selector) {
2995
+ const game2 = await findGame(response.games, selector);
2996
+ if (!game2) throw new Error(`\u627E\u4E0D\u5230\u6E38\u620F\u6216\u623F\u95F4 "${selector}"`);
2997
+ targets = [game2];
286
2998
  } else {
287
- console.error(`\u274C \u9519\u8BEF: ${e.message}`);
2999
+ console.error("\u274C \u8BF7\u6307\u5B9A\u6E38\u620F id\uFF0C\u6216\u4F7F\u7528 --all \u505C\u6B62\u5168\u90E8\u7531 host \u542F\u52A8\u7684 Worker");
3000
+ process.exitCode = 1;
3001
+ return;
3002
+ }
3003
+ if (targets.length === 0) {
3004
+ console.log("\u6CA1\u6709\u7531 host \u542F\u52A8\u7684 Worker\u3002");
3005
+ return;
3006
+ }
3007
+ let failed = 0;
3008
+ for (const game2 of targets) {
3009
+ if (!game2.worker.owned) {
3010
+ console.error(` ${game2.id}: \u4E0D\u662F\u7531 host \u542F\u52A8\u7684 Worker\uFF0C\u8DF3\u8FC7`);
3011
+ continue;
3012
+ }
3013
+ console.log(`\u{1F6D1} \u505C\u6B62 Worker ${chalk6.bold(game2.id)} ...`);
3014
+ try {
3015
+ const result = await client.stopWorker(game2.id, { force: options.force });
3016
+ console.log(` ${game2.id}: ${result.worker.state}`);
3017
+ } catch (error) {
3018
+ failed += 1;
3019
+ console.error(` ${game2.id}: ${errorMessage3(error)}`);
3020
+ }
3021
+ }
3022
+ if (failed > 0) process.exitCode = 1;
3023
+ } catch (error) {
3024
+ console.error(`\u274C ${errorMessage3(error)}`);
3025
+ process.exitCode = 1;
3026
+ }
3027
+ }
3028
+ async function gameLogsCommand(selector, options) {
3029
+ const root = resolveRoot(options.root);
3030
+ const lines = optionalInt(options.lines, "--lines") ?? 200;
3031
+ try {
3032
+ const scan = await scanGames(root);
3033
+ const game2 = resolveGame(scan.games, selector);
3034
+ const file = workerLogFile(root, game2.id);
3035
+ if (options.follow) {
3036
+ console.log(`\u{1F4DC} ${file}\uFF08Ctrl+C \u9000\u51FA\uFF09`);
3037
+ const stop = await followLogs([{ label: game2.id, file }], { initialLines: lines });
3038
+ await new Promise((resolve) => {
3039
+ process.once("SIGINT", () => {
3040
+ stop();
3041
+ resolve();
3042
+ });
3043
+ process.once("SIGTERM", () => {
3044
+ stop();
3045
+ resolve();
3046
+ });
3047
+ });
3048
+ return;
3049
+ }
3050
+ const text = await tailFile(file, lines);
3051
+ if (!text.trim()) {
3052
+ console.log(`(\u65E5\u5FD7\u4E3A\u7A7A: ${file})`);
3053
+ console.log(`\u5148\u8FD0\u884C npgame-dev game start ${game2.id}`);
3054
+ return;
3055
+ }
3056
+ process.stdout.write(text.endsWith("\n") ? text : `${text}
3057
+ `);
3058
+ } catch (error) {
3059
+ console.error(`\u274C ${errorMessage3(error)}`);
3060
+ process.exitCode = 1;
3061
+ }
3062
+ }
3063
+
3064
+ // src/commands/host.ts
3065
+ import path17 from "path";
3066
+ function optionalInt2(value, flag) {
3067
+ if (value === void 0) return void 0;
3068
+ const parsed = Number.parseInt(value, 10);
3069
+ if (!Number.isInteger(parsed) || parsed <= 0) {
3070
+ throw new Error(`${flag} \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF0C\u6536\u5230 "${value}"`);
3071
+ }
3072
+ return parsed;
3073
+ }
3074
+ async function hostLogsCommand(options) {
3075
+ const root = path17.resolve(options.root ?? process.cwd());
3076
+ const file = hostLogFile(root);
3077
+ const lines = optionalInt2(options.lines, "--lines") ?? 200;
3078
+ try {
3079
+ if (options.follow) {
3080
+ console.log(`\u{1F4DC} ${file}\uFF08Ctrl+C \u9000\u51FA\uFF09`);
3081
+ const stop = await followLogs([{ label: "host", file }], { initialLines: lines });
3082
+ await new Promise((resolve) => {
3083
+ process.once("SIGINT", () => {
3084
+ stop();
3085
+ resolve();
3086
+ });
3087
+ process.once("SIGTERM", () => {
3088
+ stop();
3089
+ resolve();
3090
+ });
3091
+ });
3092
+ return;
3093
+ }
3094
+ const text = await tailFile(file, lines);
3095
+ if (!text.trim()) {
3096
+ console.log(`(\u65E5\u5FD7\u4E3A\u7A7A: ${file})`);
3097
+ console.log("\u5148\u8FD0\u884C npgame-dev serve");
3098
+ return;
288
3099
  }
289
- process.exit(1);
3100
+ process.stdout.write(text.endsWith("\n") ? text : `${text}
3101
+ `);
3102
+ } catch (error) {
3103
+ console.error(`\u274C ${errorMessage3(error)}`);
3104
+ process.exitCode = 1;
290
3105
  }
291
3106
  }
292
3107
 
293
3108
  // src/index.ts
294
3109
  var program = new Command();
295
- program.name("npgame-dev").description("\u661F\u67A2\u6C99\u76D2 \u2014 \u672C\u5730\u6E38\u620F\u5F00\u53D1\u8C03\u8BD5\u5DE5\u5177").version("0.2.0");
296
- program.command("start").description("\u542F\u52A8\u672C\u5730\u5F15\u64CE (\u524D\u53F0\u8FD0\u884C, Ctrl+C \u505C\u6B62)").option("-p, --port <port>", "\u5F15\u64CE\u7AEF\u53E3", "56512").action(startCommand);
297
- program.command("setup").description("\u521B\u5EFA\u623F\u95F4 + \u8BBE\u7F6E\u6E38\u620F + \u5F00\u59CB (\u4E00\u6761\u9F99)").requiredOption("-w, --worker-url <url>", "Game Worker URL (\u5982 http://localhost:8788)").option("-p, --port <port>", "\u5F15\u64CE\u7AEF\u53E3", "56512").option("-n, --players <count>", "\u6307\u5B9A\u53C2\u4E0E\u6E38\u620F\u7684\u4EBA\u6570 (\u9002\u7528\u4E8EUNO\u7B49\u53EF\u9009\u4EBA\u6570\u6E38\u620F)").action(setupCommand);
298
- program.command("state").description("\u67E5\u770B\u623F\u95F4\u5168\u5C40\u72B6\u6001").option("-p, --port <port>", "\u5F15\u64CE\u7AEF\u53E3", "56512").action(stateCommand);
299
- program.command("perspective").description("\u67E5\u770B\u6307\u5B9A\u89D2\u8272\u7684\u89C6\u89D2").argument("<roleId>", "\u89D2\u8272 ID").option("-p, --port <port>", "\u5F15\u64CE\u7AEF\u53E3", "56512").action(perspectiveCommand);
300
- program.command("action").description("\u4EE5\u6307\u5B9A\u89D2\u8272\u6267\u884C\u884C\u52A8").argument("<roleId>", "\u89D2\u8272 ID").argument("<actionJson>", `\u884C\u52A8 JSON (\u5982 '{"action_id":"place","params":{"x":7,"y":7}}')`).option("-p, --port <port>", "\u5F15\u64CE\u7AEF\u53E3", "56512").action(actionCommand);
301
- program.parse();
3110
+ program.name("npgame-dev").description("\u661F\u67A2\u6C99\u76D2 \u2014 \u672C\u5730\u6E38\u620F\u5F00\u53D1\u8C03\u8BD5\u5DE5\u5177").version("0.4.0");
3111
+ program.command("serve").description("\u542F\u52A8\u5E38\u9A7B Workspace host\uFF08\u7BA1\u7406\u9875 + \u5171\u4EAB\u5F15\u64CE\uFF0C\u9700\u663E\u5F0F\u542F\u52A8\uFF09").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host / \u7BA1\u7406\u9875\u7AEF\u53E3 (\u9ED8\u8BA4 56512)").option("--engine-port <port>", "\u5F15\u64CE\u5185\u90E8\u7AEF\u53E3 (\u9ED8\u8BA4 Host \u7AEF\u53E3 + 1)").action(serveCommand);
3112
+ var game = program.command("game").description("\u6E38\u620F\u9879\u76EE\u4E0E\u672C\u5730 Worker \u8FDB\u7A0B");
3113
+ game.command("ls").description("\u626B\u63CF\u5DE5\u4F5C\u533A\u4E2D\u7684\u6E38\u620F\u548C Worker \u72B6\u6001").option("-r, --root <dir>", "\u626B\u63CF\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(gameLsCommand);
3114
+ game.command("init").description("\u521D\u59CB\u5316\u6E38\u620F\u9879\u76EE\uFF1A\u5B89\u88C5\u4F9D\u8D56\u5E76\u6784\u5EFA UI \u8D44\u6E90").argument("<game>", "\u6E38\u620F id\u3001\u540D\u79F0\u6216\u76EE\u5F55").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("--no-install", "\u8DF3\u8FC7\u4F9D\u8D56\u5B89\u88C5").option("--no-build", "\u8DF3\u8FC7\u6784\u5EFA").option("-f, --force", "\u5F3A\u5236\u91CD\u65B0\u5B89\u88C5\u4F9D\u8D56\u5E76\u91CD\u65B0\u6784\u5EFA").action(gameInitCommand);
3115
+ game.command("start").description("\u5728\u6E38\u620F\u76EE\u5F55\u542F\u52A8\u672C\u5730 Worker\uFF08\u5982 pnpm dev\uFF09").argument("<game>", "\u6E38\u620F id\u3001\u540D\u79F0\u6216\u76EE\u5F55").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--worker-port <port>", "Worker \u7AEF\u53E3 (\u9ED8\u8BA4\u53D6 wrangler \u914D\u7F6E\u6216 8787)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(gameStartCommand);
3116
+ game.command("status").description("\u67E5\u770B\u6E38\u620F Worker \u72B6\u6001").argument("[game]", "\u6E38\u620F id\u3001\u540D\u79F0\u6216\u623F\u95F4 id (\u7701\u7565\u5219\u5217\u51FA\u5168\u90E8)").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(gameStatusCommand);
3117
+ game.command("stop").description("\u505C\u6B62\u7531 host \u542F\u52A8\u7684 Worker\uFF08\u4E0D\u4F1A\u505C\u6B62\u623F\u95F4\uFF09").argument("[game]", "\u6E38\u620F id\u3001\u540D\u79F0\u6216\u623F\u95F4 id").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--all", "\u505C\u6B62\u5168\u90E8\u7531 host \u542F\u52A8\u7684 Worker").option("--force", "\u5373\u4F7F\u623F\u95F4\u4ECD\u5728\u8FD0\u884C\u4E5F\u505C\u6B62 Worker").action(gameStopCommand);
3118
+ game.command("logs").description("\u67E5\u770B\u6E38\u620F Worker \u65E5\u5FD7").argument("<game>", "\u6E38\u620F id\u3001\u540D\u79F0\u6216\u76EE\u5F55").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-n, --lines <count>", "\u663E\u793A\u6700\u540E N \u884C", "200").option("-f, --follow", "\u6301\u7EED\u8DDF\u968F\u8F93\u51FA").action(gameLogsCommand);
3119
+ program.command("connect").description("\u628A\u6E38\u620F\u7ED1\u5B9A\u5230\u623F\u95F4\u914D\u7F6E (\u9700\u8981 serve\uFF0C\u4E0D\u542F\u52A8 Worker)").argument("<game>", "\u6E38\u620F id\u3001\u540D\u79F0\u6216\u76EE\u5F55").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("-w, --worker-url <url>", "Game Worker URL (\u9ED8\u8BA4\u4F7F\u7528\u8FD0\u884C\u4E2D\u7684 Worker)").option("-n, --players <count>", "\u53C2\u4E0E\u4EBA\u6570 (\u9002\u7528\u4E8E UNO \u7B49\u53EF\u9009\u4EBA\u6570\u6E38\u620F)").option("--room <roomId>", "\u623F\u95F4 id (\u9ED8\u8BA4\u6E38\u620F id)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(connectCommand);
3120
+ program.command("disconnect").description("\u505C\u6B62\u623F\u95F4\u5E76\u79FB\u9664\u7ED1\u5B9A (\u4E0D\u505C\u6B62 Worker)").argument("<game|room>", "\u623F\u95F4 id \u6216\u6E38\u620F id").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(disconnectCommand);
3121
+ var room = program.command("room").description("\u542F\u52A8\u3001\u505C\u6B62\u548C\u67E5\u770B\u623F\u95F4");
3122
+ room.command("start").description("\u5728\u5F53\u524D\u623F\u95F4\u914D\u7F6E\u4E0A\u5206\u914D dev \u73A9\u5BB6\u5E76\u5F00\u59CB\u6E38\u620F").argument("<room>", "\u623F\u95F4 id \u6216\u6E38\u620F id").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(roomStartCommand);
3123
+ room.command("stop").description("\u505C\u6B62\u623F\u95F4\u4E2D\u7684\u6E38\u620F (\u4E0D\u505C\u6B62 Worker)").argument("<room>", "\u623F\u95F4 id \u6216\u6E38\u620F id").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(roomStopCommand);
3124
+ room.command("status").description("\u67E5\u770B\u5DF2\u8FDE\u63A5\u623F\u95F4\u7684\u72B6\u6001").argument("[room]", "\u623F\u95F4 id \u6216\u6E38\u620F id (\u7701\u7565\u5219\u5217\u51FA\u5168\u90E8)").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--json", "\u4EE5 JSON \u8F93\u51FA").action(roomStatusCommand);
3125
+ var host = program.command("host").description("Workspace host \u65E5\u5FD7");
3126
+ host.command("logs").description("\u67E5\u770B host \u65E5\u5FD7").option("-r, --root <dir>", "\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 (\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55)").option("-n, --lines <count>", "\u663E\u793A\u6700\u540E N \u884C", "200").option("-f, --follow", "\u6301\u7EED\u8DDF\u968F\u8F93\u51FA").action(hostLogsCommand);
3127
+ program.command("state").description("\u67E5\u770B\u623F\u95F4\u5168\u5C40\u72B6\u6001").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--room <roomId>", "\u623F\u95F4 id (\u4EC5\u4E00\u4E2A\u5DF2\u8FDE\u63A5\u623F\u95F4\u65F6\u53EF\u7701\u7565)").action(stateCommand);
3128
+ program.command("perspective").description("\u67E5\u770B\u6307\u5B9A\u89D2\u8272\u7684\u89C6\u89D2").argument("<roleId>", "\u89D2\u8272 ID").option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--room <roomId>", "\u623F\u95F4 id (\u4EC5\u4E00\u4E2A\u5DF2\u8FDE\u63A5\u623F\u95F4\u65F6\u53EF\u7701\u7565)").action(perspectiveCommand);
3129
+ program.command("action").description("\u4EE5\u6307\u5B9A\u89D2\u8272\u6267\u884C\u884C\u52A8").argument("<roleId>", "\u89D2\u8272 ID").argument("<actionJson>", `\u884C\u52A8 JSON (\u5982 '{"action_id":"place","params":{"x":7,"y":7}}')`).option("-p, --port <port>", "Host \u7AEF\u53E3 (\u9ED8\u8BA4\u8BFB\u53D6 .nexus/host.json)").option("--room <roomId>", "\u623F\u95F4 id (\u4EC5\u4E00\u4E2A\u5DF2\u8FDE\u63A5\u623F\u95F4\u65F6\u53EF\u7701\u7565)").action(actionCommand);
3130
+ try {
3131
+ await program.parseAsync();
3132
+ } catch (error) {
3133
+ console.error(`\u274C ${errorMessage3(error)}`);
3134
+ process.exitCode = 1;
3135
+ }