@myclaw163/clawclaw-cli 0.6.58 → 0.6.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +440 -440
- package/package.json +48 -48
- package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
- package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
- package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
- package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
- package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
- package/scripts/postinstall.mjs +20 -20
- package/scripts/sync-bundled-skill.mjs +244 -244
- package/scripts/sync-bundled-skill.test.mjs +152 -152
- package/skills/clawclaw/SKILL.md +244 -244
- package/skills/clawclaw/references/CHATTERBOX.md +142 -142
- package/skills/clawclaw/references/COMMANDS.md +129 -132
- package/skills/clawclaw/references/GAME-MECHANICS.md +186 -186
- package/skills/clawclaw/references/HUB.md +48 -48
- package/skills/clawclaw/references/KNOWLEDGE.md +43 -43
- package/skills/clawclaw/references/STRATEGIES.md +57 -57
- package/skills/clawclaw/references/STREAM.md +59 -59
- package/skills/clawclaw/references/TACTICS.md +65 -65
- package/src/assets/clawclaw-ascii-map.txt +40 -40
- package/src/cli.ts +110 -110
- package/src/commands/_schema.ts +109 -109
- package/src/commands/account.ts +209 -209
- package/src/commands/config.ts +30 -30
- package/src/commands/do.test.ts +73 -37
- package/src/commands/do.ts +126 -95
- package/src/commands/events.ts +22 -22
- package/src/commands/game-map.test.ts +28 -28
- package/src/commands/game-start-plan.test.ts +84 -84
- package/src/commands/game.ts +1027 -1027
- package/src/commands/history-player.test.ts +102 -102
- package/src/commands/history.ts +573 -573
- package/src/commands/hub.test.ts +96 -96
- package/src/commands/hub.ts +234 -234
- package/src/commands/knowledge.test.ts +19 -19
- package/src/commands/knowledge.ts +168 -168
- package/src/commands/load.test.ts +51 -51
- package/src/commands/load.ts +13 -13
- package/src/commands/meeting-history.test.ts +106 -106
- package/src/commands/memory.ts +40 -40
- package/src/commands/peek.ts +45 -45
- package/src/commands/persona.ts +57 -57
- 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/skill.ts +128 -128
- package/src/commands/state.ts +46 -46
- package/src/commands/strategy.test.ts +135 -135
- package/src/commands/strategy.ts +180 -180
- package/src/commands/tts.ts +128 -128
- package/src/commands/upgrade.test.ts +82 -82
- package/src/commands/upgrade.ts +148 -148
- package/src/commands/watch.test.ts +969 -969
- package/src/commands/watch.ts +720 -720
- package/src/lib/auth.test.ts +59 -59
- package/src/lib/auth.ts +186 -186
- package/src/lib/command-meta.ts +37 -37
- package/src/lib/game-client.ts +391 -391
- package/src/lib/host-config-patcher.test.ts +130 -130
- package/src/lib/host-config-patcher.ts +151 -151
- package/src/lib/http-keepalive.ts +15 -15
- package/src/lib/http-transport.test.ts +42 -42
- package/src/lib/http-transport.ts +113 -113
- package/src/lib/hub-client.test.ts +56 -56
- package/src/lib/hub-client.ts +88 -88
- package/src/lib/hub-install.test.ts +98 -98
- package/src/lib/hub-install.ts +121 -121
- package/src/lib/hub-reminder.ts +56 -56
- package/src/lib/hub-unzip.test.ts +69 -69
- package/src/lib/hub-unzip.ts +62 -62
- package/src/lib/init-command.test.ts +75 -75
- package/src/lib/init-command.ts +120 -120
- package/src/lib/knowledge-store.test.ts +180 -180
- package/src/lib/knowledge-store.ts +374 -374
- package/src/lib/load-context.test.ts +52 -52
- package/src/lib/load-context.ts +52 -52
- package/src/lib/match-state.test.ts +134 -134
- package/src/lib/match-state.ts +94 -94
- package/src/lib/netease-tts.ts +83 -83
- package/src/lib/normalize.ts +42 -42
- package/src/lib/persona.test.ts +41 -41
- package/src/lib/persona.ts +72 -72
- package/src/lib/server-registry.ts +152 -152
- package/src/lib/skill-version.test.ts +48 -48
- package/src/lib/skill-version.ts +19 -19
- package/src/lib/strategy-export.test.ts +232 -232
- package/src/lib/strategy-export.ts +242 -242
- package/src/lib/tts-keys.ts +7 -7
- package/src/lib/tts-speech.test.ts +63 -63
- package/src/lib/tts-speech.ts +76 -76
- package/src/lib/workspace-argv.test.ts +49 -49
- package/src/lib/workspace-argv.ts +44 -44
- package/src/perception/player-history-store.test.ts +87 -87
- package/src/perception/player-history-store.ts +194 -194
- package/src/pipeline/event-store.ts +124 -124
- package/src/pipeline/pipeline.ts +35 -35
- package/src/runtime/auto-upgrade.test.ts +66 -66
- package/src/runtime/auto-upgrade.ts +31 -31
- package/src/runtime/event-daemon.test.ts +107 -107
- package/src/runtime/event-daemon.ts +409 -409
- package/src/runtime/owner-control.ts +150 -150
- package/src/runtime/raw-ws-log.test.ts +33 -33
- package/src/runtime/raw-ws-log.ts +32 -32
- package/src/runtime/runtime-logger.ts +107 -107
- package/src/runtime/ws-client.test.ts +104 -104
- package/src/runtime/ws-client.ts +272 -272
- package/src/sdk/action.ts +166 -166
- package/src/sdk/index.ts +110 -110
- package/src/sdk/types.ts +146 -146
- package/src/strategies/avoid-lone.ts +11 -11
- package/src/strategies/avoid-players.knowledge.md +20 -20
- package/src/strategies/avoid-players.ts +15 -15
- package/src/strategies/corpse-patrol.ts +22 -22
- package/src/strategies/crab-sabotage.ts +21 -21
- package/src/strategies/custom-module.test.ts +269 -269
- package/src/strategies/find-player.ts +16 -16
- package/src/strategies/game-utils.test.ts +190 -190
- package/src/strategies/game-utils.ts +744 -744
- package/src/strategies/goals/avoid-lone-top.ts +168 -168
- package/src/strategies/goals/avoid-players-top.test.ts +83 -83
- package/src/strategies/goals/avoid-players-top.ts +121 -121
- package/src/strategies/goals/conversation-goal.ts +51 -51
- package/src/strategies/goals/corpse-patrol-top.ts +91 -91
- package/src/strategies/goals/crab-octopus-reflexes.ts +93 -93
- package/src/strategies/goals/crab-sabotage-top.ts +197 -197
- package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
- package/src/strategies/goals/find-player-top.ts +93 -93
- package/src/strategies/goals/flee-players-goal.ts +53 -53
- package/src/strategies/goals/goal-manager.ts +41 -41
- package/src/strategies/goals/goal-root-strategy.ts +49 -49
- package/src/strategies/goals/goal.ts +28 -28
- package/src/strategies/goals/keep-away-goal.ts +209 -209
- package/src/strategies/goals/kill-frenzy-top.ts +80 -80
- package/src/strategies/goals/kill-lone-top.ts +160 -160
- package/src/strategies/goals/kill-target-goal.ts +59 -59
- package/src/strategies/goals/kill-target-top.ts +109 -109
- package/src/strategies/goals/leaf-goal.ts +25 -25
- package/src/strategies/goals/linger-corpse-goal.ts +79 -79
- package/src/strategies/goals/lone-kill-core.ts +82 -82
- package/src/strategies/goals/lone-kill-goal.ts +24 -24
- package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
- package/src/strategies/goals/lone-kill-task-top.ts +86 -86
- package/src/strategies/goals/move-room-goal.ts +60 -60
- package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
- package/src/strategies/goals/normal-shrimp-top.ts +242 -242
- package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
- package/src/strategies/goals/paradise-fish-top.ts +219 -219
- package/src/strategies/goals/patrol-top.ts +57 -57
- package/src/strategies/goals/report-patrol-top.ts +80 -80
- package/src/strategies/goals/safe-task-goal.ts +102 -102
- package/src/strategies/goals/social-task-top.ts +161 -161
- package/src/strategies/goals/task-kill-report-top.ts +163 -163
- package/src/strategies/goals/task-only-top.ts +57 -57
- package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
- package/src/strategies/goals/task-report-top.ts +57 -57
- package/src/strategies/goals/wander-task-goal.ts +33 -33
- package/src/strategies/goals/warrior-shrimp-top.test.ts +86 -86
- package/src/strategies/goals/warrior-shrimp-top.ts +248 -248
- package/src/strategies/greeting.ts +53 -53
- package/src/strategies/kill-frenzy.ts +12 -12
- package/src/strategies/kill-lone.knowledge.md +20 -20
- package/src/strategies/kill-lone.ts +13 -13
- package/src/strategies/kill-target.ts +18 -18
- package/src/strategies/loader.test.ts +678 -678
- package/src/strategies/loader.ts +172 -172
- package/src/strategies/lone-kill-task.ts +21 -21
- package/src/strategies/meeting-gate.test.ts +59 -59
- package/src/strategies/meeting-gate.ts +23 -23
- package/src/strategies/move-room.ts +15 -15
- package/src/strategies/new-events-backfill.ts +98 -98
- package/src/strategies/paradise-fish.knowledge.md +20 -20
- package/src/strategies/paradise-fish.ts +25 -25
- package/src/strategies/pathfind/distance-field.ts +150 -150
- package/src/strategies/pathfind/escape-planner.test.ts +197 -197
- package/src/strategies/pathfind/escape-planner.ts +355 -355
- package/src/strategies/pathfind/walkable-grid.ts +117 -117
- package/src/strategies/patrol.ts +11 -11
- package/src/strategies/player-targets.ts +13 -13
- package/src/strategies/report-patrol.ts +11 -11
- package/src/strategies/shrimp-memory.knowledge.md +20 -20
- package/src/strategies/shrimp-memory.ts +25 -25
- package/src/strategies/social-task.test.ts +28 -28
- package/src/strategies/social-task.ts +49 -49
- package/src/strategies/spawn.ts +82 -82
- package/src/strategies/speech-module.ts +123 -123
- package/src/strategies/strategy-loop.ts +763 -763
- package/src/strategies/task-kill-report.ts +17 -17
- package/src/strategies/task-only.ts +11 -11
- package/src/strategies/task-report.ts +22 -22
- package/src/strategies/types.ts +96 -96
- package/src/strategies/warrior-memory.knowledge.md +20 -20
- package/src/strategies/warrior-memory.ts +16 -16
package/src/commands/game.ts
CHANGED
|
@@ -1,1027 +1,1027 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { spawnSync, type ChildProcess } from 'child_process';
|
|
5
|
-
import { AuthStore } from '../lib/auth.js';
|
|
6
|
-
import { ApiError, GameClient } from '../lib/game-client.js';
|
|
7
|
-
import { getProfileStateDir } from '../lib/init-command.js';
|
|
8
|
-
import { EventStore } from '../pipeline/event-store.js';
|
|
9
|
-
import { spawnStrategyLoop } from '../strategies/spawn.js';
|
|
10
|
-
import { stopStrategyIfRunning } from '../strategies/strategy-loop.js';
|
|
11
|
-
import { setMeta } from '../lib/command-meta.js';
|
|
12
|
-
import { runStreaming, buildErrorLine, summarizeFeed, nextStepFor } from './watch.js';
|
|
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';
|
|
23
|
-
import {
|
|
24
|
-
startMatch,
|
|
25
|
-
endMatch,
|
|
26
|
-
readMatchState,
|
|
27
|
-
shouldEmitWaiting,
|
|
28
|
-
markWaitingEmitted,
|
|
29
|
-
getWaitedSecs,
|
|
30
|
-
hasMatchTimedOut,
|
|
31
|
-
} from '../lib/match-state.js';
|
|
32
|
-
|
|
33
|
-
function sleep(ms: number): Promise<void> {
|
|
34
|
-
return new Promise(r => setTimeout(r, ms));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function positiveNumber(value: string, fallback: number): number {
|
|
38
|
-
const parsed = Number(value);
|
|
39
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function queueStatus(result: any): string | undefined {
|
|
43
|
-
return (result?.data ?? result)?.status;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const DEFAULT_QUEUE_WAIT_TIMEOUT_SECS = 30;
|
|
47
|
-
|
|
48
|
-
function isPidAlive(pid: number): boolean {
|
|
49
|
-
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
|
|
50
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function getRunningGameStartPid(stateDir: string): number | null {
|
|
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 {}
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function writeGameStartRuntime(
|
|
63
|
-
stateDir: string,
|
|
64
|
-
mode: GameStartPlanKind,
|
|
65
|
-
phase?: string,
|
|
66
|
-
controlOverride?: { control: OwnerControlInfo; token: string },
|
|
67
|
-
): void {
|
|
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;
|
|
83
|
-
writeFileSync(gameStartRuntimePath(stateDir), JSON.stringify({
|
|
84
|
-
schema: 3,
|
|
85
|
-
owner_pid: process.pid,
|
|
86
|
-
pid: process.pid,
|
|
87
|
-
started_at: startedAt,
|
|
88
|
-
heartbeat_at: new Date().toISOString(),
|
|
89
|
-
mode,
|
|
90
|
-
...(control ? { control } : {}),
|
|
91
|
-
...(controlToken ? { control_token: controlToken } : {}),
|
|
92
|
-
...(phase ? { phase } : {}),
|
|
93
|
-
}, null, 2));
|
|
94
|
-
}
|
|
95
|
-
|
|
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 {
|
|
105
|
-
const runtimePath = gameStartRuntimePath(stateDir);
|
|
106
|
-
try {
|
|
107
|
-
const info = JSON.parse(readFileSync(runtimePath, 'utf8'));
|
|
108
|
-
const pid = Number(info?.owner_pid ?? info?.pid);
|
|
109
|
-
if (pid !== process.pid) return;
|
|
110
|
-
} catch {}
|
|
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 };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
type QueueStatus = 'allocated' | 'queued' | 'not_in_queue' | string | undefined;
|
|
179
|
-
|
|
180
|
-
export type GameStartPlan =
|
|
181
|
-
| { kind: 'already_running'; pid: number }
|
|
182
|
-
| { kind: 'resume_queue' }
|
|
183
|
-
| { kind: 'resume_allocated' }
|
|
184
|
-
| { kind: 'fresh_start' };
|
|
185
|
-
|
|
186
|
-
type GameStartPlanKind = GameStartPlan['kind'];
|
|
187
|
-
|
|
188
|
-
export function planGameStartAction(input: {
|
|
189
|
-
gameStartPid?: number | null;
|
|
190
|
-
hasMatchState: boolean;
|
|
191
|
-
queueStatus?: QueueStatus;
|
|
192
|
-
}): GameStartPlan {
|
|
193
|
-
if (input.gameStartPid) return { kind: 'already_running', pid: input.gameStartPid };
|
|
194
|
-
|
|
195
|
-
if (input.queueStatus === 'allocated') return { kind: 'resume_allocated' };
|
|
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' };
|
|
198
|
-
return { kind: 'fresh_start' };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function isAlreadyInGameError(err: unknown): boolean {
|
|
202
|
-
return err instanceof ApiError && err.body.includes('ALREADY_IN_GAME');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function ensureEventSession(source: string): EventStore {
|
|
206
|
-
const existing = EventStore.latestSessionPath();
|
|
207
|
-
if (existing) {
|
|
208
|
-
const events = EventStore.forActiveAccount();
|
|
209
|
-
events.append({ type: 'session_resumed', source });
|
|
210
|
-
return events;
|
|
211
|
-
}
|
|
212
|
-
const events = EventStore.createSessionForActiveAccount();
|
|
213
|
-
events.append({ type: 'session_started', source });
|
|
214
|
-
return events;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function nonEmptyString(value: unknown): string | undefined {
|
|
218
|
-
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function unwrapData(value: any): any {
|
|
222
|
-
return value?.data ?? value;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export interface GameStrategyIdentity {
|
|
226
|
-
gameId?: string;
|
|
227
|
-
role?: string;
|
|
228
|
-
alive: boolean | null;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function gameStrategyIdentity(stateData: any, roleData: any): GameStrategyIdentity {
|
|
232
|
-
const state = unwrapData(stateData);
|
|
233
|
-
const role = unwrapData(roleData);
|
|
234
|
-
const you = state?.you ?? {};
|
|
235
|
-
const aliveRaw = you?.is_alive ?? you?.alive;
|
|
236
|
-
return {
|
|
237
|
-
gameId: nonEmptyString(state?.game_id) ?? nonEmptyString(state?.game?.id) ?? nonEmptyString(state?.game?.game_id),
|
|
238
|
-
role: nonEmptyString(role?.role) ?? nonEmptyString(you?.role),
|
|
239
|
-
alive: typeof aliveRaw === 'boolean' ? aliveRaw : null,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function commandError(command: string, err: unknown): { command: string; error: string } {
|
|
244
|
-
const message = err instanceof ApiError
|
|
245
|
-
? `${err.status}: ${err.body}`
|
|
246
|
-
: err instanceof Error
|
|
247
|
-
? err.message
|
|
248
|
-
: String(err);
|
|
249
|
-
return { command, error: message };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function briefMap(mapData: any): any {
|
|
253
|
-
if (!mapData) return null;
|
|
254
|
-
return {
|
|
255
|
-
rooms: mapData.rooms?.map((r: any) => ({
|
|
256
|
-
name: r.name,
|
|
257
|
-
polygon: r.polygon,
|
|
258
|
-
})),
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function taskLocations(mapData: any): { lobster: any[]; crab: any[] } {
|
|
263
|
-
const taskLocations = { lobster: [] as any[], crab: [] as any[] };
|
|
264
|
-
for (const task of Array.isArray(mapData?.all_task_locations) ? mapData.all_task_locations : []) {
|
|
265
|
-
const item = {
|
|
266
|
-
name: task.name,
|
|
267
|
-
room: task.room,
|
|
268
|
-
x: task.x,
|
|
269
|
-
y: task.y,
|
|
270
|
-
};
|
|
271
|
-
if (task.faction === 'crab') taskLocations.crab.push(item);
|
|
272
|
-
else taskLocations.lobster.push(item);
|
|
273
|
-
}
|
|
274
|
-
return taskLocations;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function loadAsciiMapAsset(): string | null {
|
|
278
|
-
try {
|
|
279
|
-
return readFileSync(new URL('../assets/clawclaw-ascii-map.txt', import.meta.url), 'utf8').trimEnd();
|
|
280
|
-
} catch {
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
export function summarizeGameMap(mapData: any, opts?: { ascii?: boolean }): any {
|
|
286
|
-
const output: Record<string, any> = {
|
|
287
|
-
rooms: mapData?.rooms?.map((r: any) => ({
|
|
288
|
-
name: r.name,
|
|
289
|
-
polygon: r.polygon,
|
|
290
|
-
})) ?? [],
|
|
291
|
-
task_locations: taskLocations(mapData),
|
|
292
|
-
};
|
|
293
|
-
if (opts?.ascii) {
|
|
294
|
-
const ascii = loadAsciiMapAsset();
|
|
295
|
-
output.ascii_map = ascii;
|
|
296
|
-
}
|
|
297
|
-
return output;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
async function fetchInitialGameContext(client: GameClient): Promise<{
|
|
301
|
-
state: any | null;
|
|
302
|
-
role: any | null;
|
|
303
|
-
map: any | null;
|
|
304
|
-
tasks: any[] | null;
|
|
305
|
-
errors?: Array<{ command: string; error: string }>;
|
|
306
|
-
}> {
|
|
307
|
-
await sleep(500);
|
|
308
|
-
const [stateResult, roleResult, mapResult] = await Promise.allSettled([
|
|
309
|
-
client.getGameState(),
|
|
310
|
-
client.getRoleInfo(),
|
|
311
|
-
client.getMap(),
|
|
312
|
-
]);
|
|
313
|
-
|
|
314
|
-
const errors: Array<{ command: string; error: string }> = [];
|
|
315
|
-
if (stateResult.status === 'rejected') errors.push(commandError('state', stateResult.reason));
|
|
316
|
-
if (roleResult.status === 'rejected') errors.push(commandError('game role', roleResult.reason));
|
|
317
|
-
if (mapResult.status === 'rejected') errors.push(commandError('game map', mapResult.reason));
|
|
318
|
-
|
|
319
|
-
const mapData = mapResult.status === 'fulfilled' ? mapResult.value : null;
|
|
320
|
-
const context = {
|
|
321
|
-
state: stateResult.status === 'fulfilled' ? stateResult.value : null,
|
|
322
|
-
role: roleResult.status === 'fulfilled' ? roleResult.value : null,
|
|
323
|
-
map: briefMap(mapData),
|
|
324
|
-
tasks: mapData?.your_tasks ?? null,
|
|
325
|
-
...(errors.length ? { errors } : {}),
|
|
326
|
-
};
|
|
327
|
-
return context;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const ROLE_DEFAULT_STRATEGY: Record<string, string> = {
|
|
331
|
-
'shrimp_generic': 'task-report',
|
|
332
|
-
'shrimp_warrior': 'task-report',
|
|
333
|
-
'shrimp_pistol': 'task-report',
|
|
334
|
-
'crab_generic': 'crab-sabotage',
|
|
335
|
-
'neutral_paradise_fish': 'corpse-patrol',
|
|
336
|
-
'neutral_octopus': 'lone-kill-task',
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { strategy: string; pid: number | undefined; child: ChildProcess } | null {
|
|
340
|
-
const roleId: string | undefined = roleData?.data?.role ?? roleData?.role;
|
|
341
|
-
if (!roleId) return null;
|
|
342
|
-
const strategyId = ROLE_DEFAULT_STRATEGY[roleId];
|
|
343
|
-
if (!strategyId) return null;
|
|
344
|
-
const child = spawnStrategyLoop(strategyId, [roleId], {
|
|
345
|
-
source: 'auto_start',
|
|
346
|
-
gameId: gameId ?? gameStrategyIdentity(stateData, roleData).gameId,
|
|
347
|
-
role: roleId,
|
|
348
|
-
detached: false,
|
|
349
|
-
writeRuntimeFiles: false,
|
|
350
|
-
});
|
|
351
|
-
return { strategy: strategyId, pid: child.pid, child };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<void> {
|
|
355
|
-
if (process.env.CLAWCLAW_REQUIRE_STREAM_TOOL === '1' && process.env.CLAWCLAW_STREAMED !== '1') {
|
|
356
|
-
process.stderr.write(
|
|
357
|
-
'In OpenClaw, start the game via the clawclaw_game_start tool - do not exec `ccl game start` directly.\n' +
|
|
358
|
-
'When run raw, the event NDJSON only fills an unconsumed buffer, so you never receive speech_your_turn and stay silent.\n',
|
|
359
|
-
);
|
|
360
|
-
process.exit(2);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const authStore = new AuthStore();
|
|
364
|
-
const profile = authStore.getActive();
|
|
365
|
-
if (!profile) throw new Error('Not logged in.');
|
|
366
|
-
|
|
367
|
-
const stateDir = getProfileStateDir(profile);
|
|
368
|
-
const feedPath = join(stateDir, 'feed.json');
|
|
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);
|
|
422
|
-
const emit = (obj: Record<string, any>): void => {
|
|
423
|
-
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
424
|
-
};
|
|
425
|
-
const emitLifecycle = (
|
|
426
|
-
reason: string,
|
|
427
|
-
payload: Record<string, any>,
|
|
428
|
-
nextStepOverride?: string,
|
|
429
|
-
): void => {
|
|
430
|
-
const event = { ...payload, type: reason };
|
|
431
|
-
emit({
|
|
432
|
-
exit_reason: [reason],
|
|
433
|
-
next_step: nextStepOverride ?? nextStepFor(reason),
|
|
434
|
-
events: [event],
|
|
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' },
|
|
447
|
-
});
|
|
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
|
-
};
|
|
456
|
-
const emitMatchEvent = (evt: Record<string, any>): void => {
|
|
457
|
-
try {
|
|
458
|
-
const store = EventStore.forActiveAccount();
|
|
459
|
-
store.append({ ts: new Date().toISOString(), ...evt });
|
|
460
|
-
} catch {}
|
|
461
|
-
};
|
|
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();
|
|
473
|
-
};
|
|
474
|
-
const streamGame = async (): Promise<void> => {
|
|
475
|
-
const sessionPath = EventStore.latestSessionPath();
|
|
476
|
-
const ctrl = new AbortController();
|
|
477
|
-
streamAbortController = ctrl;
|
|
478
|
-
const onSignal = (): void => {
|
|
479
|
-
ctrl.abort();
|
|
480
|
-
eventRuntime?.stop('SIGTERM');
|
|
481
|
-
};
|
|
482
|
-
process.on('SIGINT', onSignal);
|
|
483
|
-
process.on('SIGTERM', onSignal);
|
|
484
|
-
try {
|
|
485
|
-
await runStreaming({
|
|
486
|
-
feedPath,
|
|
487
|
-
sessionPath,
|
|
488
|
-
getSessionPath: () => EventStore.latestSessionPath(),
|
|
489
|
-
stdout: (s) => process.stdout.write(s),
|
|
490
|
-
signal: ctrl.signal,
|
|
491
|
-
skipFeedWait: true,
|
|
492
|
-
readSummary: currentSummary,
|
|
493
|
-
skipBacklogTypes: ['match_waiting', 'match_timeout'],
|
|
494
|
-
emitGameStart: true,
|
|
495
|
-
hubReminder: hubReminder(readCachedGamesPlayed()),
|
|
496
|
-
});
|
|
497
|
-
} catch (err: any) {
|
|
498
|
-
process.stdout.write(buildErrorLine(err));
|
|
499
|
-
process.exitCode = 1;
|
|
500
|
-
} finally {
|
|
501
|
-
process.off('SIGINT', onSignal);
|
|
502
|
-
process.off('SIGTERM', onSignal);
|
|
503
|
-
streamAbortController = null;
|
|
504
|
-
}
|
|
505
|
-
};
|
|
506
|
-
const handleAllocated = async (queue: any, preserveStrategy: boolean): Promise<void> => {
|
|
507
|
-
const finalState = readMatchState(stateDir);
|
|
508
|
-
const waitedSecs = finalState ? getWaitedSecs(finalState) : 0;
|
|
509
|
-
endMatch(stateDir);
|
|
510
|
-
const context = await fetchInitialGameContext(client);
|
|
511
|
-
const identity = gameStrategyIdentity(context.state, context.role);
|
|
512
|
-
// Supplement game_id from queue response when /game/current doesn't carry it yet.
|
|
513
|
-
const queueGameId = nonEmptyString(queue?.game_id) ?? nonEmptyString(queue?.data?.game_id);
|
|
514
|
-
if (!identity.gameId && queueGameId) {
|
|
515
|
-
identity.gameId = queueGameId;
|
|
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
|
-
};
|
|
534
|
-
let strategyInfo: { strategy: string; pid: number | undefined } | null = null;
|
|
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 };
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
const allocationPayload = {
|
|
545
|
-
queue,
|
|
546
|
-
waited_secs: waitedSecs,
|
|
547
|
-
game_id: identity.gameId || undefined,
|
|
548
|
-
...context,
|
|
549
|
-
...(strategyInfo ? { default_strategy: strategyInfo } : {}),
|
|
550
|
-
};
|
|
551
|
-
if (!opts.watch) {
|
|
552
|
-
emitLifecycle('allocated', allocationPayload,
|
|
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.');
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
await ensureEventRuntime();
|
|
557
|
-
await streamGame();
|
|
558
|
-
};
|
|
559
|
-
const pollQueue = async (preserveStrategy: boolean): Promise<void> => {
|
|
560
|
-
const intervalMs = 2000;
|
|
561
|
-
const QUEUE_POLL_HEARTBEAT_MS = 60_000;
|
|
562
|
-
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
563
|
-
let lastHeartbeat = Date.now();
|
|
564
|
-
let consecutiveFailures = 0;
|
|
565
|
-
|
|
566
|
-
while (!ownerExitRequested) {
|
|
567
|
-
let queue: any;
|
|
568
|
-
try {
|
|
569
|
-
queue = await client.getQueueStatus('clawclaw');
|
|
570
|
-
consecutiveFailures = 0;
|
|
571
|
-
} catch (err: any) {
|
|
572
|
-
consecutiveFailures++;
|
|
573
|
-
const msg = err?.message ?? String(err);
|
|
574
|
-
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
575
|
-
emitLifecycle('error', {
|
|
576
|
-
error: msg,
|
|
577
|
-
consecutive_failures: consecutiveFailures,
|
|
578
|
-
message: `Queue status polling failed ${consecutiveFailures} times consecutively.`,
|
|
579
|
-
}, 'Queue polling has failed repeatedly. Check network, then launch a fresh `ccl game start` to retry.');
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
emitLifecycle('poll_error', {
|
|
583
|
-
error: msg,
|
|
584
|
-
consecutive_failures: consecutiveFailures,
|
|
585
|
-
message: `Queue poll failed (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}). Retrying...`,
|
|
586
|
-
}, 'Temporary polling failure. The stream will keep retrying - stay attached.');
|
|
587
|
-
await sleep(intervalMs);
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (queueStatus(queue) === 'allocated') {
|
|
592
|
-
await handleAllocated(queue, preserveStrategy);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (queueStatus(queue) === 'not_in_queue') {
|
|
596
|
-
endMatch(stateDir);
|
|
597
|
-
emitLifecycle('not_in_queue', { queue, message: 'Left or dropped from queue.' },
|
|
598
|
-
'No longer in matchmaking queue. The stream will exit; launch a fresh `ccl game start` to retry if the user wants to continue.');
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const cur = readMatchState(stateDir);
|
|
603
|
-
if (cur && shouldEmitWaiting(cur)) {
|
|
604
|
-
const waitedSecs = getWaitedSecs(cur);
|
|
605
|
-
emitMatchEvent({ type: 'match_waiting', waited_secs: waitedSecs });
|
|
606
|
-
emitLifecycle('match_waiting', { waited_secs: waitedSecs });
|
|
607
|
-
markWaitingEmitted(stateDir);
|
|
608
|
-
lastHeartbeat = Date.now();
|
|
609
|
-
}
|
|
610
|
-
if (cur && hasMatchTimedOut(cur)) {
|
|
611
|
-
const waitedSecs = getWaitedSecs(cur);
|
|
612
|
-
emitMatchEvent({ type: 'match_timeout', waited_secs: waitedSecs });
|
|
613
|
-
emitLifecycle('match_timeout', {
|
|
614
|
-
waited_secs: waitedSecs,
|
|
615
|
-
queue,
|
|
616
|
-
message: `No match after ${waitedSecs}s.`,
|
|
617
|
-
});
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
if (Date.now() - lastHeartbeat >= QUEUE_POLL_HEARTBEAT_MS) {
|
|
622
|
-
lastHeartbeat = Date.now();
|
|
623
|
-
const waitedSecs = cur ? getWaitedSecs(cur) : 0;
|
|
624
|
-
emitLifecycle('heartbeat', { waited_secs: waitedSecs },
|
|
625
|
-
'Stream is alive; still waiting for match allocation. Keep chatting with the user.');
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
await sleep(intervalMs);
|
|
629
|
-
}
|
|
630
|
-
};
|
|
631
|
-
const resumeQueue = async (source: string): Promise<void> => {
|
|
632
|
-
ensureEventSession(source);
|
|
633
|
-
if (!readMatchState(stateDir)) startMatch(stateDir);
|
|
634
|
-
await ensureEventRuntime();
|
|
635
|
-
await pollQueue(true);
|
|
636
|
-
};
|
|
637
|
-
const recoverAlreadyInGame = async (): Promise<boolean> => {
|
|
638
|
-
let queue: any;
|
|
639
|
-
try {
|
|
640
|
-
queue = await client.getQueueStatus('clawclaw');
|
|
641
|
-
} catch {
|
|
642
|
-
return false;
|
|
643
|
-
}
|
|
644
|
-
const status = queueStatus(queue);
|
|
645
|
-
if (status === 'allocated') {
|
|
646
|
-
ensureEventSession('game_start_already_in_game');
|
|
647
|
-
await ensureEventRuntime();
|
|
648
|
-
await handleAllocated(queue, true);
|
|
649
|
-
return true;
|
|
650
|
-
}
|
|
651
|
-
if (status === 'queued' || status === 'already_in_queue') {
|
|
652
|
-
await resumeQueue('game_start_already_in_game_queue');
|
|
653
|
-
return true;
|
|
654
|
-
}
|
|
655
|
-
return false;
|
|
656
|
-
};
|
|
657
|
-
|
|
658
|
-
const runningGameStartPid = getRunningGameStartPid(stateDir);
|
|
659
|
-
if (runningGameStartPid) {
|
|
660
|
-
if (opts.force) {
|
|
661
|
-
terminateProcessTree(runningGameStartPid, 'SIGKILL');
|
|
662
|
-
try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
|
|
663
|
-
try { unlinkSync(feedPath); } catch {}
|
|
664
|
-
} else {
|
|
665
|
-
emitLifecycle('already_running', { pid: runningGameStartPid },
|
|
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}`);
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
let initialQueue: any;
|
|
671
|
-
try {
|
|
672
|
-
initialQueue = await client.getQueueStatus('clawclaw');
|
|
673
|
-
} catch {}
|
|
674
|
-
const plan = planGameStartAction({
|
|
675
|
-
gameStartPid: null,
|
|
676
|
-
hasMatchState: readMatchState(stateDir) !== null,
|
|
677
|
-
queueStatus: queueStatus(initialQueue),
|
|
678
|
-
});
|
|
679
|
-
|
|
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() };
|
|
688
|
-
}
|
|
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 {
|
|
726
|
-
if (plan.kind === 'resume_queue') {
|
|
727
|
-
await resumeQueue('game_start_resume_queue');
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
if (plan.kind === 'resume_allocated') {
|
|
732
|
-
ensureEventSession('game_start_resume_allocated');
|
|
733
|
-
await ensureEventRuntime();
|
|
734
|
-
await handleAllocated(initialQueue, true);
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
let joinResult: any;
|
|
739
|
-
try {
|
|
740
|
-
joinResult = await client.joinQueue('clawclaw');
|
|
741
|
-
} catch (err) {
|
|
742
|
-
if (isAlreadyInGameError(err) && await recoverAlreadyInGame()) return;
|
|
743
|
-
throw err;
|
|
744
|
-
}
|
|
745
|
-
const joinedStatus = queueStatus(joinResult);
|
|
746
|
-
if (joinedStatus === 'already_in_queue') {
|
|
747
|
-
await resumeQueue('game_start_already_in_queue');
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
if (joinedStatus && joinedStatus !== 'queued') {
|
|
751
|
-
emitLifecycle('error', { queue: joinResult, message: `Unexpected queue status: ${joinedStatus}.` },
|
|
752
|
-
'Queue join did not enter the clawclaw queue. Check queue status before launching another `ccl game start`.');
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
stopOwnedStrategy();
|
|
757
|
-
|
|
758
|
-
const events = EventStore.createSessionForActiveAccount();
|
|
759
|
-
events.append({ type: 'session_started', source: 'game_start' });
|
|
760
|
-
emitLifecycle('joined', joinResult,
|
|
761
|
-
'Match join request acknowledged. Spectate URL is in `events[0].url`; share it with the user. Game runtime is attached.');
|
|
762
|
-
startMatch(stateDir);
|
|
763
|
-
await ensureEventRuntime();
|
|
764
|
-
await pollQueue(false);
|
|
765
|
-
} finally {
|
|
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 });
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
export function createGameCommand(): Command {
|
|
777
|
-
const game = new Command('game');
|
|
778
|
-
game.description('Game matchmaking & session');
|
|
779
|
-
|
|
780
|
-
game
|
|
781
|
-
.command('join')
|
|
782
|
-
.alias('j')
|
|
783
|
-
.description('Join matchmaking queue')
|
|
784
|
-
.action(async () => {
|
|
785
|
-
const authStore = new AuthStore();
|
|
786
|
-
const profile = authStore.getActive();
|
|
787
|
-
if (!profile) throw new Error('Not logged in.');
|
|
788
|
-
stopStrategyIfRunning();
|
|
789
|
-
|
|
790
|
-
const client = GameClient.fromAuth();
|
|
791
|
-
const result = await client.joinQueue('clawclaw');
|
|
792
|
-
const events = EventStore.createSessionForActiveAccount();
|
|
793
|
-
events.append({ type: 'session_started', source: 'game_join' });
|
|
794
|
-
console.log(JSON.stringify(result, null, 2));
|
|
795
|
-
const stateDir = getProfileStateDir(profile);
|
|
796
|
-
// Reset match-state: a fresh queue session starts here so `ccl game queue`
|
|
797
|
-
// can compute `waited_secs` from a known anchor and emit the
|
|
798
|
-
// match_waiting / match_timeout synthetic events.
|
|
799
|
-
startMatch(stateDir);
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
game
|
|
803
|
-
.command('start')
|
|
804
|
-
.alias('s')
|
|
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.')
|
|
806
|
-
.option('--no-watch', 'exit after allocation instead of streaming events through game_over')
|
|
807
|
-
.option('--force', 'force restart: kill any lingering game-start stream process and start fresh')
|
|
808
|
-
.action(runGameStart);
|
|
809
|
-
|
|
810
|
-
game
|
|
811
|
-
.command('queue')
|
|
812
|
-
.alias('q')
|
|
813
|
-
.description('Wait for matchmaking allocation. Always run as a background bash task (run_in_background: true) — never block on it. Returns when allocated, queue is missing, or timeout (default 30s).')
|
|
814
|
-
.option('-w, --wait', '(no-op, kept for backwards compatibility — wait mode is now the default)')
|
|
815
|
-
.option('--interval <secs>', 'Polling interval', '2')
|
|
816
|
-
.option('--timeout <secs>', 'Max seconds to wait, 0 means forever', String(DEFAULT_QUEUE_WAIT_TIMEOUT_SECS))
|
|
817
|
-
.action(async (opts) => {
|
|
818
|
-
const authStore = new AuthStore();
|
|
819
|
-
const profile = authStore.getActive();
|
|
820
|
-
if (!profile) throw new Error('Not logged in.');
|
|
821
|
-
const stateDir = getProfileStateDir(profile);
|
|
822
|
-
const client = GameClient.fromAuth();
|
|
823
|
-
|
|
824
|
-
const intervalMs = positiveNumber(opts.interval, 2) * 1000;
|
|
825
|
-
const timeoutSecs = Number(opts.timeout);
|
|
826
|
-
const deadline = Number.isFinite(timeoutSecs) && timeoutSecs > 0
|
|
827
|
-
? Date.now() + timeoutSecs * 1000
|
|
828
|
-
: 0;
|
|
829
|
-
|
|
830
|
-
// Synthetic matchmaking events feed the game start stream so the agent can
|
|
831
|
-
// observe match progress through the same channel as in-game events.
|
|
832
|
-
// Concurrency note: the agent typically keeps a 30s `ccl game queue`
|
|
833
|
-
// background chain running, so two queue invocations may observe the
|
|
834
|
-
// same `allocated` server state. That is
|
|
835
|
-
// intentionally fine — stream dedups via `eventKey` (type+tick+actor),
|
|
836
|
-
// so consumers only see one notification.
|
|
837
|
-
const emitMatchEvent = (evt: Record<string, any>): void => {
|
|
838
|
-
try {
|
|
839
|
-
const store = EventStore.forActiveAccount();
|
|
840
|
-
store.append({ ts: new Date().toISOString(), ...evt });
|
|
841
|
-
} catch {
|
|
842
|
-
// No active session (user ran queue without join) — skip silently;
|
|
843
|
-
// queue's own JSON return still informs the caller.
|
|
844
|
-
}
|
|
845
|
-
};
|
|
846
|
-
|
|
847
|
-
// On entry: emit match_waiting if state exists and heartbeat is due.
|
|
848
|
-
const initialState = readMatchState(stateDir);
|
|
849
|
-
if (initialState && shouldEmitWaiting(initialState)) {
|
|
850
|
-
const waitedSecs = getWaitedSecs(initialState);
|
|
851
|
-
emitMatchEvent({ type: 'match_waiting', waited_secs: waitedSecs });
|
|
852
|
-
markWaitingEmitted(stateDir);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
while (true) {
|
|
856
|
-
const queue = await client.getQueueStatus('clawclaw');
|
|
857
|
-
if (queueStatus(queue) === 'allocated') {
|
|
858
|
-
const finalState = readMatchState(stateDir);
|
|
859
|
-
const waitedSecs = finalState ? getWaitedSecs(finalState) : 0;
|
|
860
|
-
endMatch(stateDir);
|
|
861
|
-
const context = await fetchInitialGameContext(client);
|
|
862
|
-
console.log(JSON.stringify({
|
|
863
|
-
status: 'active_game',
|
|
864
|
-
queue,
|
|
865
|
-
waited_secs: waitedSecs,
|
|
866
|
-
...context,
|
|
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.',
|
|
868
|
-
}, null, 2));
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
if (queueStatus(queue) === 'not_in_queue') {
|
|
872
|
-
// Queue session is gone (user manually left or server expired it):
|
|
873
|
-
// clean the match-state so next `game join` starts fresh.
|
|
874
|
-
endMatch(stateDir);
|
|
875
|
-
console.log(JSON.stringify({
|
|
876
|
-
status: 'not_in_queue',
|
|
877
|
-
queue,
|
|
878
|
-
message: 'You are not in the matchmaking queue. Did you forget to run `ccl game join` first?',
|
|
879
|
-
next_step: 'Run `ccl game join` to re-enter the queue, then launch the next `ccl game queue` background task.',
|
|
880
|
-
}, null, 2));
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
if (deadline > 0 && Date.now() >= deadline) {
|
|
884
|
-
const cur = readMatchState(stateDir);
|
|
885
|
-
const waitedSecs = cur ? getWaitedSecs(cur) : timeoutSecs;
|
|
886
|
-
// Total-wait timeout (>=10 min by default) gets its own synthetic
|
|
887
|
-
// event so the agent can prompt the user to keep waiting / leave /
|
|
888
|
-
// retry later. Single 30s queue-attempt timeouts do NOT emit anything
|
|
889
|
-
// — they are an internal polling boundary, not a user-visible event.
|
|
890
|
-
if (cur && hasMatchTimedOut(cur)) {
|
|
891
|
-
emitMatchEvent({ type: 'match_timeout', waited_secs: waitedSecs });
|
|
892
|
-
}
|
|
893
|
-
console.log(JSON.stringify({
|
|
894
|
-
status: 'timeout',
|
|
895
|
-
waited_secs: waitedSecs,
|
|
896
|
-
queue,
|
|
897
|
-
message: `No match after ${timeoutSecs} seconds (total wait ${waitedSecs}s).`,
|
|
898
|
-
next_step: 'ZERO-GAP PROTOCOL: (1) Launch the next `ccl game queue` background task IMMEDIATELY. (2) While it runs, chat with the user — share strategy, persona, banter — never go silent.',
|
|
899
|
-
}, null, 2));
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
await sleep(intervalMs);
|
|
903
|
-
}
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
game
|
|
907
|
-
.command('leave')
|
|
908
|
-
.alias('l')
|
|
909
|
-
.description('Leave queue')
|
|
910
|
-
.action(async () => {
|
|
911
|
-
const authStore = new AuthStore();
|
|
912
|
-
const profile = authStore.getActive();
|
|
913
|
-
if (profile) {
|
|
914
|
-
const stateDir = getProfileStateDir(profile);
|
|
915
|
-
endMatch(stateDir);
|
|
916
|
-
await stopOwnerWithCommand(stateDir, 'leave');
|
|
917
|
-
}
|
|
918
|
-
stopStrategyIfRunning();
|
|
919
|
-
const client = GameClient.fromAuth();
|
|
920
|
-
const result = await client.leaveQueue('clawclaw');
|
|
921
|
-
console.log(JSON.stringify(result, null, 2));
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
game
|
|
926
|
-
.command('stop')
|
|
927
|
-
.alias('x')
|
|
928
|
-
.description('Stop local game runtime')
|
|
929
|
-
.action(async () => {
|
|
930
|
-
const authStore = new AuthStore();
|
|
931
|
-
const profile = authStore.getActive();
|
|
932
|
-
if (!profile) throw new Error('Not logged in.');
|
|
933
|
-
const stateDir = getProfileStateDir(profile);
|
|
934
|
-
const owner = await stopOwnerIfRunning(stateDir);
|
|
935
|
-
stopStrategyIfRunning();
|
|
936
|
-
if (!owner.stopped) {
|
|
937
|
-
console.log(JSON.stringify({ message: 'No game runtime running.' }, null, 2));
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
console.log(JSON.stringify({
|
|
941
|
-
message: 'Game runtime stopped.',
|
|
942
|
-
...(owner.pid ? { owner_pid: owner.pid } : {}),
|
|
943
|
-
}, null, 2));
|
|
944
|
-
});
|
|
945
|
-
|
|
946
|
-
game
|
|
947
|
-
.command('quit')
|
|
948
|
-
.description('Leave active game and stop local runtime')
|
|
949
|
-
.action(async () => {
|
|
950
|
-
const authStore = new AuthStore();
|
|
951
|
-
const profile = authStore.getActive();
|
|
952
|
-
if (!profile) throw new Error('Not logged in.');
|
|
953
|
-
const client = GameClient.fromAuth();
|
|
954
|
-
let result: any;
|
|
955
|
-
try {
|
|
956
|
-
result = await client.leaveGame();
|
|
957
|
-
} catch (err: any) {
|
|
958
|
-
result = { error: err?.message ?? String(err) };
|
|
959
|
-
}
|
|
960
|
-
const stateDir = getProfileStateDir(profile);
|
|
961
|
-
endMatch(stateDir);
|
|
962
|
-
const owner = await stopOwnerWithCommand(stateDir, 'quit');
|
|
963
|
-
stopStrategyIfRunning();
|
|
964
|
-
const reminder = hubReminder(readCachedGamesPlayed());
|
|
965
|
-
const out: Record<string, any> = (result && typeof result === 'object' && !Array.isArray(result))
|
|
966
|
-
? { ...result }
|
|
967
|
-
: { result };
|
|
968
|
-
if (reminder) {
|
|
969
|
-
out.hub_reminder = reminder;
|
|
970
|
-
out.next_step = `Left the game. ${reminder}`;
|
|
971
|
-
}
|
|
972
|
-
if (owner.pid) out.owner_pid = owner.pid;
|
|
973
|
-
console.log(JSON.stringify(out, null, 2));
|
|
974
|
-
});
|
|
975
|
-
|
|
976
|
-
game
|
|
977
|
-
.command('map')
|
|
978
|
-
.alias('m')
|
|
979
|
-
.description('Show game map')
|
|
980
|
-
.option('--ascii', 'Include packaged ASCII topology map')
|
|
981
|
-
.action(async (opts) => {
|
|
982
|
-
const client = GameClient.fromAuth();
|
|
983
|
-
await client.discoverGameServer();
|
|
984
|
-
const result = await client.getMap();
|
|
985
|
-
console.log(JSON.stringify(summarizeGameMap(result, { ascii: opts.ascii === true }), null, 2));
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
game
|
|
989
|
-
.command('tasks')
|
|
990
|
-
.alias('t')
|
|
991
|
-
.description('Show my tasks')
|
|
992
|
-
.action(async () => {
|
|
993
|
-
const client = GameClient.fromAuth();
|
|
994
|
-
await client.discoverGameServer();
|
|
995
|
-
const result = await client.getMap();
|
|
996
|
-
console.log(JSON.stringify(result?.your_tasks ?? [], null, 2));
|
|
997
|
-
});
|
|
998
|
-
|
|
999
|
-
game
|
|
1000
|
-
.command('role')
|
|
1001
|
-
.alias('r')
|
|
1002
|
-
.description('Show my role info')
|
|
1003
|
-
.action(async () => {
|
|
1004
|
-
const client = GameClient.fromAuth();
|
|
1005
|
-
const result = await client.getRoleInfo();
|
|
1006
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
game
|
|
1010
|
-
.command('watch')
|
|
1011
|
-
.alias('w')
|
|
1012
|
-
.description('Get spectating URL')
|
|
1013
|
-
.action(() => {
|
|
1014
|
-
const authStore = new AuthStore();
|
|
1015
|
-
const profile = authStore.getActive();
|
|
1016
|
-
if (!profile) throw new Error('Not logged in.');
|
|
1017
|
-
const origin = new URL(profile.serverUrl).origin.replace(/^http:\/\//, 'https://');
|
|
1018
|
-
const url = `${origin}/lobby?token=${encodeURIComponent(profile.apiKey)}`;
|
|
1019
|
-
console.log(JSON.stringify({ url, message: 'Open in browser to spectate.' }, null, 2));
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
// ── Adapter metadata: queue blocks up to --timeout secs (default 30); cap L2 spawn at 60s ──
|
|
1023
|
-
setMeta(game.commands.find((c) => c.name() === 'queue')!, { longRunning: true, timeoutMs: 60_000 });
|
|
1024
|
-
setMeta(game.commands.find((c) => c.name() === 'start')!, { longRunning: true, timeoutMs: 1_800_000 });
|
|
1025
|
-
|
|
1026
|
-
return game;
|
|
1027
|
-
}
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { spawnSync, type ChildProcess } from 'child_process';
|
|
5
|
+
import { AuthStore } from '../lib/auth.js';
|
|
6
|
+
import { ApiError, GameClient } from '../lib/game-client.js';
|
|
7
|
+
import { getProfileStateDir } from '../lib/init-command.js';
|
|
8
|
+
import { EventStore } from '../pipeline/event-store.js';
|
|
9
|
+
import { spawnStrategyLoop } from '../strategies/spawn.js';
|
|
10
|
+
import { stopStrategyIfRunning } from '../strategies/strategy-loop.js';
|
|
11
|
+
import { setMeta } from '../lib/command-meta.js';
|
|
12
|
+
import { runStreaming, buildErrorLine, summarizeFeed, nextStepFor } from './watch.js';
|
|
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';
|
|
23
|
+
import {
|
|
24
|
+
startMatch,
|
|
25
|
+
endMatch,
|
|
26
|
+
readMatchState,
|
|
27
|
+
shouldEmitWaiting,
|
|
28
|
+
markWaitingEmitted,
|
|
29
|
+
getWaitedSecs,
|
|
30
|
+
hasMatchTimedOut,
|
|
31
|
+
} from '../lib/match-state.js';
|
|
32
|
+
|
|
33
|
+
function sleep(ms: number): Promise<void> {
|
|
34
|
+
return new Promise(r => setTimeout(r, ms));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function positiveNumber(value: string, fallback: number): number {
|
|
38
|
+
const parsed = Number(value);
|
|
39
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function queueStatus(result: any): string | undefined {
|
|
43
|
+
return (result?.data ?? result)?.status;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_QUEUE_WAIT_TIMEOUT_SECS = 30;
|
|
47
|
+
|
|
48
|
+
function isPidAlive(pid: number): boolean {
|
|
49
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
|
|
50
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getRunningGameStartPid(stateDir: string): number | null {
|
|
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 {}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeGameStartRuntime(
|
|
63
|
+
stateDir: string,
|
|
64
|
+
mode: GameStartPlanKind,
|
|
65
|
+
phase?: string,
|
|
66
|
+
controlOverride?: { control: OwnerControlInfo; token: string },
|
|
67
|
+
): void {
|
|
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;
|
|
83
|
+
writeFileSync(gameStartRuntimePath(stateDir), JSON.stringify({
|
|
84
|
+
schema: 3,
|
|
85
|
+
owner_pid: process.pid,
|
|
86
|
+
pid: process.pid,
|
|
87
|
+
started_at: startedAt,
|
|
88
|
+
heartbeat_at: new Date().toISOString(),
|
|
89
|
+
mode,
|
|
90
|
+
...(control ? { control } : {}),
|
|
91
|
+
...(controlToken ? { control_token: controlToken } : {}),
|
|
92
|
+
...(phase ? { phase } : {}),
|
|
93
|
+
}, null, 2));
|
|
94
|
+
}
|
|
95
|
+
|
|
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 {
|
|
105
|
+
const runtimePath = gameStartRuntimePath(stateDir);
|
|
106
|
+
try {
|
|
107
|
+
const info = JSON.parse(readFileSync(runtimePath, 'utf8'));
|
|
108
|
+
const pid = Number(info?.owner_pid ?? info?.pid);
|
|
109
|
+
if (pid !== process.pid) return;
|
|
110
|
+
} catch {}
|
|
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 };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type QueueStatus = 'allocated' | 'queued' | 'not_in_queue' | string | undefined;
|
|
179
|
+
|
|
180
|
+
export type GameStartPlan =
|
|
181
|
+
| { kind: 'already_running'; pid: number }
|
|
182
|
+
| { kind: 'resume_queue' }
|
|
183
|
+
| { kind: 'resume_allocated' }
|
|
184
|
+
| { kind: 'fresh_start' };
|
|
185
|
+
|
|
186
|
+
type GameStartPlanKind = GameStartPlan['kind'];
|
|
187
|
+
|
|
188
|
+
export function planGameStartAction(input: {
|
|
189
|
+
gameStartPid?: number | null;
|
|
190
|
+
hasMatchState: boolean;
|
|
191
|
+
queueStatus?: QueueStatus;
|
|
192
|
+
}): GameStartPlan {
|
|
193
|
+
if (input.gameStartPid) return { kind: 'already_running', pid: input.gameStartPid };
|
|
194
|
+
|
|
195
|
+
if (input.queueStatus === 'allocated') return { kind: 'resume_allocated' };
|
|
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' };
|
|
198
|
+
return { kind: 'fresh_start' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isAlreadyInGameError(err: unknown): boolean {
|
|
202
|
+
return err instanceof ApiError && err.body.includes('ALREADY_IN_GAME');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ensureEventSession(source: string): EventStore {
|
|
206
|
+
const existing = EventStore.latestSessionPath();
|
|
207
|
+
if (existing) {
|
|
208
|
+
const events = EventStore.forActiveAccount();
|
|
209
|
+
events.append({ type: 'session_resumed', source });
|
|
210
|
+
return events;
|
|
211
|
+
}
|
|
212
|
+
const events = EventStore.createSessionForActiveAccount();
|
|
213
|
+
events.append({ type: 'session_started', source });
|
|
214
|
+
return events;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
218
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function unwrapData(value: any): any {
|
|
222
|
+
return value?.data ?? value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface GameStrategyIdentity {
|
|
226
|
+
gameId?: string;
|
|
227
|
+
role?: string;
|
|
228
|
+
alive: boolean | null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function gameStrategyIdentity(stateData: any, roleData: any): GameStrategyIdentity {
|
|
232
|
+
const state = unwrapData(stateData);
|
|
233
|
+
const role = unwrapData(roleData);
|
|
234
|
+
const you = state?.you ?? {};
|
|
235
|
+
const aliveRaw = you?.is_alive ?? you?.alive;
|
|
236
|
+
return {
|
|
237
|
+
gameId: nonEmptyString(state?.game_id) ?? nonEmptyString(state?.game?.id) ?? nonEmptyString(state?.game?.game_id),
|
|
238
|
+
role: nonEmptyString(role?.role) ?? nonEmptyString(you?.role),
|
|
239
|
+
alive: typeof aliveRaw === 'boolean' ? aliveRaw : null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function commandError(command: string, err: unknown): { command: string; error: string } {
|
|
244
|
+
const message = err instanceof ApiError
|
|
245
|
+
? `${err.status}: ${err.body}`
|
|
246
|
+
: err instanceof Error
|
|
247
|
+
? err.message
|
|
248
|
+
: String(err);
|
|
249
|
+
return { command, error: message };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function briefMap(mapData: any): any {
|
|
253
|
+
if (!mapData) return null;
|
|
254
|
+
return {
|
|
255
|
+
rooms: mapData.rooms?.map((r: any) => ({
|
|
256
|
+
name: r.name,
|
|
257
|
+
polygon: r.polygon,
|
|
258
|
+
})),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function taskLocations(mapData: any): { lobster: any[]; crab: any[] } {
|
|
263
|
+
const taskLocations = { lobster: [] as any[], crab: [] as any[] };
|
|
264
|
+
for (const task of Array.isArray(mapData?.all_task_locations) ? mapData.all_task_locations : []) {
|
|
265
|
+
const item = {
|
|
266
|
+
name: task.name,
|
|
267
|
+
room: task.room,
|
|
268
|
+
x: task.x,
|
|
269
|
+
y: task.y,
|
|
270
|
+
};
|
|
271
|
+
if (task.faction === 'crab') taskLocations.crab.push(item);
|
|
272
|
+
else taskLocations.lobster.push(item);
|
|
273
|
+
}
|
|
274
|
+
return taskLocations;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function loadAsciiMapAsset(): string | null {
|
|
278
|
+
try {
|
|
279
|
+
return readFileSync(new URL('../assets/clawclaw-ascii-map.txt', import.meta.url), 'utf8').trimEnd();
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function summarizeGameMap(mapData: any, opts?: { ascii?: boolean }): any {
|
|
286
|
+
const output: Record<string, any> = {
|
|
287
|
+
rooms: mapData?.rooms?.map((r: any) => ({
|
|
288
|
+
name: r.name,
|
|
289
|
+
polygon: r.polygon,
|
|
290
|
+
})) ?? [],
|
|
291
|
+
task_locations: taskLocations(mapData),
|
|
292
|
+
};
|
|
293
|
+
if (opts?.ascii) {
|
|
294
|
+
const ascii = loadAsciiMapAsset();
|
|
295
|
+
output.ascii_map = ascii;
|
|
296
|
+
}
|
|
297
|
+
return output;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function fetchInitialGameContext(client: GameClient): Promise<{
|
|
301
|
+
state: any | null;
|
|
302
|
+
role: any | null;
|
|
303
|
+
map: any | null;
|
|
304
|
+
tasks: any[] | null;
|
|
305
|
+
errors?: Array<{ command: string; error: string }>;
|
|
306
|
+
}> {
|
|
307
|
+
await sleep(500);
|
|
308
|
+
const [stateResult, roleResult, mapResult] = await Promise.allSettled([
|
|
309
|
+
client.getGameState(),
|
|
310
|
+
client.getRoleInfo(),
|
|
311
|
+
client.getMap(),
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
const errors: Array<{ command: string; error: string }> = [];
|
|
315
|
+
if (stateResult.status === 'rejected') errors.push(commandError('state', stateResult.reason));
|
|
316
|
+
if (roleResult.status === 'rejected') errors.push(commandError('game role', roleResult.reason));
|
|
317
|
+
if (mapResult.status === 'rejected') errors.push(commandError('game map', mapResult.reason));
|
|
318
|
+
|
|
319
|
+
const mapData = mapResult.status === 'fulfilled' ? mapResult.value : null;
|
|
320
|
+
const context = {
|
|
321
|
+
state: stateResult.status === 'fulfilled' ? stateResult.value : null,
|
|
322
|
+
role: roleResult.status === 'fulfilled' ? roleResult.value : null,
|
|
323
|
+
map: briefMap(mapData),
|
|
324
|
+
tasks: mapData?.your_tasks ?? null,
|
|
325
|
+
...(errors.length ? { errors } : {}),
|
|
326
|
+
};
|
|
327
|
+
return context;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const ROLE_DEFAULT_STRATEGY: Record<string, string> = {
|
|
331
|
+
'shrimp_generic': 'task-report',
|
|
332
|
+
'shrimp_warrior': 'task-report',
|
|
333
|
+
'shrimp_pistol': 'task-report',
|
|
334
|
+
'crab_generic': 'crab-sabotage',
|
|
335
|
+
'neutral_paradise_fish': 'corpse-patrol',
|
|
336
|
+
'neutral_octopus': 'lone-kill-task',
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { strategy: string; pid: number | undefined; child: ChildProcess } | null {
|
|
340
|
+
const roleId: string | undefined = roleData?.data?.role ?? roleData?.role;
|
|
341
|
+
if (!roleId) return null;
|
|
342
|
+
const strategyId = ROLE_DEFAULT_STRATEGY[roleId];
|
|
343
|
+
if (!strategyId) return null;
|
|
344
|
+
const child = spawnStrategyLoop(strategyId, [roleId], {
|
|
345
|
+
source: 'auto_start',
|
|
346
|
+
gameId: gameId ?? gameStrategyIdentity(stateData, roleData).gameId,
|
|
347
|
+
role: roleId,
|
|
348
|
+
detached: false,
|
|
349
|
+
writeRuntimeFiles: false,
|
|
350
|
+
});
|
|
351
|
+
return { strategy: strategyId, pid: child.pid, child };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<void> {
|
|
355
|
+
if (process.env.CLAWCLAW_REQUIRE_STREAM_TOOL === '1' && process.env.CLAWCLAW_STREAMED !== '1') {
|
|
356
|
+
process.stderr.write(
|
|
357
|
+
'In OpenClaw, start the game via the clawclaw_game_start tool - do not exec `ccl game start` directly.\n' +
|
|
358
|
+
'When run raw, the event NDJSON only fills an unconsumed buffer, so you never receive speech_your_turn and stay silent.\n',
|
|
359
|
+
);
|
|
360
|
+
process.exit(2);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const authStore = new AuthStore();
|
|
364
|
+
const profile = authStore.getActive();
|
|
365
|
+
if (!profile) throw new Error('Not logged in.');
|
|
366
|
+
|
|
367
|
+
const stateDir = getProfileStateDir(profile);
|
|
368
|
+
const feedPath = join(stateDir, 'feed.json');
|
|
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);
|
|
422
|
+
const emit = (obj: Record<string, any>): void => {
|
|
423
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
424
|
+
};
|
|
425
|
+
const emitLifecycle = (
|
|
426
|
+
reason: string,
|
|
427
|
+
payload: Record<string, any>,
|
|
428
|
+
nextStepOverride?: string,
|
|
429
|
+
): void => {
|
|
430
|
+
const event = { ...payload, type: reason };
|
|
431
|
+
emit({
|
|
432
|
+
exit_reason: [reason],
|
|
433
|
+
next_step: nextStepOverride ?? nextStepFor(reason),
|
|
434
|
+
events: [event],
|
|
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' },
|
|
447
|
+
});
|
|
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
|
+
};
|
|
456
|
+
const emitMatchEvent = (evt: Record<string, any>): void => {
|
|
457
|
+
try {
|
|
458
|
+
const store = EventStore.forActiveAccount();
|
|
459
|
+
store.append({ ts: new Date().toISOString(), ...evt });
|
|
460
|
+
} catch {}
|
|
461
|
+
};
|
|
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();
|
|
473
|
+
};
|
|
474
|
+
const streamGame = async (): Promise<void> => {
|
|
475
|
+
const sessionPath = EventStore.latestSessionPath();
|
|
476
|
+
const ctrl = new AbortController();
|
|
477
|
+
streamAbortController = ctrl;
|
|
478
|
+
const onSignal = (): void => {
|
|
479
|
+
ctrl.abort();
|
|
480
|
+
eventRuntime?.stop('SIGTERM');
|
|
481
|
+
};
|
|
482
|
+
process.on('SIGINT', onSignal);
|
|
483
|
+
process.on('SIGTERM', onSignal);
|
|
484
|
+
try {
|
|
485
|
+
await runStreaming({
|
|
486
|
+
feedPath,
|
|
487
|
+
sessionPath,
|
|
488
|
+
getSessionPath: () => EventStore.latestSessionPath(),
|
|
489
|
+
stdout: (s) => process.stdout.write(s),
|
|
490
|
+
signal: ctrl.signal,
|
|
491
|
+
skipFeedWait: true,
|
|
492
|
+
readSummary: currentSummary,
|
|
493
|
+
skipBacklogTypes: ['match_waiting', 'match_timeout'],
|
|
494
|
+
emitGameStart: true,
|
|
495
|
+
hubReminder: hubReminder(readCachedGamesPlayed()),
|
|
496
|
+
});
|
|
497
|
+
} catch (err: any) {
|
|
498
|
+
process.stdout.write(buildErrorLine(err));
|
|
499
|
+
process.exitCode = 1;
|
|
500
|
+
} finally {
|
|
501
|
+
process.off('SIGINT', onSignal);
|
|
502
|
+
process.off('SIGTERM', onSignal);
|
|
503
|
+
streamAbortController = null;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
const handleAllocated = async (queue: any, preserveStrategy: boolean): Promise<void> => {
|
|
507
|
+
const finalState = readMatchState(stateDir);
|
|
508
|
+
const waitedSecs = finalState ? getWaitedSecs(finalState) : 0;
|
|
509
|
+
endMatch(stateDir);
|
|
510
|
+
const context = await fetchInitialGameContext(client);
|
|
511
|
+
const identity = gameStrategyIdentity(context.state, context.role);
|
|
512
|
+
// Supplement game_id from queue response when /game/current doesn't carry it yet.
|
|
513
|
+
const queueGameId = nonEmptyString(queue?.game_id) ?? nonEmptyString(queue?.data?.game_id);
|
|
514
|
+
if (!identity.gameId && queueGameId) {
|
|
515
|
+
identity.gameId = queueGameId;
|
|
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
|
+
};
|
|
534
|
+
let strategyInfo: { strategy: string; pid: number | undefined } | null = null;
|
|
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 };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const allocationPayload = {
|
|
545
|
+
queue,
|
|
546
|
+
waited_secs: waitedSecs,
|
|
547
|
+
game_id: identity.gameId || undefined,
|
|
548
|
+
...context,
|
|
549
|
+
...(strategyInfo ? { default_strategy: strategyInfo } : {}),
|
|
550
|
+
};
|
|
551
|
+
if (!opts.watch) {
|
|
552
|
+
emitLifecycle('allocated', allocationPayload,
|
|
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.');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await ensureEventRuntime();
|
|
557
|
+
await streamGame();
|
|
558
|
+
};
|
|
559
|
+
const pollQueue = async (preserveStrategy: boolean): Promise<void> => {
|
|
560
|
+
const intervalMs = 2000;
|
|
561
|
+
const QUEUE_POLL_HEARTBEAT_MS = 60_000;
|
|
562
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
563
|
+
let lastHeartbeat = Date.now();
|
|
564
|
+
let consecutiveFailures = 0;
|
|
565
|
+
|
|
566
|
+
while (!ownerExitRequested) {
|
|
567
|
+
let queue: any;
|
|
568
|
+
try {
|
|
569
|
+
queue = await client.getQueueStatus('clawclaw');
|
|
570
|
+
consecutiveFailures = 0;
|
|
571
|
+
} catch (err: any) {
|
|
572
|
+
consecutiveFailures++;
|
|
573
|
+
const msg = err?.message ?? String(err);
|
|
574
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
575
|
+
emitLifecycle('error', {
|
|
576
|
+
error: msg,
|
|
577
|
+
consecutive_failures: consecutiveFailures,
|
|
578
|
+
message: `Queue status polling failed ${consecutiveFailures} times consecutively.`,
|
|
579
|
+
}, 'Queue polling has failed repeatedly. Check network, then launch a fresh `ccl game start` to retry.');
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
emitLifecycle('poll_error', {
|
|
583
|
+
error: msg,
|
|
584
|
+
consecutive_failures: consecutiveFailures,
|
|
585
|
+
message: `Queue poll failed (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}). Retrying...`,
|
|
586
|
+
}, 'Temporary polling failure. The stream will keep retrying - stay attached.');
|
|
587
|
+
await sleep(intervalMs);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (queueStatus(queue) === 'allocated') {
|
|
592
|
+
await handleAllocated(queue, preserveStrategy);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (queueStatus(queue) === 'not_in_queue') {
|
|
596
|
+
endMatch(stateDir);
|
|
597
|
+
emitLifecycle('not_in_queue', { queue, message: 'Left or dropped from queue.' },
|
|
598
|
+
'No longer in matchmaking queue. The stream will exit; launch a fresh `ccl game start` to retry if the user wants to continue.');
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const cur = readMatchState(stateDir);
|
|
603
|
+
if (cur && shouldEmitWaiting(cur)) {
|
|
604
|
+
const waitedSecs = getWaitedSecs(cur);
|
|
605
|
+
emitMatchEvent({ type: 'match_waiting', waited_secs: waitedSecs });
|
|
606
|
+
emitLifecycle('match_waiting', { waited_secs: waitedSecs });
|
|
607
|
+
markWaitingEmitted(stateDir);
|
|
608
|
+
lastHeartbeat = Date.now();
|
|
609
|
+
}
|
|
610
|
+
if (cur && hasMatchTimedOut(cur)) {
|
|
611
|
+
const waitedSecs = getWaitedSecs(cur);
|
|
612
|
+
emitMatchEvent({ type: 'match_timeout', waited_secs: waitedSecs });
|
|
613
|
+
emitLifecycle('match_timeout', {
|
|
614
|
+
waited_secs: waitedSecs,
|
|
615
|
+
queue,
|
|
616
|
+
message: `No match after ${waitedSecs}s.`,
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (Date.now() - lastHeartbeat >= QUEUE_POLL_HEARTBEAT_MS) {
|
|
622
|
+
lastHeartbeat = Date.now();
|
|
623
|
+
const waitedSecs = cur ? getWaitedSecs(cur) : 0;
|
|
624
|
+
emitLifecycle('heartbeat', { waited_secs: waitedSecs },
|
|
625
|
+
'Stream is alive; still waiting for match allocation. Keep chatting with the user.');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
await sleep(intervalMs);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
const resumeQueue = async (source: string): Promise<void> => {
|
|
632
|
+
ensureEventSession(source);
|
|
633
|
+
if (!readMatchState(stateDir)) startMatch(stateDir);
|
|
634
|
+
await ensureEventRuntime();
|
|
635
|
+
await pollQueue(true);
|
|
636
|
+
};
|
|
637
|
+
const recoverAlreadyInGame = async (): Promise<boolean> => {
|
|
638
|
+
let queue: any;
|
|
639
|
+
try {
|
|
640
|
+
queue = await client.getQueueStatus('clawclaw');
|
|
641
|
+
} catch {
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
const status = queueStatus(queue);
|
|
645
|
+
if (status === 'allocated') {
|
|
646
|
+
ensureEventSession('game_start_already_in_game');
|
|
647
|
+
await ensureEventRuntime();
|
|
648
|
+
await handleAllocated(queue, true);
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
if (status === 'queued' || status === 'already_in_queue') {
|
|
652
|
+
await resumeQueue('game_start_already_in_game_queue');
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const runningGameStartPid = getRunningGameStartPid(stateDir);
|
|
659
|
+
if (runningGameStartPid) {
|
|
660
|
+
if (opts.force) {
|
|
661
|
+
terminateProcessTree(runningGameStartPid, 'SIGKILL');
|
|
662
|
+
try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
|
|
663
|
+
try { unlinkSync(feedPath); } catch {}
|
|
664
|
+
} else {
|
|
665
|
+
emitLifecycle('already_running', { pid: runningGameStartPid },
|
|
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}`);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
let initialQueue: any;
|
|
671
|
+
try {
|
|
672
|
+
initialQueue = await client.getQueueStatus('clawclaw');
|
|
673
|
+
} catch {}
|
|
674
|
+
const plan = planGameStartAction({
|
|
675
|
+
gameStartPid: null,
|
|
676
|
+
hasMatchState: readMatchState(stateDir) !== null,
|
|
677
|
+
queueStatus: queueStatus(initialQueue),
|
|
678
|
+
});
|
|
679
|
+
|
|
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() };
|
|
688
|
+
}
|
|
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 {
|
|
726
|
+
if (plan.kind === 'resume_queue') {
|
|
727
|
+
await resumeQueue('game_start_resume_queue');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (plan.kind === 'resume_allocated') {
|
|
732
|
+
ensureEventSession('game_start_resume_allocated');
|
|
733
|
+
await ensureEventRuntime();
|
|
734
|
+
await handleAllocated(initialQueue, true);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let joinResult: any;
|
|
739
|
+
try {
|
|
740
|
+
joinResult = await client.joinQueue('clawclaw');
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (isAlreadyInGameError(err) && await recoverAlreadyInGame()) return;
|
|
743
|
+
throw err;
|
|
744
|
+
}
|
|
745
|
+
const joinedStatus = queueStatus(joinResult);
|
|
746
|
+
if (joinedStatus === 'already_in_queue') {
|
|
747
|
+
await resumeQueue('game_start_already_in_queue');
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (joinedStatus && joinedStatus !== 'queued') {
|
|
751
|
+
emitLifecycle('error', { queue: joinResult, message: `Unexpected queue status: ${joinedStatus}.` },
|
|
752
|
+
'Queue join did not enter the clawclaw queue. Check queue status before launching another `ccl game start`.');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
stopOwnedStrategy();
|
|
757
|
+
|
|
758
|
+
const events = EventStore.createSessionForActiveAccount();
|
|
759
|
+
events.append({ type: 'session_started', source: 'game_start' });
|
|
760
|
+
emitLifecycle('joined', joinResult,
|
|
761
|
+
'Match join request acknowledged. Spectate URL is in `events[0].url`; share it with the user. Game runtime is attached.');
|
|
762
|
+
startMatch(stateDir);
|
|
763
|
+
await ensureEventRuntime();
|
|
764
|
+
await pollQueue(false);
|
|
765
|
+
} finally {
|
|
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 });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function createGameCommand(): Command {
|
|
777
|
+
const game = new Command('game');
|
|
778
|
+
game.description('Game matchmaking & session');
|
|
779
|
+
|
|
780
|
+
game
|
|
781
|
+
.command('join')
|
|
782
|
+
.alias('j')
|
|
783
|
+
.description('Join matchmaking queue')
|
|
784
|
+
.action(async () => {
|
|
785
|
+
const authStore = new AuthStore();
|
|
786
|
+
const profile = authStore.getActive();
|
|
787
|
+
if (!profile) throw new Error('Not logged in.');
|
|
788
|
+
stopStrategyIfRunning();
|
|
789
|
+
|
|
790
|
+
const client = GameClient.fromAuth();
|
|
791
|
+
const result = await client.joinQueue('clawclaw');
|
|
792
|
+
const events = EventStore.createSessionForActiveAccount();
|
|
793
|
+
events.append({ type: 'session_started', source: 'game_join' });
|
|
794
|
+
console.log(JSON.stringify(result, null, 2));
|
|
795
|
+
const stateDir = getProfileStateDir(profile);
|
|
796
|
+
// Reset match-state: a fresh queue session starts here so `ccl game queue`
|
|
797
|
+
// can compute `waited_secs` from a known anchor and emit the
|
|
798
|
+
// match_waiting / match_timeout synthetic events.
|
|
799
|
+
startMatch(stateDir);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
game
|
|
803
|
+
.command('start')
|
|
804
|
+
.alias('s')
|
|
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.')
|
|
806
|
+
.option('--no-watch', 'exit after allocation instead of streaming events through game_over')
|
|
807
|
+
.option('--force', 'force restart: kill any lingering game-start stream process and start fresh')
|
|
808
|
+
.action(runGameStart);
|
|
809
|
+
|
|
810
|
+
game
|
|
811
|
+
.command('queue')
|
|
812
|
+
.alias('q')
|
|
813
|
+
.description('Wait for matchmaking allocation. Always run as a background bash task (run_in_background: true) — never block on it. Returns when allocated, queue is missing, or timeout (default 30s).')
|
|
814
|
+
.option('-w, --wait', '(no-op, kept for backwards compatibility — wait mode is now the default)')
|
|
815
|
+
.option('--interval <secs>', 'Polling interval', '2')
|
|
816
|
+
.option('--timeout <secs>', 'Max seconds to wait, 0 means forever', String(DEFAULT_QUEUE_WAIT_TIMEOUT_SECS))
|
|
817
|
+
.action(async (opts) => {
|
|
818
|
+
const authStore = new AuthStore();
|
|
819
|
+
const profile = authStore.getActive();
|
|
820
|
+
if (!profile) throw new Error('Not logged in.');
|
|
821
|
+
const stateDir = getProfileStateDir(profile);
|
|
822
|
+
const client = GameClient.fromAuth();
|
|
823
|
+
|
|
824
|
+
const intervalMs = positiveNumber(opts.interval, 2) * 1000;
|
|
825
|
+
const timeoutSecs = Number(opts.timeout);
|
|
826
|
+
const deadline = Number.isFinite(timeoutSecs) && timeoutSecs > 0
|
|
827
|
+
? Date.now() + timeoutSecs * 1000
|
|
828
|
+
: 0;
|
|
829
|
+
|
|
830
|
+
// Synthetic matchmaking events feed the game start stream so the agent can
|
|
831
|
+
// observe match progress through the same channel as in-game events.
|
|
832
|
+
// Concurrency note: the agent typically keeps a 30s `ccl game queue`
|
|
833
|
+
// background chain running, so two queue invocations may observe the
|
|
834
|
+
// same `allocated` server state. That is
|
|
835
|
+
// intentionally fine — stream dedups via `eventKey` (type+tick+actor),
|
|
836
|
+
// so consumers only see one notification.
|
|
837
|
+
const emitMatchEvent = (evt: Record<string, any>): void => {
|
|
838
|
+
try {
|
|
839
|
+
const store = EventStore.forActiveAccount();
|
|
840
|
+
store.append({ ts: new Date().toISOString(), ...evt });
|
|
841
|
+
} catch {
|
|
842
|
+
// No active session (user ran queue without join) — skip silently;
|
|
843
|
+
// queue's own JSON return still informs the caller.
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
// On entry: emit match_waiting if state exists and heartbeat is due.
|
|
848
|
+
const initialState = readMatchState(stateDir);
|
|
849
|
+
if (initialState && shouldEmitWaiting(initialState)) {
|
|
850
|
+
const waitedSecs = getWaitedSecs(initialState);
|
|
851
|
+
emitMatchEvent({ type: 'match_waiting', waited_secs: waitedSecs });
|
|
852
|
+
markWaitingEmitted(stateDir);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
while (true) {
|
|
856
|
+
const queue = await client.getQueueStatus('clawclaw');
|
|
857
|
+
if (queueStatus(queue) === 'allocated') {
|
|
858
|
+
const finalState = readMatchState(stateDir);
|
|
859
|
+
const waitedSecs = finalState ? getWaitedSecs(finalState) : 0;
|
|
860
|
+
endMatch(stateDir);
|
|
861
|
+
const context = await fetchInitialGameContext(client);
|
|
862
|
+
console.log(JSON.stringify({
|
|
863
|
+
status: 'active_game',
|
|
864
|
+
queue,
|
|
865
|
+
waited_secs: waitedSecs,
|
|
866
|
+
...context,
|
|
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.',
|
|
868
|
+
}, null, 2));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (queueStatus(queue) === 'not_in_queue') {
|
|
872
|
+
// Queue session is gone (user manually left or server expired it):
|
|
873
|
+
// clean the match-state so next `game join` starts fresh.
|
|
874
|
+
endMatch(stateDir);
|
|
875
|
+
console.log(JSON.stringify({
|
|
876
|
+
status: 'not_in_queue',
|
|
877
|
+
queue,
|
|
878
|
+
message: 'You are not in the matchmaking queue. Did you forget to run `ccl game join` first?',
|
|
879
|
+
next_step: 'Run `ccl game join` to re-enter the queue, then launch the next `ccl game queue` background task.',
|
|
880
|
+
}, null, 2));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (deadline > 0 && Date.now() >= deadline) {
|
|
884
|
+
const cur = readMatchState(stateDir);
|
|
885
|
+
const waitedSecs = cur ? getWaitedSecs(cur) : timeoutSecs;
|
|
886
|
+
// Total-wait timeout (>=10 min by default) gets its own synthetic
|
|
887
|
+
// event so the agent can prompt the user to keep waiting / leave /
|
|
888
|
+
// retry later. Single 30s queue-attempt timeouts do NOT emit anything
|
|
889
|
+
// — they are an internal polling boundary, not a user-visible event.
|
|
890
|
+
if (cur && hasMatchTimedOut(cur)) {
|
|
891
|
+
emitMatchEvent({ type: 'match_timeout', waited_secs: waitedSecs });
|
|
892
|
+
}
|
|
893
|
+
console.log(JSON.stringify({
|
|
894
|
+
status: 'timeout',
|
|
895
|
+
waited_secs: waitedSecs,
|
|
896
|
+
queue,
|
|
897
|
+
message: `No match after ${timeoutSecs} seconds (total wait ${waitedSecs}s).`,
|
|
898
|
+
next_step: 'ZERO-GAP PROTOCOL: (1) Launch the next `ccl game queue` background task IMMEDIATELY. (2) While it runs, chat with the user — share strategy, persona, banter — never go silent.',
|
|
899
|
+
}, null, 2));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
await sleep(intervalMs);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
game
|
|
907
|
+
.command('leave')
|
|
908
|
+
.alias('l')
|
|
909
|
+
.description('Leave queue')
|
|
910
|
+
.action(async () => {
|
|
911
|
+
const authStore = new AuthStore();
|
|
912
|
+
const profile = authStore.getActive();
|
|
913
|
+
if (profile) {
|
|
914
|
+
const stateDir = getProfileStateDir(profile);
|
|
915
|
+
endMatch(stateDir);
|
|
916
|
+
await stopOwnerWithCommand(stateDir, 'leave');
|
|
917
|
+
}
|
|
918
|
+
stopStrategyIfRunning();
|
|
919
|
+
const client = GameClient.fromAuth();
|
|
920
|
+
const result = await client.leaveQueue('clawclaw');
|
|
921
|
+
console.log(JSON.stringify(result, null, 2));
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
game
|
|
926
|
+
.command('stop')
|
|
927
|
+
.alias('x')
|
|
928
|
+
.description('Stop local game runtime')
|
|
929
|
+
.action(async () => {
|
|
930
|
+
const authStore = new AuthStore();
|
|
931
|
+
const profile = authStore.getActive();
|
|
932
|
+
if (!profile) throw new Error('Not logged in.');
|
|
933
|
+
const stateDir = getProfileStateDir(profile);
|
|
934
|
+
const owner = await stopOwnerIfRunning(stateDir);
|
|
935
|
+
stopStrategyIfRunning();
|
|
936
|
+
if (!owner.stopped) {
|
|
937
|
+
console.log(JSON.stringify({ message: 'No game runtime running.' }, null, 2));
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
console.log(JSON.stringify({
|
|
941
|
+
message: 'Game runtime stopped.',
|
|
942
|
+
...(owner.pid ? { owner_pid: owner.pid } : {}),
|
|
943
|
+
}, null, 2));
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
game
|
|
947
|
+
.command('quit')
|
|
948
|
+
.description('Leave active game and stop local runtime')
|
|
949
|
+
.action(async () => {
|
|
950
|
+
const authStore = new AuthStore();
|
|
951
|
+
const profile = authStore.getActive();
|
|
952
|
+
if (!profile) throw new Error('Not logged in.');
|
|
953
|
+
const client = GameClient.fromAuth();
|
|
954
|
+
let result: any;
|
|
955
|
+
try {
|
|
956
|
+
result = await client.leaveGame();
|
|
957
|
+
} catch (err: any) {
|
|
958
|
+
result = { error: err?.message ?? String(err) };
|
|
959
|
+
}
|
|
960
|
+
const stateDir = getProfileStateDir(profile);
|
|
961
|
+
endMatch(stateDir);
|
|
962
|
+
const owner = await stopOwnerWithCommand(stateDir, 'quit');
|
|
963
|
+
stopStrategyIfRunning();
|
|
964
|
+
const reminder = hubReminder(readCachedGamesPlayed());
|
|
965
|
+
const out: Record<string, any> = (result && typeof result === 'object' && !Array.isArray(result))
|
|
966
|
+
? { ...result }
|
|
967
|
+
: { result };
|
|
968
|
+
if (reminder) {
|
|
969
|
+
out.hub_reminder = reminder;
|
|
970
|
+
out.next_step = `Left the game. ${reminder}`;
|
|
971
|
+
}
|
|
972
|
+
if (owner.pid) out.owner_pid = owner.pid;
|
|
973
|
+
console.log(JSON.stringify(out, null, 2));
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
game
|
|
977
|
+
.command('map')
|
|
978
|
+
.alias('m')
|
|
979
|
+
.description('Show game map')
|
|
980
|
+
.option('--ascii', 'Include packaged ASCII topology map')
|
|
981
|
+
.action(async (opts) => {
|
|
982
|
+
const client = GameClient.fromAuth();
|
|
983
|
+
await client.discoverGameServer();
|
|
984
|
+
const result = await client.getMap();
|
|
985
|
+
console.log(JSON.stringify(summarizeGameMap(result, { ascii: opts.ascii === true }), null, 2));
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
game
|
|
989
|
+
.command('tasks')
|
|
990
|
+
.alias('t')
|
|
991
|
+
.description('Show my tasks')
|
|
992
|
+
.action(async () => {
|
|
993
|
+
const client = GameClient.fromAuth();
|
|
994
|
+
await client.discoverGameServer();
|
|
995
|
+
const result = await client.getMap();
|
|
996
|
+
console.log(JSON.stringify(result?.your_tasks ?? [], null, 2));
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
game
|
|
1000
|
+
.command('role')
|
|
1001
|
+
.alias('r')
|
|
1002
|
+
.description('Show my role info')
|
|
1003
|
+
.action(async () => {
|
|
1004
|
+
const client = GameClient.fromAuth();
|
|
1005
|
+
const result = await client.getRoleInfo();
|
|
1006
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
game
|
|
1010
|
+
.command('watch')
|
|
1011
|
+
.alias('w')
|
|
1012
|
+
.description('Get spectating URL')
|
|
1013
|
+
.action(() => {
|
|
1014
|
+
const authStore = new AuthStore();
|
|
1015
|
+
const profile = authStore.getActive();
|
|
1016
|
+
if (!profile) throw new Error('Not logged in.');
|
|
1017
|
+
const origin = new URL(profile.serverUrl).origin.replace(/^http:\/\//, 'https://');
|
|
1018
|
+
const url = `${origin}/lobby?token=${encodeURIComponent(profile.apiKey)}`;
|
|
1019
|
+
console.log(JSON.stringify({ url, message: 'Open in browser to spectate.' }, null, 2));
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// ── Adapter metadata: queue blocks up to --timeout secs (default 30); cap L2 spawn at 60s ──
|
|
1023
|
+
setMeta(game.commands.find((c) => c.name() === 'queue')!, { longRunning: true, timeoutMs: 60_000 });
|
|
1024
|
+
setMeta(game.commands.find((c) => c.name() === 'start')!, { longRunning: true, timeoutMs: 1_800_000 });
|
|
1025
|
+
|
|
1026
|
+
return game;
|
|
1027
|
+
}
|