@myclaw163/clawclaw-cli 0.6.56 → 0.6.58
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/bin/clawclaw-cli.mjs +3 -3
- package/package.json +1 -1
- package/scripts/sync-bundled-skill.mjs +1 -1
- package/skills/clawclaw/SKILL.md +7 -3
- package/skills/clawclaw/references/GAME-MECHANICS.md +5 -5
- package/skills/clawclaw/references/STREAM.md +4 -3
- package/src/cli.ts +0 -43
- package/src/commands/config.ts +30 -30
- package/src/commands/game-start-plan.test.ts +10 -68
- package/src/commands/game.ts +318 -173
- package/src/commands/history.ts +1 -1
- package/src/commands/peek.ts +16 -9
- package/src/commands/setup/codex.ts +248 -248
- package/src/commands/setup/hermes.test.ts +96 -96
- package/src/commands/setup/hermes.ts +76 -76
- package/src/commands/setup/index.ts +13 -13
- package/src/commands/setup/openclaw.test.ts +114 -114
- package/src/commands/setup/openclaw.ts +147 -147
- package/src/commands/strategy.ts +29 -38
- package/src/commands/watch.test.ts +7 -11
- package/src/commands/watch.ts +77 -66
- package/src/lib/game-client.ts +3 -3
- package/src/lib/host-config-patcher.test.ts +130 -130
- package/src/lib/host-config-patcher.ts +151 -151
- package/src/lib/hub-reminder.ts +19 -19
- package/src/lib/strategy-export.test.ts +1 -1
- package/src/runtime/event-daemon.test.ts +81 -2
- package/src/runtime/event-daemon.ts +325 -287
- package/src/runtime/owner-control.ts +150 -0
- package/src/runtime/runtime-logger.ts +11 -3
- package/src/runtime/ws-client.test.ts +57 -0
- package/src/runtime/ws-client.ts +2 -2
- package/src/sdk/index.ts +4 -4
- package/src/strategies/game-utils.test.ts +27 -1
- package/src/strategies/game-utils.ts +27 -4
- package/src/strategies/goals/crab-octopus-reflexes.ts +4 -4
- package/src/strategies/goals/crab-sabotage-top.ts +1 -1
- package/src/strategies/goals/keep-away-goal.ts +10 -7
- package/src/strategies/goals/kill-frenzy-top.ts +5 -5
- package/src/strategies/goals/kill-target-goal.ts +2 -2
- package/src/strategies/goals/kill-target-top.ts +2 -2
- package/src/strategies/goals/lone-kill-core.ts +3 -3
- package/src/strategies/goals/lone-kill-task-top.ts +1 -1
- package/src/strategies/goals/task-kill-report-top.ts +3 -3
- package/src/strategies/goals/warrior-shrimp-top.ts +2 -2
- package/src/strategies/pathfind/escape-planner.ts +10 -3
- package/src/strategies/spawn.ts +16 -5
- package/src/strategies/strategy-loop.ts +9 -3
- package/src/runtime/daemon.ts +0 -100
- package/src/runtime/opening-mover.ts +0 -303
package/src/commands/game.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
+
import { spawnSync, type ChildProcess } from 'child_process';
|
|
4
5
|
import { AuthStore } from '../lib/auth.js';
|
|
5
6
|
import { ApiError, GameClient } from '../lib/game-client.js';
|
|
6
7
|
import { getProfileStateDir } from '../lib/init-command.js';
|
|
7
|
-
import { spawnDaemon, getRunningDaemonPid, waitDaemonExit } from '../runtime/daemon.js';
|
|
8
8
|
import { EventStore } from '../pipeline/event-store.js';
|
|
9
|
-
import { spawnOpeningMover, stopOpeningMoverIfRunning } from '../runtime/opening-mover.js';
|
|
10
9
|
import { spawnStrategyLoop } from '../strategies/spawn.js';
|
|
10
|
+
import { stopStrategyIfRunning } from '../strategies/strategy-loop.js';
|
|
11
11
|
import { setMeta } from '../lib/command-meta.js';
|
|
12
|
-
import { runStreaming, buildErrorLine,
|
|
12
|
+
import { runStreaming, buildErrorLine, summarizeFeed, nextStepFor } from './watch.js';
|
|
13
13
|
import { hubReminder, readCachedGamesPlayed } from '../lib/hub-reminder.js';
|
|
14
|
+
import { EventRuntime } from '../runtime/event-daemon.js';
|
|
15
|
+
import {
|
|
16
|
+
gameStartRuntimePath,
|
|
17
|
+
readGameStartRuntime,
|
|
18
|
+
sendOwnerControlRequest,
|
|
19
|
+
startOwnerControlServer,
|
|
20
|
+
type OwnerControlInfo,
|
|
21
|
+
type OwnerControlServer,
|
|
22
|
+
} from '../runtime/owner-control.js';
|
|
14
23
|
import {
|
|
15
24
|
startMatch,
|
|
16
25
|
endMatch,
|
|
@@ -21,10 +30,6 @@ import {
|
|
|
21
30
|
hasMatchTimedOut,
|
|
22
31
|
} from '../lib/match-state.js';
|
|
23
32
|
|
|
24
|
-
function writeControl(stateDir: string, command: 'stop'): void {
|
|
25
|
-
writeFileSync(join(stateDir, 'control.json'), JSON.stringify({ command }));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
33
|
function sleep(ms: number): Promise<void> {
|
|
29
34
|
return new Promise(r => setTimeout(r, ms));
|
|
30
35
|
}
|
|
@@ -39,52 +44,141 @@ function queueStatus(result: any): string | undefined {
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
const DEFAULT_QUEUE_WAIT_TIMEOUT_SECS = 30;
|
|
42
|
-
const GAME_START_RUNTIME_FILE = 'game-start.json';
|
|
43
47
|
|
|
44
48
|
function isPidAlive(pid: number): boolean {
|
|
45
49
|
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
|
|
46
50
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
function gameStartRuntimePath(stateDir: string): string {
|
|
50
|
-
return join(stateDir, GAME_START_RUNTIME_FILE);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
53
|
function getRunningGameStartPid(stateDir: string): number | null {
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (isPidAlive(pid)) return pid;
|
|
60
|
-
} catch {}
|
|
61
|
-
try { unlinkSync(runtimePath); } catch {}
|
|
54
|
+
const info = readGameStartRuntime(stateDir);
|
|
55
|
+
if (!info) return null;
|
|
56
|
+
const pid = Number(info?.owner_pid ?? info?.pid);
|
|
57
|
+
if (isPidAlive(pid)) return pid;
|
|
58
|
+
try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
|
|
62
59
|
return null;
|
|
63
60
|
}
|
|
64
61
|
|
|
65
|
-
function writeGameStartRuntime(
|
|
62
|
+
function writeGameStartRuntime(
|
|
63
|
+
stateDir: string,
|
|
64
|
+
mode: GameStartPlanKind,
|
|
65
|
+
phase?: string,
|
|
66
|
+
controlOverride?: { control: OwnerControlInfo; token: string },
|
|
67
|
+
): void {
|
|
66
68
|
mkdirSync(stateDir, { recursive: true });
|
|
69
|
+
let startedAt = new Date().toISOString();
|
|
70
|
+
let existingControl: OwnerControlInfo | undefined;
|
|
71
|
+
let existingToken: string | undefined;
|
|
72
|
+
try {
|
|
73
|
+
const existing = JSON.parse(readFileSync(gameStartRuntimePath(stateDir), 'utf8'));
|
|
74
|
+
const existingPid = Number(existing?.owner_pid ?? existing?.pid);
|
|
75
|
+
if (existingPid === process.pid && typeof existing?.started_at === 'string') {
|
|
76
|
+
startedAt = existing.started_at;
|
|
77
|
+
if (existing?.control?.path) existingControl = existing.control;
|
|
78
|
+
if (typeof existing?.control_token === 'string') existingToken = existing.control_token;
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
const control = controlOverride?.control ?? existingControl;
|
|
82
|
+
const controlToken = controlOverride?.token ?? existingToken;
|
|
67
83
|
writeFileSync(gameStartRuntimePath(stateDir), JSON.stringify({
|
|
84
|
+
schema: 3,
|
|
85
|
+
owner_pid: process.pid,
|
|
68
86
|
pid: process.pid,
|
|
69
|
-
started_at:
|
|
87
|
+
started_at: startedAt,
|
|
88
|
+
heartbeat_at: new Date().toISOString(),
|
|
70
89
|
mode,
|
|
71
|
-
|
|
90
|
+
...(control ? { control } : {}),
|
|
91
|
+
...(controlToken ? { control_token: controlToken } : {}),
|
|
92
|
+
...(phase ? { phase } : {}),
|
|
93
|
+
}, null, 2));
|
|
72
94
|
}
|
|
73
95
|
|
|
74
|
-
function
|
|
96
|
+
function startGameStartHeartbeat(stateDir: string, mode: GameStartPlanKind): ReturnType<typeof setInterval> {
|
|
97
|
+
return setInterval(() => {
|
|
98
|
+
try {
|
|
99
|
+
writeGameStartRuntime(stateDir, mode);
|
|
100
|
+
} catch {}
|
|
101
|
+
}, 5000);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cleanupGameStartRuntime(stateDir: string, opts: { removeFeed?: boolean; controlPath?: string } = {}): void {
|
|
75
105
|
const runtimePath = gameStartRuntimePath(stateDir);
|
|
76
106
|
try {
|
|
77
107
|
const info = JSON.parse(readFileSync(runtimePath, 'utf8'));
|
|
78
|
-
|
|
108
|
+
const pid = Number(info?.owner_pid ?? info?.pid);
|
|
109
|
+
if (pid !== process.pid) return;
|
|
79
110
|
} catch {}
|
|
80
111
|
try { unlinkSync(runtimePath); } catch {}
|
|
112
|
+
if (opts.removeFeed) {
|
|
113
|
+
try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
|
|
114
|
+
}
|
|
115
|
+
if (opts.controlPath && process.platform !== 'win32') {
|
|
116
|
+
try { unlinkSync(opts.controlPath); } catch {}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function terminateProcessTree(pid: number, signal: NodeJS.Signals = 'SIGTERM'): boolean {
|
|
121
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
|
|
122
|
+
if (process.platform === 'win32') {
|
|
123
|
+
const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
124
|
+
return result.status === 0;
|
|
125
|
+
}
|
|
126
|
+
const pkillSignal = signal === 'SIGKILL' ? '-KILL' : '-TERM';
|
|
127
|
+
try { spawnSync('pkill', [pkillSignal, '-P', String(pid)], { stdio: 'ignore' }); } catch {}
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, signal);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function waitPidExit(pid: number, timeoutMs = 5000): Promise<boolean> {
|
|
137
|
+
const deadline = Date.now() + timeoutMs;
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
if (!isPidAlive(pid)) return true;
|
|
140
|
+
await sleep(200);
|
|
141
|
+
}
|
|
142
|
+
return !isPidAlive(pid);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function stopOwnerIfRunning(stateDir: string, timeoutMs = 5000): Promise<{ pid: number | null; stopped: boolean }> {
|
|
146
|
+
return stopOwnerWithCommand(stateDir, 'stop', timeoutMs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function stopOwnerWithCommand(
|
|
150
|
+
stateDir: string,
|
|
151
|
+
command: 'stop' | 'quit' | 'leave',
|
|
152
|
+
timeoutMs = 5000,
|
|
153
|
+
): Promise<{ pid: number | null; stopped: boolean }> {
|
|
154
|
+
const pid = getRunningGameStartPid(stateDir);
|
|
155
|
+
if (!pid) {
|
|
156
|
+
try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
|
|
157
|
+
try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
|
|
158
|
+
return { pid: null, stopped: false };
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const response = await sendOwnerControlRequest(stateDir, command);
|
|
162
|
+
if (response?.ok) {
|
|
163
|
+
const exited = await waitPidExit(pid, timeoutMs);
|
|
164
|
+
if (exited) return { pid, stopped: true };
|
|
165
|
+
}
|
|
166
|
+
} catch {}
|
|
167
|
+
terminateProcessTree(pid, 'SIGTERM');
|
|
168
|
+
const exited = await waitPidExit(pid, timeoutMs);
|
|
169
|
+
if (!exited) {
|
|
170
|
+
terminateProcessTree(pid, 'SIGKILL');
|
|
171
|
+
await waitPidExit(pid, 2000);
|
|
172
|
+
}
|
|
173
|
+
try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
|
|
174
|
+
try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
|
|
175
|
+
return { pid, stopped: true };
|
|
81
176
|
}
|
|
82
177
|
|
|
83
178
|
type QueueStatus = 'allocated' | 'queued' | 'not_in_queue' | string | undefined;
|
|
84
179
|
|
|
85
180
|
export type GameStartPlan =
|
|
86
181
|
| { kind: 'already_running'; pid: number }
|
|
87
|
-
| { kind: 'attach_daemon' }
|
|
88
182
|
| { kind: 'resume_queue' }
|
|
89
183
|
| { kind: 'resume_allocated' }
|
|
90
184
|
| { kind: 'fresh_start' };
|
|
@@ -93,21 +187,14 @@ type GameStartPlanKind = GameStartPlan['kind'];
|
|
|
93
187
|
|
|
94
188
|
export function planGameStartAction(input: {
|
|
95
189
|
gameStartPid?: number | null;
|
|
96
|
-
daemonPid?: number | null;
|
|
97
190
|
hasMatchState: boolean;
|
|
98
191
|
queueStatus?: QueueStatus;
|
|
99
|
-
feedPhase?: string | null;
|
|
100
192
|
}): GameStartPlan {
|
|
101
193
|
if (input.gameStartPid) return { kind: 'already_running', pid: input.gameStartPid };
|
|
102
194
|
|
|
103
|
-
if (input.daemonPid) {
|
|
104
|
-
if (input.hasMatchState || input.feedPhase === 'matching') return { kind: 'resume_queue' };
|
|
105
|
-
if (input.feedPhase === 'lobby') return { kind: 'fresh_start' };
|
|
106
|
-
return { kind: 'attach_daemon' };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
195
|
if (input.queueStatus === 'allocated') return { kind: 'resume_allocated' };
|
|
110
196
|
if (input.queueStatus === 'queued' || input.queueStatus === 'already_in_queue') return { kind: 'resume_queue' };
|
|
197
|
+
if (input.hasMatchState && input.queueStatus !== 'not_in_queue') return { kind: 'resume_queue' };
|
|
111
198
|
return { kind: 'fresh_start' };
|
|
112
199
|
}
|
|
113
200
|
|
|
@@ -127,15 +214,6 @@ function ensureEventSession(source: string): EventStore {
|
|
|
127
214
|
return events;
|
|
128
215
|
}
|
|
129
216
|
|
|
130
|
-
function readFeedPhase(feedPath: string): string | null {
|
|
131
|
-
try {
|
|
132
|
-
const phase = JSON.parse(readFileSync(feedPath, 'utf8'))?.phase;
|
|
133
|
-
return typeof phase === 'string' ? phase : null;
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
217
|
function nonEmptyString(value: unknown): string | undefined {
|
|
140
218
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
141
219
|
}
|
|
@@ -150,15 +228,6 @@ export interface GameStrategyIdentity {
|
|
|
150
228
|
alive: boolean | null;
|
|
151
229
|
}
|
|
152
230
|
|
|
153
|
-
export interface StrategyRuntimeInfo {
|
|
154
|
-
strategy?: string;
|
|
155
|
-
pid?: number;
|
|
156
|
-
source?: string;
|
|
157
|
-
gameId?: string;
|
|
158
|
-
role?: string;
|
|
159
|
-
running: boolean;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
231
|
export function gameStrategyIdentity(stateData: any, roleData: any): GameStrategyIdentity {
|
|
163
232
|
const state = unwrapData(stateData);
|
|
164
233
|
const role = unwrapData(roleData);
|
|
@@ -171,33 +240,6 @@ export function gameStrategyIdentity(stateData: any, roleData: any): GameStrateg
|
|
|
171
240
|
};
|
|
172
241
|
}
|
|
173
242
|
|
|
174
|
-
export function strategyRuntimeMatchesIdentity(
|
|
175
|
-
runtime: StrategyRuntimeInfo | null,
|
|
176
|
-
identity: GameStrategyIdentity,
|
|
177
|
-
): boolean {
|
|
178
|
-
if (!runtime?.running) return false;
|
|
179
|
-
if (!runtime.gameId || !identity.gameId || runtime.gameId !== identity.gameId) return false;
|
|
180
|
-
if (runtime.role && identity.role && runtime.role !== identity.role) return false;
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function readStrategyRuntimeInfo(stateDir: string): StrategyRuntimeInfo | null {
|
|
185
|
-
try {
|
|
186
|
-
const raw = JSON.parse(readFileSync(join(stateDir, 'auto.json'), 'utf8'));
|
|
187
|
-
const pid = Number(raw?.pid);
|
|
188
|
-
return {
|
|
189
|
-
strategy: nonEmptyString(raw?.strategy),
|
|
190
|
-
pid: Number.isFinite(pid) ? pid : undefined,
|
|
191
|
-
source: nonEmptyString(raw?.source),
|
|
192
|
-
gameId: nonEmptyString(raw?.game_id) ?? nonEmptyString(raw?.gameId),
|
|
193
|
-
role: nonEmptyString(raw?.role),
|
|
194
|
-
running: Number.isFinite(pid) && pid > 0 ? isPidAlive(pid) : false,
|
|
195
|
-
};
|
|
196
|
-
} catch {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
243
|
function commandError(command: string, err: unknown): { command: string; error: string } {
|
|
202
244
|
const message = err instanceof ApiError
|
|
203
245
|
? `${err.status}: ${err.body}`
|
|
@@ -294,7 +336,7 @@ const ROLE_DEFAULT_STRATEGY: Record<string, string> = {
|
|
|
294
336
|
'neutral_octopus': 'lone-kill-task',
|
|
295
337
|
};
|
|
296
338
|
|
|
297
|
-
function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { strategy: string; pid: number | undefined } | null {
|
|
339
|
+
function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { strategy: string; pid: number | undefined; child: ChildProcess } | null {
|
|
298
340
|
const roleId: string | undefined = roleData?.data?.role ?? roleData?.role;
|
|
299
341
|
if (!roleId) return null;
|
|
300
342
|
const strategyId = ROLE_DEFAULT_STRATEGY[roleId];
|
|
@@ -303,8 +345,10 @@ function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { s
|
|
|
303
345
|
source: 'auto_start',
|
|
304
346
|
gameId: gameId ?? gameStrategyIdentity(stateData, roleData).gameId,
|
|
305
347
|
role: roleId,
|
|
348
|
+
detached: false,
|
|
349
|
+
writeRuntimeFiles: false,
|
|
306
350
|
});
|
|
307
|
-
return { strategy: strategyId, pid: child.pid };
|
|
351
|
+
return { strategy: strategyId, pid: child.pid, child };
|
|
308
352
|
}
|
|
309
353
|
|
|
310
354
|
async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<void> {
|
|
@@ -323,6 +367,58 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
323
367
|
const stateDir = getProfileStateDir(profile);
|
|
324
368
|
const feedPath = join(stateDir, 'feed.json');
|
|
325
369
|
const client = GameClient.fromAuth();
|
|
370
|
+
let eventRuntime: EventRuntime | undefined;
|
|
371
|
+
let streamAbortController: AbortController | null = null;
|
|
372
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
373
|
+
let ownerControl: OwnerControlServer | null = null;
|
|
374
|
+
let ownerFeed: any = {
|
|
375
|
+
ts: new Date().toISOString(),
|
|
376
|
+
phase: 'matching',
|
|
377
|
+
terminal: false,
|
|
378
|
+
you: { name: profile.agentName },
|
|
379
|
+
game: {},
|
|
380
|
+
urgent: {},
|
|
381
|
+
meeting: null,
|
|
382
|
+
recent_events: [],
|
|
383
|
+
};
|
|
384
|
+
let strategyChild: ChildProcess | null = null;
|
|
385
|
+
let currentStrategy: string | null = null;
|
|
386
|
+
const currentAutomationSummary = (): Record<string, any> | undefined => {
|
|
387
|
+
if (!currentStrategy) return undefined;
|
|
388
|
+
const pid = strategyChild?.pid;
|
|
389
|
+
return { strategy: currentStrategy, running: !!(pid && isPidAlive(pid)) };
|
|
390
|
+
};
|
|
391
|
+
const currentSummary = (): any | null => {
|
|
392
|
+
const runtimeFeed = eventRuntime?.snapshot();
|
|
393
|
+
const feed = runtimeFeed && runtimeFeed.phase !== 'lobby' ? runtimeFeed : ownerFeed;
|
|
394
|
+
const automation = currentAutomationSummary();
|
|
395
|
+
return summarizeFeed(automation ? { ...feed, automation } : feed);
|
|
396
|
+
};
|
|
397
|
+
let manualExitEmitted = false;
|
|
398
|
+
let ownerExitRequested = false;
|
|
399
|
+
const stopOwnedStrategy = (): void => {
|
|
400
|
+
const child = strategyChild;
|
|
401
|
+
strategyChild = null;
|
|
402
|
+
currentStrategy = null;
|
|
403
|
+
if (child?.pid && isPidAlive(child.pid)) {
|
|
404
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
if (child.pid && isPidAlive(child.pid)) {
|
|
407
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
408
|
+
}
|
|
409
|
+
}, 1000).unref();
|
|
410
|
+
}
|
|
411
|
+
eventRuntime?.refreshFeed();
|
|
412
|
+
};
|
|
413
|
+
const onOwnerSignal = (): void => {
|
|
414
|
+
streamAbortController?.abort();
|
|
415
|
+
eventRuntime?.stop('SIGTERM');
|
|
416
|
+
stopOwnedStrategy();
|
|
417
|
+
cleanupGameStartRuntime(stateDir, { removeFeed: true, controlPath: ownerControl?.control.path });
|
|
418
|
+
process.exit(130);
|
|
419
|
+
};
|
|
420
|
+
process.on('SIGINT', onOwnerSignal);
|
|
421
|
+
process.on('SIGTERM', onOwnerSignal);
|
|
326
422
|
const emit = (obj: Record<string, any>): void => {
|
|
327
423
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
328
424
|
};
|
|
@@ -336,28 +432,53 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
336
432
|
exit_reason: [reason],
|
|
337
433
|
next_step: nextStepOverride ?? nextStepFor(reason),
|
|
338
434
|
events: [event],
|
|
339
|
-
summary:
|
|
435
|
+
summary: currentSummary(),
|
|
436
|
+
});
|
|
437
|
+
};
|
|
438
|
+
const emitOwnerExit = (kind: 'stop' | 'quit' | 'leave', command: string): void => {
|
|
439
|
+
if (manualExitEmitted) return;
|
|
440
|
+
manualExitEmitted = true;
|
|
441
|
+
const eventType = kind === 'leave' ? 'stop' : kind;
|
|
442
|
+
emit({
|
|
443
|
+
exit_reason: [eventType],
|
|
444
|
+
next_step: `Received ${command}. The current ccl game start process is exiting now.`,
|
|
445
|
+
events: [{ type: eventType, command }],
|
|
446
|
+
summary: { phase: 'stopped' },
|
|
340
447
|
});
|
|
341
448
|
};
|
|
449
|
+
const requestOwnerExit = (kind: 'stop' | 'quit' | 'leave', command: string): void => {
|
|
450
|
+
ownerExitRequested = true;
|
|
451
|
+
emitOwnerExit(kind, command);
|
|
452
|
+
streamAbortController?.abort();
|
|
453
|
+
eventRuntime?.stop('manual');
|
|
454
|
+
stopOwnedStrategy();
|
|
455
|
+
};
|
|
342
456
|
const emitMatchEvent = (evt: Record<string, any>): void => {
|
|
343
457
|
try {
|
|
344
458
|
const store = EventStore.forActiveAccount();
|
|
345
459
|
store.append({ ts: new Date().toISOString(), ...evt });
|
|
346
460
|
} catch {}
|
|
347
461
|
};
|
|
348
|
-
const
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
462
|
+
const ensureEventRuntime = async (): Promise<void> => {
|
|
463
|
+
if (eventRuntime) return;
|
|
464
|
+
eventRuntime = new EventRuntime({
|
|
465
|
+
authStore,
|
|
466
|
+
getAutomation: currentAutomationSummary,
|
|
467
|
+
onStop: (stop) => {
|
|
468
|
+
if (stop.reason === 'game_over') return;
|
|
469
|
+
streamAbortController?.abort();
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
await eventRuntime.start();
|
|
356
473
|
};
|
|
357
474
|
const streamGame = async (): Promise<void> => {
|
|
358
475
|
const sessionPath = EventStore.latestSessionPath();
|
|
359
476
|
const ctrl = new AbortController();
|
|
360
|
-
|
|
477
|
+
streamAbortController = ctrl;
|
|
478
|
+
const onSignal = (): void => {
|
|
479
|
+
ctrl.abort();
|
|
480
|
+
eventRuntime?.stop('SIGTERM');
|
|
481
|
+
};
|
|
361
482
|
process.on('SIGINT', onSignal);
|
|
362
483
|
process.on('SIGTERM', onSignal);
|
|
363
484
|
try {
|
|
@@ -367,6 +488,8 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
367
488
|
getSessionPath: () => EventStore.latestSessionPath(),
|
|
368
489
|
stdout: (s) => process.stdout.write(s),
|
|
369
490
|
signal: ctrl.signal,
|
|
491
|
+
skipFeedWait: true,
|
|
492
|
+
readSummary: currentSummary,
|
|
370
493
|
skipBacklogTypes: ['match_waiting', 'match_timeout'],
|
|
371
494
|
emitGameStart: true,
|
|
372
495
|
hubReminder: hubReminder(readCachedGamesPlayed()),
|
|
@@ -377,6 +500,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
377
500
|
} finally {
|
|
378
501
|
process.off('SIGINT', onSignal);
|
|
379
502
|
process.off('SIGTERM', onSignal);
|
|
503
|
+
streamAbortController = null;
|
|
380
504
|
}
|
|
381
505
|
};
|
|
382
506
|
const handleAllocated = async (queue: any, preserveStrategy: boolean): Promise<void> => {
|
|
@@ -390,21 +514,31 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
390
514
|
if (!identity.gameId && queueGameId) {
|
|
391
515
|
identity.gameId = queueGameId;
|
|
392
516
|
}
|
|
517
|
+
const role = unwrapData(context.role);
|
|
518
|
+
ownerFeed = {
|
|
519
|
+
...ownerFeed,
|
|
520
|
+
ts: new Date().toISOString(),
|
|
521
|
+
phase: 'allocated',
|
|
522
|
+
you: {
|
|
523
|
+
...ownerFeed.you,
|
|
524
|
+
role: nonEmptyString(role?.role) ?? identity.role,
|
|
525
|
+
role_display: nonEmptyString(role?.role_display_name) ?? nonEmptyString(role?.role_display),
|
|
526
|
+
faction: nonEmptyString(role?.faction),
|
|
527
|
+
alive: identity.alive,
|
|
528
|
+
},
|
|
529
|
+
game: {
|
|
530
|
+
...ownerFeed.game,
|
|
531
|
+
game_id: identity.gameId,
|
|
532
|
+
},
|
|
533
|
+
};
|
|
393
534
|
let strategyInfo: { strategy: string; pid: number | undefined } | null = null;
|
|
394
|
-
if (identity.alive !== false) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
strategyInfo = autoStartStrategy(context.role, context.state, identity.gameId);
|
|
402
|
-
}
|
|
403
|
-
} else {
|
|
404
|
-
strategyInfo = autoStartStrategy(context.role, context.state, identity.gameId);
|
|
405
|
-
}
|
|
406
|
-
} else {
|
|
407
|
-
strategyInfo = autoStartStrategy(context.role, context.state, identity.gameId);
|
|
535
|
+
if (opts.watch && identity.alive !== false) {
|
|
536
|
+
stopOwnedStrategy();
|
|
537
|
+
const started = autoStartStrategy(context.role, context.state, identity.gameId);
|
|
538
|
+
if (started) {
|
|
539
|
+
strategyChild = started.child;
|
|
540
|
+
currentStrategy = started.strategy;
|
|
541
|
+
strategyInfo = { strategy: started.strategy, pid: started.pid };
|
|
408
542
|
}
|
|
409
543
|
}
|
|
410
544
|
const allocationPayload = {
|
|
@@ -416,9 +550,10 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
416
550
|
};
|
|
417
551
|
if (!opts.watch) {
|
|
418
552
|
emitLifecycle('allocated', allocationPayload,
|
|
419
|
-
'Match secured. Read role/map/tasks from `events[0]
|
|
553
|
+
'Match secured. Read role/map/tasks from `events[0]`, then launch `ccl game start` without --no-watch to attach the owner stream and automation.');
|
|
420
554
|
return;
|
|
421
555
|
}
|
|
556
|
+
await ensureEventRuntime();
|
|
422
557
|
await streamGame();
|
|
423
558
|
};
|
|
424
559
|
const pollQueue = async (preserveStrategy: boolean): Promise<void> => {
|
|
@@ -428,7 +563,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
428
563
|
let lastHeartbeat = Date.now();
|
|
429
564
|
let consecutiveFailures = 0;
|
|
430
565
|
|
|
431
|
-
while (
|
|
566
|
+
while (!ownerExitRequested) {
|
|
432
567
|
let queue: any;
|
|
433
568
|
try {
|
|
434
569
|
queue = await client.getQueueStatus('clawclaw');
|
|
@@ -496,8 +631,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
496
631
|
const resumeQueue = async (source: string): Promise<void> => {
|
|
497
632
|
ensureEventSession(source);
|
|
498
633
|
if (!readMatchState(stateDir)) startMatch(stateDir);
|
|
499
|
-
|
|
500
|
-
spawnOpeningMover();
|
|
634
|
+
await ensureEventRuntime();
|
|
501
635
|
await pollQueue(true);
|
|
502
636
|
};
|
|
503
637
|
const recoverAlreadyInGame = async (): Promise<boolean> => {
|
|
@@ -510,8 +644,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
510
644
|
const status = queueStatus(queue);
|
|
511
645
|
if (status === 'allocated') {
|
|
512
646
|
ensureEventSession('game_start_already_in_game');
|
|
513
|
-
|
|
514
|
-
spawnOpeningMover();
|
|
647
|
+
await ensureEventRuntime();
|
|
515
648
|
await handleAllocated(queue, true);
|
|
516
649
|
return true;
|
|
517
650
|
}
|
|
@@ -525,40 +658,71 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
525
658
|
const runningGameStartPid = getRunningGameStartPid(stateDir);
|
|
526
659
|
if (runningGameStartPid) {
|
|
527
660
|
if (opts.force) {
|
|
528
|
-
|
|
661
|
+
terminateProcessTree(runningGameStartPid, 'SIGKILL');
|
|
529
662
|
try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
|
|
663
|
+
try { unlinkSync(feedPath); } catch {}
|
|
530
664
|
} else {
|
|
531
665
|
emitLifecycle('already_running', { pid: runningGameStartPid },
|
|
532
666
|
`A ccl game start stream is already running (pid ${runningGameStartPid}). To replace it, re-run with --force or stop it manually: taskkill /F /PID ${runningGameStartPid}`);
|
|
533
667
|
return;
|
|
534
668
|
}
|
|
535
669
|
}
|
|
536
|
-
|
|
537
670
|
let initialQueue: any;
|
|
538
671
|
try {
|
|
539
672
|
initialQueue = await client.getQueueStatus('clawclaw');
|
|
540
673
|
} catch {}
|
|
541
674
|
const plan = planGameStartAction({
|
|
542
675
|
gameStartPid: null,
|
|
543
|
-
daemonPid: getRunningDaemonPid(stateDir),
|
|
544
676
|
hasMatchState: readMatchState(stateDir) !== null,
|
|
545
677
|
queueStatus: queueStatus(initialQueue),
|
|
546
|
-
feedPhase: readFeedPhase(feedPath),
|
|
547
678
|
});
|
|
548
679
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
await streamGame();
|
|
559
|
-
return;
|
|
680
|
+
ownerFeed = {
|
|
681
|
+
...ownerFeed,
|
|
682
|
+
ts: new Date().toISOString(),
|
|
683
|
+
phase: plan.kind === 'resume_allocated' ? 'allocated' : 'matching',
|
|
684
|
+
};
|
|
685
|
+
ownerControl = await startOwnerControlServer(stateDir, async (request) => {
|
|
686
|
+
if (request.type === 'snapshot') {
|
|
687
|
+
return { ok: true, type: 'snapshot', summary: currentSummary() };
|
|
560
688
|
}
|
|
561
|
-
|
|
689
|
+
if (request.type === 'stop') {
|
|
690
|
+
requestOwnerExit('stop', 'ccl game stop');
|
|
691
|
+
return { ok: true, type: 'stop' };
|
|
692
|
+
}
|
|
693
|
+
if (request.type === 'quit') {
|
|
694
|
+
requestOwnerExit('quit', 'ccl game quit');
|
|
695
|
+
return { ok: true, type: 'quit' };
|
|
696
|
+
}
|
|
697
|
+
if (request.type === 'leave') {
|
|
698
|
+
requestOwnerExit('leave', 'ccl game leave');
|
|
699
|
+
return { ok: true, type: 'leave' };
|
|
700
|
+
}
|
|
701
|
+
if (request.type === 'stop_strategy') {
|
|
702
|
+
stopOwnedStrategy();
|
|
703
|
+
return { ok: true, type: 'stop_strategy' };
|
|
704
|
+
}
|
|
705
|
+
if (request.type === 'switch_strategy') {
|
|
706
|
+
if (!request.strategy) return { ok: false, error: 'missing_strategy' };
|
|
707
|
+
stopOwnedStrategy();
|
|
708
|
+
const child = spawnStrategyLoop(request.strategy, request.args, {
|
|
709
|
+
source: 'manual',
|
|
710
|
+
detached: false,
|
|
711
|
+
writeRuntimeFiles: false,
|
|
712
|
+
});
|
|
713
|
+
strategyChild = child;
|
|
714
|
+
currentStrategy = request.strategy;
|
|
715
|
+
eventRuntime?.refreshFeed();
|
|
716
|
+
return { ok: true, type: 'switch_strategy', strategy: request.strategy, pid: child.pid };
|
|
717
|
+
}
|
|
718
|
+
return { ok: false, error: 'unsupported_owner_control_request' };
|
|
719
|
+
});
|
|
720
|
+
writeGameStartRuntime(stateDir, plan.kind, undefined, {
|
|
721
|
+
control: ownerControl.control,
|
|
722
|
+
token: ownerControl.token,
|
|
723
|
+
});
|
|
724
|
+
heartbeatTimer = startGameStartHeartbeat(stateDir, plan.kind);
|
|
725
|
+
try {
|
|
562
726
|
if (plan.kind === 'resume_queue') {
|
|
563
727
|
await resumeQueue('game_start_resume_queue');
|
|
564
728
|
return;
|
|
@@ -566,8 +730,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
566
730
|
|
|
567
731
|
if (plan.kind === 'resume_allocated') {
|
|
568
732
|
ensureEventSession('game_start_resume_allocated');
|
|
569
|
-
|
|
570
|
-
spawnOpeningMover();
|
|
733
|
+
await ensureEventRuntime();
|
|
571
734
|
await handleAllocated(initialQueue, true);
|
|
572
735
|
return;
|
|
573
736
|
}
|
|
@@ -590,24 +753,23 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
|
|
|
590
753
|
return;
|
|
591
754
|
}
|
|
592
755
|
|
|
593
|
-
|
|
594
|
-
stopStrategyIfRunning();
|
|
595
|
-
stopOpeningMoverIfRunning();
|
|
756
|
+
stopOwnedStrategy();
|
|
596
757
|
|
|
597
758
|
const events = EventStore.createSessionForActiveAccount();
|
|
598
759
|
events.append({ type: 'session_started', source: 'game_start' });
|
|
599
760
|
emitLifecycle('joined', joinResult,
|
|
600
|
-
'Match join request acknowledged. Spectate URL is in `events[0].url`; share it with the user.
|
|
761
|
+
'Match join request acknowledged. Spectate URL is in `events[0].url`; share it with the user. Game runtime is attached.');
|
|
601
762
|
startMatch(stateDir);
|
|
602
|
-
|
|
603
|
-
writeControl(stateDir, 'stop');
|
|
604
|
-
await waitDaemonExit(stateDir);
|
|
605
|
-
}
|
|
606
|
-
spawnDaemon();
|
|
607
|
-
spawnOpeningMover();
|
|
763
|
+
await ensureEventRuntime();
|
|
608
764
|
await pollQueue(false);
|
|
609
765
|
} finally {
|
|
610
|
-
|
|
766
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
767
|
+
process.off('SIGINT', onOwnerSignal);
|
|
768
|
+
process.off('SIGTERM', onOwnerSignal);
|
|
769
|
+
eventRuntime?.stop('manual');
|
|
770
|
+
stopOwnedStrategy();
|
|
771
|
+
if (ownerControl) await ownerControl.close();
|
|
772
|
+
cleanupGameStartRuntime(stateDir, { removeFeed: true, controlPath: ownerControl?.control.path });
|
|
611
773
|
}
|
|
612
774
|
}
|
|
613
775
|
|
|
@@ -623,9 +785,7 @@ export function createGameCommand(): Command {
|
|
|
623
785
|
const authStore = new AuthStore();
|
|
624
786
|
const profile = authStore.getActive();
|
|
625
787
|
if (!profile) throw new Error('Not logged in.');
|
|
626
|
-
const { stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
|
|
627
788
|
stopStrategyIfRunning();
|
|
628
|
-
stopOpeningMoverIfRunning();
|
|
629
789
|
|
|
630
790
|
const client = GameClient.fromAuth();
|
|
631
791
|
const result = await client.joinQueue('clawclaw');
|
|
@@ -637,19 +797,12 @@ export function createGameCommand(): Command {
|
|
|
637
797
|
// can compute `waited_secs` from a known anchor and emit the
|
|
638
798
|
// match_waiting / match_timeout synthetic events.
|
|
639
799
|
startMatch(stateDir);
|
|
640
|
-
if (getRunningDaemonPid(stateDir)) {
|
|
641
|
-
writeControl(stateDir, 'stop');
|
|
642
|
-
await waitDaemonExit(stateDir);
|
|
643
|
-
}
|
|
644
|
-
const { pid } = spawnDaemon();
|
|
645
|
-
console.log(JSON.stringify({ message: 'Event daemon started', pid }, null, 2));
|
|
646
|
-
spawnOpeningMover();
|
|
647
800
|
});
|
|
648
801
|
|
|
649
802
|
game
|
|
650
803
|
.command('start')
|
|
651
804
|
.alias('s')
|
|
652
|
-
.description('
|
|
805
|
+
.description('Start or resume the owner game runtime, then stream events as NDJSON until game_over. Pass --no-watch to exit after allocation. Pass --force to replace an orphaned game-start runtime.')
|
|
653
806
|
.option('--no-watch', 'exit after allocation instead of streaming events through game_over')
|
|
654
807
|
.option('--force', 'force restart: kill any lingering game-start stream process and start fresh')
|
|
655
808
|
.action(runGameStart);
|
|
@@ -711,7 +864,7 @@ export function createGameCommand(): Command {
|
|
|
711
864
|
queue,
|
|
712
865
|
waited_secs: waitedSecs,
|
|
713
866
|
...context,
|
|
714
|
-
next_step: 'Game allocated.
|
|
867
|
+
next_step: 'Game allocated. Launch `ccl game start` to attach the owner stream if it is not already running, then narrate the role / map / opening plan to the user.',
|
|
715
868
|
}, null, 2));
|
|
716
869
|
return;
|
|
717
870
|
}
|
|
@@ -755,18 +908,13 @@ export function createGameCommand(): Command {
|
|
|
755
908
|
.alias('l')
|
|
756
909
|
.description('Leave queue')
|
|
757
910
|
.action(async () => {
|
|
758
|
-
stopOpeningMoverIfRunning();
|
|
759
911
|
const authStore = new AuthStore();
|
|
760
912
|
const profile = authStore.getActive();
|
|
761
913
|
if (profile) {
|
|
762
914
|
const stateDir = getProfileStateDir(profile);
|
|
763
915
|
endMatch(stateDir);
|
|
764
|
-
|
|
765
|
-
writeControl(stateDir, 'stop');
|
|
766
|
-
await waitDaemonExit(stateDir);
|
|
767
|
-
}
|
|
916
|
+
await stopOwnerWithCommand(stateDir, 'leave');
|
|
768
917
|
}
|
|
769
|
-
const { stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
|
|
770
918
|
stopStrategyIfRunning();
|
|
771
919
|
const client = GameClient.fromAuth();
|
|
772
920
|
const result = await client.leaveQueue('clawclaw');
|
|
@@ -777,26 +925,27 @@ export function createGameCommand(): Command {
|
|
|
777
925
|
game
|
|
778
926
|
.command('stop')
|
|
779
927
|
.alias('x')
|
|
780
|
-
.description('Stop
|
|
928
|
+
.description('Stop local game runtime')
|
|
781
929
|
.action(async () => {
|
|
782
930
|
const authStore = new AuthStore();
|
|
783
931
|
const profile = authStore.getActive();
|
|
784
932
|
if (!profile) throw new Error('Not logged in.');
|
|
785
933
|
const stateDir = getProfileStateDir(profile);
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
if (!
|
|
789
|
-
console.log(JSON.stringify({ message: 'No
|
|
934
|
+
const owner = await stopOwnerIfRunning(stateDir);
|
|
935
|
+
stopStrategyIfRunning();
|
|
936
|
+
if (!owner.stopped) {
|
|
937
|
+
console.log(JSON.stringify({ message: 'No game runtime running.' }, null, 2));
|
|
790
938
|
return;
|
|
791
939
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
940
|
+
console.log(JSON.stringify({
|
|
941
|
+
message: 'Game runtime stopped.',
|
|
942
|
+
...(owner.pid ? { owner_pid: owner.pid } : {}),
|
|
943
|
+
}, null, 2));
|
|
795
944
|
});
|
|
796
945
|
|
|
797
946
|
game
|
|
798
947
|
.command('quit')
|
|
799
|
-
.description('Leave active game and stop
|
|
948
|
+
.description('Leave active game and stop local runtime')
|
|
800
949
|
.action(async () => {
|
|
801
950
|
const authStore = new AuthStore();
|
|
802
951
|
const profile = authStore.getActive();
|
|
@@ -806,16 +955,11 @@ export function createGameCommand(): Command {
|
|
|
806
955
|
try {
|
|
807
956
|
result = await client.leaveGame();
|
|
808
957
|
} catch (err: any) {
|
|
809
|
-
|
|
958
|
+
result = { error: err?.message ?? String(err) };
|
|
810
959
|
}
|
|
811
960
|
const stateDir = getProfileStateDir(profile);
|
|
812
961
|
endMatch(stateDir);
|
|
813
|
-
|
|
814
|
-
writeControl(stateDir, 'stop');
|
|
815
|
-
await waitDaemonExit(stateDir);
|
|
816
|
-
}
|
|
817
|
-
stopOpeningMoverIfRunning();
|
|
818
|
-
const { stopStrategyIfRunning } = await import('../strategies/strategy-loop.js');
|
|
962
|
+
const owner = await stopOwnerWithCommand(stateDir, 'quit');
|
|
819
963
|
stopStrategyIfRunning();
|
|
820
964
|
const reminder = hubReminder(readCachedGamesPlayed());
|
|
821
965
|
const out: Record<string, any> = (result && typeof result === 'object' && !Array.isArray(result))
|
|
@@ -825,6 +969,7 @@ export function createGameCommand(): Command {
|
|
|
825
969
|
out.hub_reminder = reminder;
|
|
826
970
|
out.next_step = `Left the game. ${reminder}`;
|
|
827
971
|
}
|
|
972
|
+
if (owner.pid) out.owner_pid = owner.pid;
|
|
828
973
|
console.log(JSON.stringify(out, null, 2));
|
|
829
974
|
});
|
|
830
975
|
|