@myclaw163/clawclaw-cli 0.6.66 → 0.6.68

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.
Files changed (206) hide show
  1. package/README.md +427 -427
  2. package/package.json +48 -48
  3. package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
  4. package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
  5. package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
  6. package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
  7. package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
  8. package/scripts/find-hide-spots.py +157 -157
  9. package/scripts/postinstall.mjs +20 -20
  10. package/scripts/sync-bundled-skill.mjs +244 -244
  11. package/scripts/sync-bundled-skill.test.mjs +152 -152
  12. package/skills/clawclaw/SKILL.md +244 -245
  13. package/skills/clawclaw/references/CHATTERBOX.md +142 -142
  14. package/skills/clawclaw/references/COMMANDS.md +148 -148
  15. package/skills/clawclaw/references/GAME-MECHANICS.md +188 -188
  16. package/skills/clawclaw/references/HUB.md +48 -48
  17. package/skills/clawclaw/references/KNOWLEDGE.md +45 -43
  18. package/skills/clawclaw/references/STRATEGIES.md +57 -57
  19. package/skills/clawclaw/references/STREAM.md +91 -92
  20. package/skills/clawclaw/references/TACTICS.md +65 -65
  21. package/src/assets/clawclaw-ascii-map.txt +40 -40
  22. package/src/cli.ts +110 -110
  23. package/src/commands/_schema.ts +109 -109
  24. package/src/commands/account.ts +209 -209
  25. package/src/commands/config.ts +30 -30
  26. package/src/commands/do.test.ts +73 -73
  27. package/src/commands/do.ts +126 -126
  28. package/src/commands/events.test.ts +71 -71
  29. package/src/commands/events.ts +155 -155
  30. package/src/commands/game-map.test.ts +28 -28
  31. package/src/commands/game-start-plan.test.ts +84 -84
  32. package/src/commands/game.ts +1027 -1027
  33. package/src/commands/history-player.test.ts +102 -102
  34. package/src/commands/history.ts +573 -573
  35. package/src/commands/hub.test.ts +96 -96
  36. package/src/commands/hub.ts +234 -234
  37. package/src/commands/knowledge.test.ts +13 -19
  38. package/src/commands/knowledge.ts +139 -168
  39. package/src/commands/load.test.ts +51 -51
  40. package/src/commands/load.ts +13 -13
  41. package/src/commands/meeting-history.test.ts +106 -106
  42. package/src/commands/memory.ts +40 -40
  43. package/src/commands/peek.ts +45 -45
  44. package/src/commands/persona.ts +57 -57
  45. package/src/commands/setup/codex.ts +266 -248
  46. package/src/commands/setup/hermes.test.ts +96 -96
  47. package/src/commands/setup/hermes.ts +76 -76
  48. package/src/commands/setup/index.ts +13 -13
  49. package/src/commands/setup/openclaw.test.ts +114 -114
  50. package/src/commands/setup/openclaw.ts +147 -147
  51. package/src/commands/skill.ts +128 -128
  52. package/src/commands/state.ts +46 -46
  53. package/src/commands/strategy.test.ts +135 -135
  54. package/src/commands/strategy.ts +180 -180
  55. package/src/commands/tts.ts +128 -128
  56. package/src/commands/upgrade.test.ts +82 -82
  57. package/src/commands/upgrade.ts +148 -148
  58. package/src/commands/watch.test.ts +966 -966
  59. package/src/commands/watch.ts +659 -659
  60. package/src/lib/auth.test.ts +59 -59
  61. package/src/lib/auth.ts +186 -186
  62. package/src/lib/command-meta.ts +37 -37
  63. package/src/lib/game-client.ts +391 -391
  64. package/src/lib/host-config-patcher.test.ts +130 -130
  65. package/src/lib/host-config-patcher.ts +151 -151
  66. package/src/lib/http-keepalive.ts +15 -15
  67. package/src/lib/http-transport.test.ts +42 -42
  68. package/src/lib/http-transport.ts +113 -113
  69. package/src/lib/hub-client.test.ts +56 -56
  70. package/src/lib/hub-client.ts +88 -88
  71. package/src/lib/hub-install.test.ts +98 -98
  72. package/src/lib/hub-install.ts +121 -121
  73. package/src/lib/hub-reminder.ts +56 -56
  74. package/src/lib/hub-unzip.test.ts +69 -69
  75. package/src/lib/hub-unzip.ts +62 -62
  76. package/src/lib/init-command.test.ts +75 -75
  77. package/src/lib/init-command.ts +120 -120
  78. package/src/lib/knowledge-store.test.ts +170 -180
  79. package/src/lib/knowledge-store.ts +369 -374
  80. package/src/lib/load-context.test.ts +52 -52
  81. package/src/lib/load-context.ts +52 -52
  82. package/src/lib/match-state.test.ts +134 -134
  83. package/src/lib/match-state.ts +94 -94
  84. package/src/lib/netease-tts.ts +83 -83
  85. package/src/lib/normalize.ts +42 -42
  86. package/src/lib/persona.test.ts +41 -41
  87. package/src/lib/persona.ts +72 -72
  88. package/src/lib/server-registry.ts +152 -152
  89. package/src/lib/skill-version.test.ts +48 -48
  90. package/src/lib/skill-version.ts +19 -19
  91. package/src/lib/strategy-export.test.ts +232 -232
  92. package/src/lib/strategy-export.ts +242 -242
  93. package/src/lib/tts-keys.ts +7 -7
  94. package/src/lib/tts-speech.test.ts +63 -63
  95. package/src/lib/tts-speech.ts +76 -76
  96. package/src/lib/workspace-argv.test.ts +49 -49
  97. package/src/lib/workspace-argv.ts +44 -44
  98. package/src/perception/player-history-store.test.ts +87 -87
  99. package/src/perception/player-history-store.ts +194 -194
  100. package/src/pipeline/event-format.test.ts +135 -135
  101. package/src/pipeline/event-format.ts +376 -376
  102. package/src/pipeline/event-hints.ts +173 -173
  103. package/src/pipeline/event-store.test.ts +28 -28
  104. package/src/pipeline/event-store.ts +193 -193
  105. package/src/pipeline/pipeline.ts +35 -35
  106. package/src/runtime/auto-upgrade.test.ts +66 -66
  107. package/src/runtime/auto-upgrade.ts +31 -31
  108. package/src/runtime/event-daemon.test.ts +107 -107
  109. package/src/runtime/event-daemon.ts +409 -409
  110. package/src/runtime/owner-control.ts +150 -150
  111. package/src/runtime/raw-ws-log.test.ts +33 -33
  112. package/src/runtime/raw-ws-log.ts +32 -32
  113. package/src/runtime/runtime-logger.ts +107 -107
  114. package/src/runtime/ws-client.test.ts +104 -104
  115. package/src/runtime/ws-client.ts +272 -272
  116. package/src/sdk/action.ts +166 -166
  117. package/src/sdk/index.ts +110 -111
  118. package/src/sdk/types.ts +161 -159
  119. package/src/strategies/avoid-lone.ts +11 -11
  120. package/src/strategies/avoid-players.knowledge.md +19 -20
  121. package/src/strategies/avoid-players.ts +15 -15
  122. package/src/strategies/corpse-patrol.ts +22 -22
  123. package/src/strategies/crab-sabotage.ts +21 -21
  124. package/src/strategies/custom-module.test.ts +269 -269
  125. package/src/strategies/find-player.ts +16 -16
  126. package/src/strategies/game-utils.test.ts +190 -190
  127. package/src/strategies/game-utils.ts +794 -782
  128. package/src/strategies/goals/anchor-linger.ts +77 -77
  129. package/src/strategies/goals/avoid-lone-top.ts +168 -168
  130. package/src/strategies/goals/avoid-players-top.test.ts +83 -83
  131. package/src/strategies/goals/avoid-players-top.ts +121 -121
  132. package/src/strategies/goals/conversation-goal.ts +51 -51
  133. package/src/strategies/goals/corpse-patrol-top.ts +113 -91
  134. package/src/strategies/goals/crab-octopus-reflexes.ts +93 -93
  135. package/src/strategies/goals/crab-sabotage-top.ts +197 -197
  136. package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
  137. package/src/strategies/goals/find-player-top.ts +93 -93
  138. package/src/strategies/goals/flee-players-goal.ts +53 -53
  139. package/src/strategies/goals/follow-companion-goal.ts +106 -106
  140. package/src/strategies/goals/goal-manager.ts +41 -41
  141. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  142. package/src/strategies/goals/goal.ts +28 -28
  143. package/src/strategies/goals/hide-top.ts +197 -197
  144. package/src/strategies/goals/keep-away-goal.ts +217 -217
  145. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  146. package/src/strategies/goals/kill-lone-top.ts +160 -160
  147. package/src/strategies/goals/kill-target-goal.ts +59 -59
  148. package/src/strategies/goals/kill-target-top.ts +109 -109
  149. package/src/strategies/goals/leaf-goal.ts +27 -25
  150. package/src/strategies/goals/linger-corpse-goal.ts +35 -35
  151. package/src/strategies/goals/lone-kill-core.ts +82 -82
  152. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  153. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  154. package/src/strategies/goals/lone-kill-task-top.ts +117 -86
  155. package/src/strategies/goals/move-room-goal.ts +60 -60
  156. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  157. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  158. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  159. package/src/strategies/goals/paradise-fish-top.ts +224 -207
  160. package/src/strategies/goals/patrol-top.ts +57 -57
  161. package/src/strategies/goals/report-patrol-top.ts +80 -80
  162. package/src/strategies/goals/safe-task-goal.ts +102 -102
  163. package/src/strategies/goals/social-task-top.ts +161 -161
  164. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  165. package/src/strategies/goals/task-only-top.ts +57 -57
  166. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  167. package/src/strategies/goals/task-report-top.ts +57 -57
  168. package/src/strategies/goals/wander-task-goal.ts +33 -33
  169. package/src/strategies/goals/warrior-shrimp-top.test.ts +87 -86
  170. package/src/strategies/goals/warrior-shrimp-top.ts +560 -500
  171. package/src/strategies/greeting.ts +53 -53
  172. package/src/strategies/hide-spots.ts +123 -123
  173. package/src/strategies/hide.ts +23 -23
  174. package/src/strategies/kill-frenzy.ts +12 -12
  175. package/src/strategies/kill-lone.knowledge.md +17 -20
  176. package/src/strategies/kill-lone.ts +13 -13
  177. package/src/strategies/kill-target.ts +18 -18
  178. package/src/strategies/loader.test.ts +678 -678
  179. package/src/strategies/loader.ts +172 -172
  180. package/src/strategies/lone-kill-task.ts +21 -21
  181. package/src/strategies/meeting-gate.test.ts +59 -59
  182. package/src/strategies/meeting-gate.ts +23 -23
  183. package/src/strategies/move-room.ts +15 -15
  184. package/src/strategies/new-events-backfill.ts +98 -98
  185. package/src/strategies/paradise-fish.knowledge.md +19 -20
  186. package/src/strategies/paradise-fish.ts +25 -25
  187. package/src/strategies/pathfind/distance-field.ts +150 -150
  188. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  189. package/src/strategies/pathfind/escape-planner.ts +355 -355
  190. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  191. package/src/strategies/patrol.ts +11 -11
  192. package/src/strategies/player-targets.ts +13 -13
  193. package/src/strategies/report-patrol.ts +11 -11
  194. package/src/strategies/shrimp-memory.knowledge.md +19 -20
  195. package/src/strategies/shrimp-memory.ts +25 -25
  196. package/src/strategies/social-task.test.ts +28 -28
  197. package/src/strategies/social-task.ts +49 -49
  198. package/src/strategies/spawn.ts +82 -82
  199. package/src/strategies/speech-module.ts +123 -123
  200. package/src/strategies/strategy-loop.ts +771 -771
  201. package/src/strategies/task-kill-report.ts +17 -17
  202. package/src/strategies/task-only.ts +11 -11
  203. package/src/strategies/task-report.ts +22 -22
  204. package/src/strategies/types.ts +102 -102
  205. package/src/strategies/warrior-memory.knowledge.md +21 -22
  206. package/src/strategies/warrior-memory.ts +16 -16
@@ -1,771 +1,771 @@
1
- import type { GameState } from '../sdk/types.js';
2
- import { existsSync, writeFileSync, unlinkSync, readFileSync, statSync } from 'fs';
3
- import { join } from 'path';
4
- import { execFileSync } from 'child_process';
5
- import { GameClient } from '../lib/game-client.js';
6
- import { ApiError, formatApiError } from '../lib/http-transport.js';
7
- import { EventStore, extractNewEvents } from '../pipeline/event-store.js';
8
- import { AuthStore } from '../lib/auth.js';
9
- import { getProfileStateDir } from '../lib/init-command.js';
10
- import { maybeSynthesizeSpeechAudioUrl } from '../lib/tts-speech.js';
11
- import type { Action } from '../sdk/action.js';
12
- import type { Strategy, StrategyContext, BehaviorDecision } from './types.js';
13
- import { resolveStrategy } from './loader.js';
14
- import { CorpseMemory } from './game-utils.js';
15
- import {
16
- meetingEndedInEvents,
17
- meetingStartedInEvents,
18
- shouldPauseForMeeting,
19
- } from './meeting-gate.js';
20
- import { createStrategyNewEventsBackfill } from './new-events-backfill.js';
21
- import {
22
- buildKnowledgeView,
23
- currentGameId,
24
- emptyKnowledgeView,
25
- knowledgeFilePathForActiveAccount,
26
- readKnowledgeFileResult,
27
- } from '../lib/knowledge-store.js';
28
-
29
- function sleep(ms: number): Promise<void> {
30
- return new Promise(r => setTimeout(r, ms));
31
- }
32
-
33
- const RECENT_KILL_IGNORE_MS = 3000;
34
-
35
- function formatErrorMessage(err: unknown): string {
36
- if (err instanceof ApiError) return formatApiError(err);
37
- return err instanceof Error ? err.message : String(err);
38
- }
39
-
40
- function getPidPath(): string {
41
- const profile = new AuthStore().getActive();
42
- if (!profile) throw new Error('Not logged in.');
43
- return join(getProfileStateDir(profile), 'auto.pid');
44
- }
45
-
46
- function getStatusPath(): string {
47
- const profile = new AuthStore().getActive();
48
- if (!profile) throw new Error('Not logged in.');
49
- return join(getProfileStateDir(profile), 'auto.json');
50
- }
51
-
52
- function shouldWriteStrategyRuntimeFiles(): boolean {
53
- return process.env.CLAWCLAW_STRATEGY_RUNTIME_FILES === '1';
54
- }
55
-
56
- export function isStrategyRunning(): boolean {
57
- try {
58
- const pidPath = getPidPath();
59
- if (!existsSync(pidPath)) return false;
60
- const pid = Number(readFileSync(pidPath, 'utf8').trim());
61
- if (pid <= 0) return false;
62
- try {
63
- process.kill(pid, 0);
64
- return true;
65
- } catch {
66
- try { unlinkSync(pidPath); } catch {}
67
- try { unlinkSync(getStatusPath()); } catch {}
68
- return false;
69
- }
70
- } catch {
71
- return false;
72
- }
73
- }
74
-
75
- export function stopStrategyIfRunning(): void {
76
- try {
77
- const pidPath = getPidPath();
78
- if (existsSync(pidPath)) {
79
- const pid = Number(readFileSync(pidPath, 'utf8').trim());
80
- stopPid(pid);
81
- try { unlinkSync(pidPath); } catch {}
82
- try { unlinkSync(getStatusPath()); } catch {}
83
- }
84
- try { unlinkSync(getStatusPath()); } catch {}
85
- } catch {}
86
- stopOrphanProcesses();
87
- }
88
-
89
- function stopPid(pid: number): void {
90
- if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return;
91
- try { process.kill(pid, 'SIGTERM'); } catch {}
92
- const waitBuffer = new SharedArrayBuffer(4);
93
- Atomics.wait(new Int32Array(waitBuffer), 0, 0, 200);
94
- try {
95
- process.kill(pid, 0);
96
- process.kill(pid, 'SIGKILL');
97
- } catch {}
98
- }
99
-
100
- function stopOrphanProcesses(): void {
101
- if (process.platform === 'win32') return;
102
- try {
103
- const lines = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' }).split('\n');
104
- for (const line of lines) {
105
- const match = line.trim().match(/^(\d+)\s+(.+)$/);
106
- if (!match) continue;
107
- const pid = Number(match[1]);
108
- const command = match[2];
109
- if (pid === process.pid) continue;
110
- if (!command.includes('clawclaw-cli') || !command.includes(' _strategy ')) continue;
111
- stopPid(pid);
112
- }
113
- } catch {}
114
- }
115
-
116
- interface RoomTarget {
117
- name: string;
118
- x: number;
119
- y: number;
120
- }
121
-
122
- function pointFromMapEntry(entry: any): { x: number; y: number } | null {
123
- const x = Number(entry?.x ?? entry?.[0]);
124
- const y = Number(entry?.y ?? entry?.[1]);
125
- return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
126
- }
127
-
128
- function roomName(r: any, index = 0): string {
129
- return String(r?.name ?? r?.id ?? r?.room ?? `room-${index + 1}`);
130
- }
131
-
132
- function taskLocationsByRoom(allTaskLocations: any[] | undefined): Map<string, any[]> {
133
- const byRoom = new Map<string, any[]>();
134
- if (!Array.isArray(allTaskLocations)) return byRoom;
135
- for (const task of allTaskLocations) {
136
- const room = typeof task?.room === 'string' ? task.room : '';
137
- const point = pointFromMapEntry(task);
138
- if (!room || !point) continue;
139
- const list = byRoom.get(room) ?? [];
140
- list.push(task);
141
- byRoom.set(room, list);
142
- }
143
- return byRoom;
144
- }
145
-
146
- function roomAnchor(r: any, index = 0, allTaskLocationsByRoom = new Map<string, any[]>()): RoomTarget {
147
- const taskLocations = Array.isArray(r?.task_locations) ? r.task_locations : [];
148
- const name = roomName(r, index);
149
- const candidates = [
150
- ...taskLocations,
151
- ...(allTaskLocationsByRoom.get(name) ?? []),
152
- ]
153
- .map(pointFromMapEntry)
154
- .filter((p): p is { x: number; y: number } => p != null);
155
- if (candidates.length > 0) {
156
- return { name, ...candidates[Math.floor(Math.random() * candidates.length)] };
157
- }
158
-
159
- const poly: number[][] = Array.isArray(r?.polygon) ? r.polygon : [];
160
- if (poly.length === 0) return { name, x: 0, y: 0 };
161
-
162
- let area = 0;
163
- let cx = 0;
164
- let cy = 0;
165
- for (let i = 0; i < poly.length; i += 1) {
166
- const [x1, y1] = poly[i];
167
- const [x2, y2] = poly[(i + 1) % poly.length];
168
- const cross = x1 * y2 - x2 * y1;
169
- area += cross;
170
- cx += (x1 + x2) * cross;
171
- cy += (y1 + y2) * cross;
172
- }
173
- area *= 0.5;
174
- if (Math.abs(area) < 1e-6) {
175
- const ax = poly.reduce((s, p) => s + p[0], 0) / poly.length;
176
- const ay = poly.reduce((s, p) => s + p[1], 0) / poly.length;
177
- return { name, x: ax, y: ay };
178
- }
179
- return { name, x: cx / (6 * area), y: cy / (6 * area) };
180
- }
181
-
182
- function learnTeammatesFromEvents(events: any[] | undefined, teammates: Set<string>): void {
183
- if (!Array.isArray(events)) return;
184
- for (const evt of events) {
185
- if (evt?.type !== 'crab_teammates') continue;
186
- const list = Array.isArray(evt.teammates) ? evt.teammates : [];
187
- for (const name of list) {
188
- if (typeof name === 'string' && name.length > 0) teammates.add(name);
189
- }
190
- }
191
- }
192
-
193
- const MOVEMENT_TERMINAL_EVENT_TYPES = new Set(['move_end', 'move_interrupted']);
194
-
195
- function ownMovementTerminalEvent(events: any[] | undefined, playerName: string): any | null {
196
- if (!Array.isArray(events)) return null;
197
- return events.find(evt => {
198
- if (!MOVEMENT_TERMINAL_EVENT_TYPES.has(evt?.type)) return false;
199
- if (evt.actor_name && evt.actor_name !== playerName) return false;
200
- return true;
201
- }) ?? null;
202
- }
203
-
204
- function ownKillEvent(events: any[] | undefined, playerName: string): boolean {
205
- if (!Array.isArray(events) || !playerName) return false;
206
- return events.some(evt => evt?.type === 'kill' && evt.actor_name === playerName);
207
- }
208
-
209
- function learnSeatNames(players: any[] | undefined, target: Record<string, string>): void {
210
- if (!Array.isArray(players)) return;
211
- for (const player of players) {
212
- const seat = player?.seat;
213
- const name = player?.name;
214
- if (seat == null || typeof name !== 'string' || name.length === 0) continue;
215
- target[String(seat)] = name;
216
- }
217
- }
218
-
219
- function eventsSinceCurrentSession(events: any[] | undefined): any[] {
220
- if (!Array.isArray(events)) return [];
221
- let idx = -1;
222
- for (let i = events.length - 1; i >= 0; i--) {
223
- if (events[i]?.type === 'session_started') { idx = i; break; }
224
- }
225
- return idx >= 0 ? events.slice(idx + 1) : events;
226
- }
227
-
228
- interface SpeechAudioWarmup {
229
- url?: string;
230
- done: boolean;
231
- }
232
-
233
- interface MoveTarget {
234
- kind: 'point' | 'room';
235
- x?: number;
236
- y?: number;
237
- room?: string;
238
- }
239
-
240
- interface ActiveMoveTarget extends MoveTarget {
241
- until: number;
242
- }
243
-
244
- const SAME_MOVE_TARGET_DISTANCE = 10;
245
-
246
- function moveTargetFromPayload(payload: Record<string, any>): MoveTarget | null {
247
- if (typeof payload.target === 'string' && payload.target.trim()) {
248
- return { kind: 'room', room: payload.target.trim().toLowerCase() };
249
- }
250
- const x = Number(payload.target_x);
251
- const y = Number(payload.target_y);
252
- if (Number.isFinite(x) && Number.isFinite(y)) return { kind: 'point', x, y };
253
- return null;
254
- }
255
-
256
- function sameMoveTarget(a: MoveTarget, b: MoveTarget): boolean {
257
- if (a.kind !== b.kind) return false;
258
- if (a.kind === 'room') return a.room === b.room;
259
- const dx = (a.x ?? 0) - (b.x ?? 0);
260
- const dy = (a.y ?? 0) - (b.y ?? 0);
261
- return Math.sqrt(dx ** 2 + dy ** 2) <= SAME_MOVE_TARGET_DISTANCE;
262
- }
263
-
264
- function activeMoveTargetFromPayload(payload: Record<string, any>, until: number): ActiveMoveTarget | null {
265
- const target = moveTargetFromPayload(payload);
266
- return target ? { ...target, until } : null;
267
- }
268
-
269
- function repeatsActiveMoveTarget(decision: BehaviorDecision, activeMoveTarget: ActiveMoveTarget | null): boolean {
270
- if (!activeMoveTarget || Date.now() >= activeMoveTarget.until) return false;
271
- const payload = decision.action.toJSON();
272
- if (payload.action !== 'move') return false;
273
- const target = moveTargetFromPayload(payload);
274
- return !!target && sameMoveTarget(target, activeMoveTarget);
275
- }
276
-
277
- function collectSpeechWarmupTexts(strategyId: string, args: string[] | undefined): string[] {
278
- if (!args || args.length === 0) return [];
279
-
280
- // task-report / corpse-patrol / shrimp-memory / paradise-fish 都自行实现了 speechWarmupTexts(),
281
- // warmupSpeechAudio 会优先用它,不会落到这里——故此处只保留未实现该方法的策略。
282
- if (strategyId === 'social-task') {
283
- const raw = args.join(' ').replace(/(?<!\s)(\d+=)/g, ' $1').trim();
284
- const texts: string[] = [];
285
- for (const token of raw.split(/\s+/)) {
286
- const eqIdx = token.indexOf('=');
287
- if (eqIdx < 0) continue;
288
- const text = token.slice(eqIdx + 1).trim();
289
- if (text) texts.push(text);
290
- }
291
- return texts;
292
- }
293
-
294
- return [];
295
- }
296
-
297
- function warmupSpeechAudio(
298
- strategyId: string,
299
- args: string[] | undefined,
300
- strategy: Strategy,
301
- client: GameClient,
302
- store: EventStore,
303
- ): Map<string, SpeechAudioWarmup> {
304
- const warmups = new Map<string, SpeechAudioWarmup>();
305
- const texts = [...new Set(strategy.speechWarmupTexts?.() ?? collectSpeechWarmupTexts(strategyId, args))];
306
-
307
- for (const text of texts) {
308
- const item: SpeechAudioWarmup = { done: false };
309
- warmups.set(text, item);
310
- void maybeSynthesizeSpeechAudioUrl(text, undefined, client)
311
- .then(url => {
312
- item.url = url;
313
- item.done = true;
314
- if (url) store.append({ type: 'auto', message: 'speech audio warmed up' });
315
- })
316
- .catch((err: any) => {
317
- item.done = true;
318
- store.append({ type: 'auto', error: 'speech_audio_warmup_failed', message: formatErrorMessage(err) });
319
- });
320
- }
321
-
322
- return warmups;
323
- }
324
-
325
- function pickDecision(
326
- decisions: BehaviorDecision[],
327
- blockedMoveTarget: ({ x: number; y: number; until: number }) | null,
328
- activeMoveTarget: ActiveMoveTarget | null,
329
- ): BehaviorDecision | null {
330
- return decisions.find(d => {
331
- if (repeatsActiveMoveTarget(d, activeMoveTarget)) return false;
332
- const a = d.action.toJSON();
333
- if (a.action !== 'move') return true;
334
- if (!blockedMoveTarget || Date.now() >= blockedMoveTarget.until) return true;
335
- const x = Number(a.target_x);
336
- const y = Number(a.target_y);
337
- if (!Number.isFinite(x) || !Number.isFinite(y)) return true;
338
- const dx = x - blockedMoveTarget.x;
339
- const dy = y - blockedMoveTarget.y;
340
- return Math.sqrt(dx ** 2 + dy ** 2) > 10;
341
- }) ?? null;
342
- }
343
-
344
- function isSpeechDecision(decision: BehaviorDecision): boolean {
345
- return decision.action.toJSON().action === 'speech';
346
- }
347
-
348
- function supportsSidecarSpeech(strategyId: string): boolean {
349
- return strategyId === 'task-report'
350
- || strategyId === 'corpse-patrol'
351
- || strategyId === 'shrimp-memory'
352
- || strategyId === 'paradise-fish';
353
- }
354
-
355
- export async function runStrategyLoop(strategyId: string, args?: string[]): Promise<void> {
356
- const strategy = await resolveStrategy(strategyId, args);
357
- const store = EventStore.forActiveAccount();
358
- const pidPath = getPidPath();
359
-
360
- if (shouldWriteStrategyRuntimeFiles()) writeFileSync(pidPath, String(process.pid));
361
- store.append({ type: 'auto', message: 'strategy started', strategy: strategyId, pid: process.pid });
362
-
363
- const client = GameClient.fromAuth();
364
- await client.discoverGameServer();
365
- const speechAudioWarmups = warmupSpeechAudio(strategyId, args, strategy, client, store);
366
-
367
- const teammates = new Set<string>();
368
- const playerNamesBySeat: Record<string, string> = {};
369
- let blockedMoveTarget: { x: number; y: number; until: number } | null = null;
370
- let consecutiveBlocks = 0;
371
- const BLOCK_SKIP_THRESHOLD = 3;
372
- let mapDirty = true;
373
- let lastMapRefreshAt = 0;
374
- const MAP_REFRESH_INTERVAL_MS = 10_000;
375
-
376
- const knowledgePath = (() => { try { return knowledgeFilePathForActiveAccount(); } catch { return ''; } })();
377
- const knowledgeGameId = currentGameId();
378
- let lastKnowledgeMtimeMs = -1;
379
-
380
- const ctx: StrategyContext = {
381
- taskData: [],
382
- taskLocations: [],
383
- emergency: null,
384
- taskLocalBlockedUntil: 0,
385
- reportCorpseTarget: null,
386
- reportBlockedUntil: 0,
387
- notifications: [],
388
- lastProgressNotifyAt: 0,
389
- teammates,
390
- alarmDone: false,
391
- rooms: [],
392
- playerNamesBySeat,
393
- forcePatrolAdvance: false,
394
- blockedMoveTarget: null,
395
- mySeat: 0,
396
- speechNotifications: [],
397
- agentAlerts: [],
398
- knownCorpses: [],
399
- knowledge: emptyKnowledgeView(),
400
- recentlyKilledTargets: new Map(),
401
- };
402
-
403
- // 全局唯一的尸体记忆:每 tick observe 后写入 ctx.knownCorpses,开会清场时 reset。
404
- const corpseMemory = new CorpseMemory();
405
-
406
- try { learnTeammatesFromEvents(eventsSinceCurrentSession(store.tail(1000)), teammates); } catch {}
407
- const newEventsBackfill = createStrategyNewEventsBackfill(store.path);
408
- try {
409
- const roleInfo = await client.getRoleInfo();
410
- learnSeatNames(roleInfo?.data?.all_seats ?? roleInfo?.all_seats, playerNamesBySeat);
411
- const crabTeammates: any[] = roleInfo?.data?.crab_teammates ?? roleInfo?.crab_teammates ?? [];
412
- for (const name of crabTeammates) {
413
- if (typeof name === 'string' && name.length > 0) teammates.add(name);
414
- }
415
- const mySeat = Number(roleInfo?.data?.seat ?? roleInfo?.seat ?? 0);
416
- if (mySeat > 0) ctx.mySeat = mySeat;
417
- const role: string = roleInfo?.data?.role ?? roleInfo?.role ?? '';
418
- if (role && strategy.updateRole) strategy.updateRole(role);
419
- } catch {}
420
-
421
- // Register custom modules before main loop so tick-1 events are captured
422
- const customModules = strategy.customModules?.() ?? [];
423
- if (customModules.length > 0) ctx.customModules = customModules;
424
-
425
- let running = true;
426
- let activeMoveTarget: ActiveMoveTarget | null = null;
427
- let pausedForMeeting = false;
428
- let releasedMeetingPause = false;
429
- let currentRole = '';
430
- let currentPlayerName = '';
431
- let pistolKillUsed = false;
432
- let pistolKillInitialized = false;
433
- const onSignal = () => { running = false; };
434
- process.on('SIGINT', onSignal);
435
- process.on('SIGTERM', onSignal);
436
-
437
- const submitAction = async (action: Action): Promise<{ acted: boolean }> => {
438
- try {
439
- const payload = action.toJSON();
440
- const actionType = payload.action;
441
- if (actionType === 'speech' && typeof payload.text === 'string' && !payload.audio_url) {
442
- const warmup = speechAudioWarmups.get(payload.text);
443
- if (warmup?.url) payload.audio_url = warmup.url;
444
- }
445
-
446
- const result = await client.submitAction(payload as any);
447
- const newEvents = extractNewEvents(result);
448
- store.appendNewEvents(newEvents);
449
- // action 结果里回来的 corpse_spotted 也入账尸体记忆——主循环只 observe state.new_events,
450
- // 那条通道会被 runtime WS listener 抢游标,这里补上避免尸体坐标漏记(ctx.knownCorpses 与 list() 同引用,立即可见)。
451
- corpseMemory.ingestEvents(newEvents);
452
- learnTeammatesFromEvents(newEvents, teammates);
453
- if (currentRole === 'shrimp_pistol' && ownKillEvent(newEvents, currentPlayerName)) {
454
- pistolKillUsed = true;
455
- }
456
- const actionResult = result?.data ?? result;
457
- store.append({ type: 'auto', action: actionType, result: actionResult ?? result?.error ?? 'ok' });
458
-
459
- const errorCode = actionResult?.error?.code ?? actionResult?.code ?? result?.error?.code;
460
- const errorMessage = String(
461
- actionResult?.error?.message ?? actionResult?.message ?? result?.error?.message ?? actionResult?.reason ?? '',
462
- );
463
- const failed = errorCode === 'ACTION_FAILED' || actionResult?.success === false || result?.success === false;
464
- const queued = actionResult?.status === 'queued';
465
-
466
- if (failed) {
467
- ctx.reportCorpseTarget = null;
468
- const failureText = JSON.stringify(actionResult ?? result ?? {});
469
- if (actionType === 'kill' && currentRole === 'shrimp_pistol' && failureText.includes('role_cannot_kill')) {
470
- pistolKillUsed = true;
471
- }
472
- if (actionType === 'task' || actionType === 'kill' || actionType === 'report' || actionType === 'trigger_alarm') {
473
- activeMoveTarget = null;
474
- }
475
- if (actionType === 'kill' && errorMessage.includes('cannot_kill_teammate')) {
476
- const target = action.toJSON().target;
477
- if (typeof target === 'string' && target.length > 0) teammates.add(target);
478
- }
479
- if (actionType === 'task' && errorMessage.includes('not_at_task_location')) {
480
- ctx.taskLocalBlockedUntil = Date.now() + 5000;
481
- activeMoveTarget = null;
482
- }
483
- if (actionType === 'report') {
484
- // doing_task 型失败不是真报不了:服务端只是因正在做任务而拒绝,下一 tick 任务被 move 打断后即可补报。
485
- // 这种情况不设 5 秒退避,否则会错过「目击者刚靠近尸体」的报尸自证窗口;其余原因(距离不够等)仍退避避免空报刷屏。
486
- if (!failureText.includes('doing_task')) {
487
- ctx.reportBlockedUntil = Date.now() + 5000;
488
- }
489
- activeMoveTarget = null;
490
- }
491
- if (actionType === 'move') {
492
- if (errorMessage.includes('invalid_position_blocked')) {
493
- consecutiveBlocks++;
494
- const payload = action.toJSON();
495
- const x = Number(payload.target_x);
496
- const y = Number(payload.target_y);
497
- if (Number.isFinite(x) && Number.isFinite(y)) {
498
- if (consecutiveBlocks >= BLOCK_SKIP_THRESHOLD) {
499
- blockedMoveTarget = null;
500
- consecutiveBlocks = 0;
501
- ctx.forcePatrolAdvance = true;
502
- ctx.notifications.push('移动目标多次不可达,跳过。');
503
- } else {
504
- blockedMoveTarget = { x, y, until: Date.now() + 5000 };
505
- }
506
- }
507
- } else {
508
- consecutiveBlocks = 0;
509
- }
510
- activeMoveTarget = null;
511
- }
512
- }
513
-
514
- if (!failed && actionType === 'kill') {
515
- const target = typeof payload.target === 'string' ? payload.target.trim().toLowerCase() : '';
516
- if (target) ctx.recentlyKilledTargets?.set(target, Date.now() + RECENT_KILL_IGNORE_MS);
517
- if (currentRole === 'shrimp_pistol') pistolKillUsed = true;
518
- activeMoveTarget = null;
519
- }
520
-
521
- if (!failed && actionType === 'trigger_alarm') {
522
- activeMoveTarget = null;
523
- }
524
-
525
- if (queued) {
526
- if (actionType === 'task') {
527
- mapDirty = true;
528
- activeMoveTarget = null;
529
- }
530
- } else if (actionType === 'move' && !failed) {
531
- consecutiveBlocks = 0;
532
- const durationSecs = actionResult?.duration_secs ?? result?.duration_secs ?? 0;
533
- const arrivalAt = Date.now() + Math.max(0.5, durationSecs) * 1000;
534
- activeMoveTarget = activeMoveTargetFromPayload(payload, arrivalAt + 500);
535
- } else if (!failed && (actionType === 'task' || actionType === 'report')) {
536
- activeMoveTarget = null;
537
- }
538
-
539
- return { acted: true };
540
- } catch (e: any) {
541
- store.append({ type: 'auto', error: 'submit_action_failed', message: formatErrorMessage(e) });
542
- ctx.reportCorpseTarget = null;
543
- if (action.toJSON().action === 'move') {
544
- activeMoveTarget = null;
545
- }
546
- return { acted: true };
547
- }
548
- };
549
-
550
- while (running) {
551
- let state: GameState | null;
552
- try {
553
- state = await client.getGameState();
554
- } catch {
555
- await sleep(2000);
556
- continue;
557
- }
558
-
559
- if (!state) {
560
- store.append({ type: 'auto', message: 'no active game, retrying' });
561
- await sleep(2000);
562
- continue;
563
- }
564
-
565
- store.appendNewEvents(state.new_events);
566
- state.new_events = newEventsBackfill.correct(state.new_events);
567
- currentRole = state.you.role ?? currentRole;
568
- currentPlayerName = state.you.name ?? currentPlayerName;
569
- if (currentRole === 'shrimp_pistol') {
570
- if (!pistolKillInitialized) {
571
- pistolKillUsed = pistolKillUsed || ownKillEvent(eventsSinceCurrentSession(store.tail(1000)), currentPlayerName);
572
- pistolKillInitialized = true;
573
- }
574
- if (state.you.kills_remaining === 0 || ownKillEvent(state.new_events, currentPlayerName)) {
575
- pistolKillUsed = true;
576
- }
577
- state.you.kills_remaining = pistolKillUsed ? 0 : (state.you.kills_remaining ?? 1);
578
- }
579
-
580
- learnTeammatesFromEvents(state.new_events, teammates);
581
- learnSeatNames(state.all_players, playerNamesBySeat);
582
- const terminalMove = ownMovementTerminalEvent(state.new_events, state.you.name);
583
- if (terminalMove) {
584
- activeMoveTarget = null;
585
- }
586
-
587
- // Process events through strategy-registered custom modules
588
- const currentTick = state.tick ?? 0;
589
- const newEvents = state.new_events ?? [];
590
- for (const mod of ctx.customModules ?? []) {
591
- mod.processEvents(newEvents, currentTick, state);
592
- }
593
-
594
- if (ctx.mySeat === 0 && state.you.seat != null) {
595
- ctx.mySeat = state.you.seat;
596
- }
597
-
598
- const prevEmergency = ctx.emergency;
599
- ctx.emergency = state.emergency ?? null;
600
- if (ctx.emergency && !prevEmergency) {
601
- const room = ctx.emergency.room ?? '未知';
602
- ctx.notifications.push(`紧急任务出现!「${ctx.emergency.task_name}」在${room},剩余${Math.round(ctx.emergency.remaining_secs)}秒。`);
603
- }
604
-
605
- if (meetingStartedInEvents(state.new_events)) {
606
- releasedMeetingPause = false;
607
- }
608
- if (state.phase !== 'meeting') {
609
- releasedMeetingPause = false;
610
- }
611
- if (meetingEndedInEvents(state.new_events)) {
612
- releasedMeetingPause = true;
613
- }
614
-
615
- if (state.phase === 'game_over') {
616
- store.append({ type: 'auto', message: 'game over' });
617
- break;
618
- }
619
- if (state.you.is_alive === false) {
620
- store.append({ type: 'auto', message: 'player dead, stopping strategy', strategy: strategyId });
621
- break;
622
- }
623
- if (shouldPauseForMeeting(state, releasedMeetingPause)) {
624
- if (!pausedForMeeting) {
625
- store.append({ type: 'auto', message: 'meeting started, pausing strategy', strategy: strategyId });
626
- pausedForMeeting = true;
627
- activeMoveTarget = null;
628
- }
629
- await sleep(2000);
630
- continue;
631
- }
632
- if (pausedForMeeting) {
633
- store.append({ type: 'auto', message: 'meeting ended, resuming strategy', strategy: strategyId });
634
- pausedForMeeting = false;
635
- releasedMeetingPause = true;
636
- mapDirty = true;
637
- activeMoveTarget = null;
638
- blockedMoveTarget = null;
639
- consecutiveBlocks = 0;
640
- ctx.taskLocalBlockedUntil = 0;
641
- ctx.reportCorpseTarget = null;
642
- ctx.reportBlockedUntil = 0;
643
- ctx.forcePatrolAdvance = false;
644
- ctx.blockedMoveTarget = null;
645
- // 开会后尸体清场:清空尸体记忆,避免回避/报告已不存在的尸体。
646
- corpseMemory.reset();
647
- ctx.knownCorpses = [];
648
- // Notify strategy-registered custom modules (decay instead of full reset)
649
- for (const mod of ctx.customModules ?? []) {
650
- mod.onMeetingResume?.();
651
- }
652
- strategy.onMeetingResume?.();
653
- }
654
-
655
- if (mapDirty || Date.now() - lastMapRefreshAt >= MAP_REFRESH_INTERVAL_MS) {
656
- try {
657
- const mapData = await client.getMap();
658
- const taskFactionByName = new Map<string, 'lobster' | 'crab'>(
659
- (mapData?.all_task_locations ?? [])
660
- .filter((t: any) => t?.name && (t.faction === 'lobster' || t.faction === 'crab'))
661
- .map((t: any) => [t.name, t.faction]),
662
- );
663
- ctx.taskData = (mapData?.your_tasks ?? []).map((t: any) => ({
664
- task_id: t.name ?? '',
665
- task_name: t.name ?? '',
666
- room: t.room ?? '',
667
- status: t.status ?? 'normal',
668
- x: t.x,
669
- y: t.y,
670
- is_fake_shrimp: t.is_fake_shrimp ?? false,
671
- faction: taskFactionByName.get(t.name),
672
- }));
673
- ctx.taskLocations = (mapData?.all_task_locations ?? [])
674
- .filter((t: any) => t && typeof t.name === 'string' && typeof t.x === 'number' && typeof t.y === 'number')
675
- .map((t: any) => ({ name: t.name, room: t.room, x: t.x, y: t.y, faction: t.faction }));
676
- if (Array.isArray(mapData?.rooms)) {
677
- if (ctx.rooms.length === 0) {
678
- const byRoom = taskLocationsByRoom(mapData?.all_task_locations);
679
- ctx.rooms = mapData.rooms.map((r: any, index: number) => roomAnchor(r, index, byRoom));
680
- }
681
- // Notify custom modules of new map data
682
- for (const mod of ctx.customModules ?? []) {
683
- mod.onMapLoaded?.(mapData.rooms);
684
- }
685
- }
686
- mapDirty = false;
687
- lastMapRefreshAt = Date.now();
688
- } catch (e: any) {
689
- store.append({ type: 'auto', error: 'map_load_failed', message: formatErrorMessage(e) });
690
- }
691
- }
692
-
693
- const activeBlockedMoveTarget = ((): { x: number; y: number; until: number } | null => blockedMoveTarget)();
694
- ctx.blockedMoveTarget = activeBlockedMoveTarget && Date.now() < activeBlockedMoveTarget.until
695
- ? { x: activeBlockedMoveTarget.x, y: activeBlockedMoveTarget.y }
696
- : null;
697
-
698
- if (knowledgePath) {
699
- try {
700
- const mtime = existsSync(knowledgePath) ? statSync(knowledgePath).mtimeMs : 0;
701
- if (mtime !== lastKnowledgeMtimeMs) {
702
- lastKnowledgeMtimeMs = mtime;
703
- const result = readKnowledgeFileResult(knowledgePath);
704
- if (result.status !== 'invalid') {
705
- const scoped = result.status === 'ok' && result.file.gameId === knowledgeGameId ? result.file : null;
706
- ctx.knowledge = buildKnowledgeView(scoped, { playerNamesBySeat });
707
- }
708
- }
709
- } catch {}
710
- }
711
-
712
- // 更新跨 tick 尸体记忆,暴露给所有策略(检测/接近/任务回避/死亡确认统一读 ctx.knownCorpses)。
713
- corpseMemory.observe(state, ctx.recentlyKilledTargets);
714
- ctx.knownCorpses = corpseMemory.list();
715
-
716
- let decisions = strategy.decide(state, ctx);
717
-
718
- // Run custom module afterDecide pipeline (chained by registration order)
719
- for (const mod of ctx.customModules ?? []) {
720
- if (mod.afterDecide) {
721
- decisions = mod.afterDecide(decisions, state, ctx);
722
- }
723
- }
724
-
725
- const sidecarSpeech = supportsSidecarSpeech(strategyId) ? decisions.filter(isSpeechDecision) : [];
726
- const mainDecisions = sidecarSpeech.length > 0 ? decisions.filter(d => !isSpeechDecision(d)) : decisions;
727
-
728
- const decision = pickDecision(
729
- mainDecisions,
730
- activeBlockedMoveTarget,
731
- activeMoveTarget,
732
- );
733
-
734
- if (decision) {
735
- await submitAction(decision.action);
736
- }
737
- for (const speech of sidecarSpeech) {
738
- await submitAction(speech.action);
739
- }
740
-
741
- if (ctx.notifications.length > 0) {
742
- for (const msg of ctx.notifications) {
743
- store.append({ type: 'auto', message: msg, strategy: strategyId });
744
- }
745
- ctx.notifications.length = 0;
746
- }
747
-
748
- if (ctx.agentAlerts.length > 0) {
749
- const tick = typeof state.tick === 'number' ? state.tick : undefined;
750
- for (const msg of ctx.agentAlerts) {
751
- store.append({ type: 'strategy_alert', message: msg, strategy: strategyId, tick });
752
- }
753
- ctx.agentAlerts.length = 0;
754
- }
755
-
756
- if (ctx.speechNotifications.length > 0) {
757
- for (const msg of ctx.speechNotifications) {
758
- store.append({ type: 'robot_speak_rule', message: msg });
759
- }
760
- ctx.speechNotifications.length = 0;
761
- }
762
-
763
- await sleep(500);
764
- }
765
-
766
- process.off('SIGINT', onSignal);
767
- process.off('SIGTERM', onSignal);
768
- if (shouldWriteStrategyRuntimeFiles()) {
769
- try { unlinkSync(pidPath); } catch {}
770
- }
771
- }
1
+ import type { GameState } from '../sdk/types.js';
2
+ import { existsSync, writeFileSync, unlinkSync, readFileSync, statSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { execFileSync } from 'child_process';
5
+ import { GameClient } from '../lib/game-client.js';
6
+ import { ApiError, formatApiError } from '../lib/http-transport.js';
7
+ import { EventStore, extractNewEvents } from '../pipeline/event-store.js';
8
+ import { AuthStore } from '../lib/auth.js';
9
+ import { getProfileStateDir } from '../lib/init-command.js';
10
+ import { maybeSynthesizeSpeechAudioUrl } from '../lib/tts-speech.js';
11
+ import type { Action } from '../sdk/action.js';
12
+ import type { Strategy, StrategyContext, BehaviorDecision } from './types.js';
13
+ import { resolveStrategy } from './loader.js';
14
+ import { CorpseMemory } from './game-utils.js';
15
+ import {
16
+ meetingEndedInEvents,
17
+ meetingStartedInEvents,
18
+ shouldPauseForMeeting,
19
+ } from './meeting-gate.js';
20
+ import { createStrategyNewEventsBackfill } from './new-events-backfill.js';
21
+ import {
22
+ buildKnowledgeView,
23
+ currentGameId,
24
+ emptyKnowledgeView,
25
+ knowledgeFilePathForActiveAccount,
26
+ readKnowledgeFileResult,
27
+ } from '../lib/knowledge-store.js';
28
+
29
+ function sleep(ms: number): Promise<void> {
30
+ return new Promise(r => setTimeout(r, ms));
31
+ }
32
+
33
+ const RECENT_KILL_IGNORE_MS = 3000;
34
+
35
+ function formatErrorMessage(err: unknown): string {
36
+ if (err instanceof ApiError) return formatApiError(err);
37
+ return err instanceof Error ? err.message : String(err);
38
+ }
39
+
40
+ function getPidPath(): string {
41
+ const profile = new AuthStore().getActive();
42
+ if (!profile) throw new Error('Not logged in.');
43
+ return join(getProfileStateDir(profile), 'auto.pid');
44
+ }
45
+
46
+ function getStatusPath(): string {
47
+ const profile = new AuthStore().getActive();
48
+ if (!profile) throw new Error('Not logged in.');
49
+ return join(getProfileStateDir(profile), 'auto.json');
50
+ }
51
+
52
+ function shouldWriteStrategyRuntimeFiles(): boolean {
53
+ return process.env.CLAWCLAW_STRATEGY_RUNTIME_FILES === '1';
54
+ }
55
+
56
+ export function isStrategyRunning(): boolean {
57
+ try {
58
+ const pidPath = getPidPath();
59
+ if (!existsSync(pidPath)) return false;
60
+ const pid = Number(readFileSync(pidPath, 'utf8').trim());
61
+ if (pid <= 0) return false;
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ } catch {
66
+ try { unlinkSync(pidPath); } catch {}
67
+ try { unlinkSync(getStatusPath()); } catch {}
68
+ return false;
69
+ }
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ export function stopStrategyIfRunning(): void {
76
+ try {
77
+ const pidPath = getPidPath();
78
+ if (existsSync(pidPath)) {
79
+ const pid = Number(readFileSync(pidPath, 'utf8').trim());
80
+ stopPid(pid);
81
+ try { unlinkSync(pidPath); } catch {}
82
+ try { unlinkSync(getStatusPath()); } catch {}
83
+ }
84
+ try { unlinkSync(getStatusPath()); } catch {}
85
+ } catch {}
86
+ stopOrphanProcesses();
87
+ }
88
+
89
+ function stopPid(pid: number): void {
90
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return;
91
+ try { process.kill(pid, 'SIGTERM'); } catch {}
92
+ const waitBuffer = new SharedArrayBuffer(4);
93
+ Atomics.wait(new Int32Array(waitBuffer), 0, 0, 200);
94
+ try {
95
+ process.kill(pid, 0);
96
+ process.kill(pid, 'SIGKILL');
97
+ } catch {}
98
+ }
99
+
100
+ function stopOrphanProcesses(): void {
101
+ if (process.platform === 'win32') return;
102
+ try {
103
+ const lines = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' }).split('\n');
104
+ for (const line of lines) {
105
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
106
+ if (!match) continue;
107
+ const pid = Number(match[1]);
108
+ const command = match[2];
109
+ if (pid === process.pid) continue;
110
+ if (!command.includes('clawclaw-cli') || !command.includes(' _strategy ')) continue;
111
+ stopPid(pid);
112
+ }
113
+ } catch {}
114
+ }
115
+
116
+ interface RoomTarget {
117
+ name: string;
118
+ x: number;
119
+ y: number;
120
+ }
121
+
122
+ function pointFromMapEntry(entry: any): { x: number; y: number } | null {
123
+ const x = Number(entry?.x ?? entry?.[0]);
124
+ const y = Number(entry?.y ?? entry?.[1]);
125
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
126
+ }
127
+
128
+ function roomName(r: any, index = 0): string {
129
+ return String(r?.name ?? r?.id ?? r?.room ?? `room-${index + 1}`);
130
+ }
131
+
132
+ function taskLocationsByRoom(allTaskLocations: any[] | undefined): Map<string, any[]> {
133
+ const byRoom = new Map<string, any[]>();
134
+ if (!Array.isArray(allTaskLocations)) return byRoom;
135
+ for (const task of allTaskLocations) {
136
+ const room = typeof task?.room === 'string' ? task.room : '';
137
+ const point = pointFromMapEntry(task);
138
+ if (!room || !point) continue;
139
+ const list = byRoom.get(room) ?? [];
140
+ list.push(task);
141
+ byRoom.set(room, list);
142
+ }
143
+ return byRoom;
144
+ }
145
+
146
+ function roomAnchor(r: any, index = 0, allTaskLocationsByRoom = new Map<string, any[]>()): RoomTarget {
147
+ const taskLocations = Array.isArray(r?.task_locations) ? r.task_locations : [];
148
+ const name = roomName(r, index);
149
+ const candidates = [
150
+ ...taskLocations,
151
+ ...(allTaskLocationsByRoom.get(name) ?? []),
152
+ ]
153
+ .map(pointFromMapEntry)
154
+ .filter((p): p is { x: number; y: number } => p != null);
155
+ if (candidates.length > 0) {
156
+ return { name, ...candidates[Math.floor(Math.random() * candidates.length)] };
157
+ }
158
+
159
+ const poly: number[][] = Array.isArray(r?.polygon) ? r.polygon : [];
160
+ if (poly.length === 0) return { name, x: 0, y: 0 };
161
+
162
+ let area = 0;
163
+ let cx = 0;
164
+ let cy = 0;
165
+ for (let i = 0; i < poly.length; i += 1) {
166
+ const [x1, y1] = poly[i];
167
+ const [x2, y2] = poly[(i + 1) % poly.length];
168
+ const cross = x1 * y2 - x2 * y1;
169
+ area += cross;
170
+ cx += (x1 + x2) * cross;
171
+ cy += (y1 + y2) * cross;
172
+ }
173
+ area *= 0.5;
174
+ if (Math.abs(area) < 1e-6) {
175
+ const ax = poly.reduce((s, p) => s + p[0], 0) / poly.length;
176
+ const ay = poly.reduce((s, p) => s + p[1], 0) / poly.length;
177
+ return { name, x: ax, y: ay };
178
+ }
179
+ return { name, x: cx / (6 * area), y: cy / (6 * area) };
180
+ }
181
+
182
+ function learnTeammatesFromEvents(events: any[] | undefined, teammates: Set<string>): void {
183
+ if (!Array.isArray(events)) return;
184
+ for (const evt of events) {
185
+ if (evt?.type !== 'crab_teammates') continue;
186
+ const list = Array.isArray(evt.teammates) ? evt.teammates : [];
187
+ for (const name of list) {
188
+ if (typeof name === 'string' && name.length > 0) teammates.add(name);
189
+ }
190
+ }
191
+ }
192
+
193
+ const MOVEMENT_TERMINAL_EVENT_TYPES = new Set(['move_end', 'move_interrupted']);
194
+
195
+ function ownMovementTerminalEvent(events: any[] | undefined, playerName: string): any | null {
196
+ if (!Array.isArray(events)) return null;
197
+ return events.find(evt => {
198
+ if (!MOVEMENT_TERMINAL_EVENT_TYPES.has(evt?.type)) return false;
199
+ if (evt.actor_name && evt.actor_name !== playerName) return false;
200
+ return true;
201
+ }) ?? null;
202
+ }
203
+
204
+ function ownKillEvent(events: any[] | undefined, playerName: string): boolean {
205
+ if (!Array.isArray(events) || !playerName) return false;
206
+ return events.some(evt => evt?.type === 'kill' && evt.actor_name === playerName);
207
+ }
208
+
209
+ function learnSeatNames(players: any[] | undefined, target: Record<string, string>): void {
210
+ if (!Array.isArray(players)) return;
211
+ for (const player of players) {
212
+ const seat = player?.seat;
213
+ const name = player?.name;
214
+ if (seat == null || typeof name !== 'string' || name.length === 0) continue;
215
+ target[String(seat)] = name;
216
+ }
217
+ }
218
+
219
+ function eventsSinceCurrentSession(events: any[] | undefined): any[] {
220
+ if (!Array.isArray(events)) return [];
221
+ let idx = -1;
222
+ for (let i = events.length - 1; i >= 0; i--) {
223
+ if (events[i]?.type === 'session_started') { idx = i; break; }
224
+ }
225
+ return idx >= 0 ? events.slice(idx + 1) : events;
226
+ }
227
+
228
+ interface SpeechAudioWarmup {
229
+ url?: string;
230
+ done: boolean;
231
+ }
232
+
233
+ interface MoveTarget {
234
+ kind: 'point' | 'room';
235
+ x?: number;
236
+ y?: number;
237
+ room?: string;
238
+ }
239
+
240
+ interface ActiveMoveTarget extends MoveTarget {
241
+ until: number;
242
+ }
243
+
244
+ const SAME_MOVE_TARGET_DISTANCE = 10;
245
+
246
+ function moveTargetFromPayload(payload: Record<string, any>): MoveTarget | null {
247
+ if (typeof payload.target === 'string' && payload.target.trim()) {
248
+ return { kind: 'room', room: payload.target.trim().toLowerCase() };
249
+ }
250
+ const x = Number(payload.target_x);
251
+ const y = Number(payload.target_y);
252
+ if (Number.isFinite(x) && Number.isFinite(y)) return { kind: 'point', x, y };
253
+ return null;
254
+ }
255
+
256
+ function sameMoveTarget(a: MoveTarget, b: MoveTarget): boolean {
257
+ if (a.kind !== b.kind) return false;
258
+ if (a.kind === 'room') return a.room === b.room;
259
+ const dx = (a.x ?? 0) - (b.x ?? 0);
260
+ const dy = (a.y ?? 0) - (b.y ?? 0);
261
+ return Math.sqrt(dx ** 2 + dy ** 2) <= SAME_MOVE_TARGET_DISTANCE;
262
+ }
263
+
264
+ function activeMoveTargetFromPayload(payload: Record<string, any>, until: number): ActiveMoveTarget | null {
265
+ const target = moveTargetFromPayload(payload);
266
+ return target ? { ...target, until } : null;
267
+ }
268
+
269
+ function repeatsActiveMoveTarget(decision: BehaviorDecision, activeMoveTarget: ActiveMoveTarget | null): boolean {
270
+ if (!activeMoveTarget || Date.now() >= activeMoveTarget.until) return false;
271
+ const payload = decision.action.toJSON();
272
+ if (payload.action !== 'move') return false;
273
+ const target = moveTargetFromPayload(payload);
274
+ return !!target && sameMoveTarget(target, activeMoveTarget);
275
+ }
276
+
277
+ function collectSpeechWarmupTexts(strategyId: string, args: string[] | undefined): string[] {
278
+ if (!args || args.length === 0) return [];
279
+
280
+ // task-report / corpse-patrol / shrimp-memory / paradise-fish 都自行实现了 speechWarmupTexts(),
281
+ // warmupSpeechAudio 会优先用它,不会落到这里——故此处只保留未实现该方法的策略。
282
+ if (strategyId === 'social-task') {
283
+ const raw = args.join(' ').replace(/(?<!\s)(\d+=)/g, ' $1').trim();
284
+ const texts: string[] = [];
285
+ for (const token of raw.split(/\s+/)) {
286
+ const eqIdx = token.indexOf('=');
287
+ if (eqIdx < 0) continue;
288
+ const text = token.slice(eqIdx + 1).trim();
289
+ if (text) texts.push(text);
290
+ }
291
+ return texts;
292
+ }
293
+
294
+ return [];
295
+ }
296
+
297
+ function warmupSpeechAudio(
298
+ strategyId: string,
299
+ args: string[] | undefined,
300
+ strategy: Strategy,
301
+ client: GameClient,
302
+ store: EventStore,
303
+ ): Map<string, SpeechAudioWarmup> {
304
+ const warmups = new Map<string, SpeechAudioWarmup>();
305
+ const texts = [...new Set(strategy.speechWarmupTexts?.() ?? collectSpeechWarmupTexts(strategyId, args))];
306
+
307
+ for (const text of texts) {
308
+ const item: SpeechAudioWarmup = { done: false };
309
+ warmups.set(text, item);
310
+ void maybeSynthesizeSpeechAudioUrl(text, undefined, client)
311
+ .then(url => {
312
+ item.url = url;
313
+ item.done = true;
314
+ if (url) store.append({ type: 'auto', message: 'speech audio warmed up' });
315
+ })
316
+ .catch((err: any) => {
317
+ item.done = true;
318
+ store.append({ type: 'auto', error: 'speech_audio_warmup_failed', message: formatErrorMessage(err) });
319
+ });
320
+ }
321
+
322
+ return warmups;
323
+ }
324
+
325
+ function pickDecision(
326
+ decisions: BehaviorDecision[],
327
+ blockedMoveTarget: ({ x: number; y: number; until: number }) | null,
328
+ activeMoveTarget: ActiveMoveTarget | null,
329
+ ): BehaviorDecision | null {
330
+ return decisions.find(d => {
331
+ if (repeatsActiveMoveTarget(d, activeMoveTarget)) return false;
332
+ const a = d.action.toJSON();
333
+ if (a.action !== 'move') return true;
334
+ if (!blockedMoveTarget || Date.now() >= blockedMoveTarget.until) return true;
335
+ const x = Number(a.target_x);
336
+ const y = Number(a.target_y);
337
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return true;
338
+ const dx = x - blockedMoveTarget.x;
339
+ const dy = y - blockedMoveTarget.y;
340
+ return Math.sqrt(dx ** 2 + dy ** 2) > 10;
341
+ }) ?? null;
342
+ }
343
+
344
+ function isSpeechDecision(decision: BehaviorDecision): boolean {
345
+ return decision.action.toJSON().action === 'speech';
346
+ }
347
+
348
+ function supportsSidecarSpeech(strategyId: string): boolean {
349
+ return strategyId === 'task-report'
350
+ || strategyId === 'corpse-patrol'
351
+ || strategyId === 'shrimp-memory'
352
+ || strategyId === 'paradise-fish';
353
+ }
354
+
355
+ export async function runStrategyLoop(strategyId: string, args?: string[]): Promise<void> {
356
+ const strategy = await resolveStrategy(strategyId, args);
357
+ const store = EventStore.forActiveAccount();
358
+ const pidPath = getPidPath();
359
+
360
+ if (shouldWriteStrategyRuntimeFiles()) writeFileSync(pidPath, String(process.pid));
361
+ store.append({ type: 'auto', message: 'strategy started', strategy: strategyId, pid: process.pid });
362
+
363
+ const client = GameClient.fromAuth();
364
+ await client.discoverGameServer();
365
+ const speechAudioWarmups = warmupSpeechAudio(strategyId, args, strategy, client, store);
366
+
367
+ const teammates = new Set<string>();
368
+ const playerNamesBySeat: Record<string, string> = {};
369
+ let blockedMoveTarget: { x: number; y: number; until: number } | null = null;
370
+ let consecutiveBlocks = 0;
371
+ const BLOCK_SKIP_THRESHOLD = 3;
372
+ let mapDirty = true;
373
+ let lastMapRefreshAt = 0;
374
+ const MAP_REFRESH_INTERVAL_MS = 10_000;
375
+
376
+ const knowledgePath = (() => { try { return knowledgeFilePathForActiveAccount(); } catch { return ''; } })();
377
+ const knowledgeGameId = currentGameId();
378
+ let lastKnowledgeMtimeMs = -1;
379
+
380
+ const ctx: StrategyContext = {
381
+ taskData: [],
382
+ taskLocations: [],
383
+ emergency: null,
384
+ taskLocalBlockedUntil: 0,
385
+ reportCorpseTarget: null,
386
+ reportBlockedUntil: 0,
387
+ notifications: [],
388
+ lastProgressNotifyAt: 0,
389
+ teammates,
390
+ alarmDone: false,
391
+ rooms: [],
392
+ playerNamesBySeat,
393
+ forcePatrolAdvance: false,
394
+ blockedMoveTarget: null,
395
+ mySeat: 0,
396
+ speechNotifications: [],
397
+ agentAlerts: [],
398
+ knownCorpses: [],
399
+ knowledge: emptyKnowledgeView(),
400
+ recentlyKilledTargets: new Map(),
401
+ };
402
+
403
+ // 全局唯一的尸体记忆:每 tick observe 后写入 ctx.knownCorpses,开会清场时 reset。
404
+ const corpseMemory = new CorpseMemory();
405
+
406
+ try { learnTeammatesFromEvents(eventsSinceCurrentSession(store.tail(1000)), teammates); } catch {}
407
+ const newEventsBackfill = createStrategyNewEventsBackfill(store.path);
408
+ try {
409
+ const roleInfo = await client.getRoleInfo();
410
+ learnSeatNames(roleInfo?.data?.all_seats ?? roleInfo?.all_seats, playerNamesBySeat);
411
+ const crabTeammates: any[] = roleInfo?.data?.crab_teammates ?? roleInfo?.crab_teammates ?? [];
412
+ for (const name of crabTeammates) {
413
+ if (typeof name === 'string' && name.length > 0) teammates.add(name);
414
+ }
415
+ const mySeat = Number(roleInfo?.data?.seat ?? roleInfo?.seat ?? 0);
416
+ if (mySeat > 0) ctx.mySeat = mySeat;
417
+ const role: string = roleInfo?.data?.role ?? roleInfo?.role ?? '';
418
+ if (role && strategy.updateRole) strategy.updateRole(role);
419
+ } catch {}
420
+
421
+ // Register custom modules before main loop so tick-1 events are captured
422
+ const customModules = strategy.customModules?.() ?? [];
423
+ if (customModules.length > 0) ctx.customModules = customModules;
424
+
425
+ let running = true;
426
+ let activeMoveTarget: ActiveMoveTarget | null = null;
427
+ let pausedForMeeting = false;
428
+ let releasedMeetingPause = false;
429
+ let currentRole = '';
430
+ let currentPlayerName = '';
431
+ let pistolKillUsed = false;
432
+ let pistolKillInitialized = false;
433
+ const onSignal = () => { running = false; };
434
+ process.on('SIGINT', onSignal);
435
+ process.on('SIGTERM', onSignal);
436
+
437
+ const submitAction = async (action: Action): Promise<{ acted: boolean }> => {
438
+ try {
439
+ const payload = action.toJSON();
440
+ const actionType = payload.action;
441
+ if (actionType === 'speech' && typeof payload.text === 'string' && !payload.audio_url) {
442
+ const warmup = speechAudioWarmups.get(payload.text);
443
+ if (warmup?.url) payload.audio_url = warmup.url;
444
+ }
445
+
446
+ const result = await client.submitAction(payload as any);
447
+ const newEvents = extractNewEvents(result);
448
+ store.appendNewEvents(newEvents);
449
+ // action 结果里回来的 corpse_spotted 也入账尸体记忆——主循环只 observe state.new_events,
450
+ // 那条通道会被 runtime WS listener 抢游标,这里补上避免尸体坐标漏记(ctx.knownCorpses 与 list() 同引用,立即可见)。
451
+ corpseMemory.ingestEvents(newEvents);
452
+ learnTeammatesFromEvents(newEvents, teammates);
453
+ if (currentRole === 'shrimp_pistol' && ownKillEvent(newEvents, currentPlayerName)) {
454
+ pistolKillUsed = true;
455
+ }
456
+ const actionResult = result?.data ?? result;
457
+ store.append({ type: 'auto', action: actionType, result: actionResult ?? result?.error ?? 'ok' });
458
+
459
+ const errorCode = actionResult?.error?.code ?? actionResult?.code ?? result?.error?.code;
460
+ const errorMessage = String(
461
+ actionResult?.error?.message ?? actionResult?.message ?? result?.error?.message ?? actionResult?.reason ?? '',
462
+ );
463
+ const failed = errorCode === 'ACTION_FAILED' || actionResult?.success === false || result?.success === false;
464
+ const queued = actionResult?.status === 'queued';
465
+
466
+ if (failed) {
467
+ ctx.reportCorpseTarget = null;
468
+ const failureText = JSON.stringify(actionResult ?? result ?? {});
469
+ if (actionType === 'kill' && currentRole === 'shrimp_pistol' && failureText.includes('role_cannot_kill')) {
470
+ pistolKillUsed = true;
471
+ }
472
+ if (actionType === 'task' || actionType === 'kill' || actionType === 'report' || actionType === 'trigger_alarm') {
473
+ activeMoveTarget = null;
474
+ }
475
+ if (actionType === 'kill' && errorMessage.includes('cannot_kill_teammate')) {
476
+ const target = action.toJSON().target;
477
+ if (typeof target === 'string' && target.length > 0) teammates.add(target);
478
+ }
479
+ if (actionType === 'task' && errorMessage.includes('not_at_task_location')) {
480
+ ctx.taskLocalBlockedUntil = Date.now() + 5000;
481
+ activeMoveTarget = null;
482
+ }
483
+ if (actionType === 'report') {
484
+ // doing_task 型失败不是真报不了:服务端只是因正在做任务而拒绝,下一 tick 任务被 move 打断后即可补报。
485
+ // 这种情况不设 5 秒退避,否则会错过「目击者刚靠近尸体」的报尸自证窗口;其余原因(距离不够等)仍退避避免空报刷屏。
486
+ if (!failureText.includes('doing_task')) {
487
+ ctx.reportBlockedUntil = Date.now() + 5000;
488
+ }
489
+ activeMoveTarget = null;
490
+ }
491
+ if (actionType === 'move') {
492
+ if (errorMessage.includes('invalid_position_blocked')) {
493
+ consecutiveBlocks++;
494
+ const payload = action.toJSON();
495
+ const x = Number(payload.target_x);
496
+ const y = Number(payload.target_y);
497
+ if (Number.isFinite(x) && Number.isFinite(y)) {
498
+ if (consecutiveBlocks >= BLOCK_SKIP_THRESHOLD) {
499
+ blockedMoveTarget = null;
500
+ consecutiveBlocks = 0;
501
+ ctx.forcePatrolAdvance = true;
502
+ ctx.notifications.push('移动目标多次不可达,跳过。');
503
+ } else {
504
+ blockedMoveTarget = { x, y, until: Date.now() + 5000 };
505
+ }
506
+ }
507
+ } else {
508
+ consecutiveBlocks = 0;
509
+ }
510
+ activeMoveTarget = null;
511
+ }
512
+ }
513
+
514
+ if (!failed && actionType === 'kill') {
515
+ const target = typeof payload.target === 'string' ? payload.target.trim().toLowerCase() : '';
516
+ if (target) ctx.recentlyKilledTargets?.set(target, Date.now() + RECENT_KILL_IGNORE_MS);
517
+ if (currentRole === 'shrimp_pistol') pistolKillUsed = true;
518
+ activeMoveTarget = null;
519
+ }
520
+
521
+ if (!failed && actionType === 'trigger_alarm') {
522
+ activeMoveTarget = null;
523
+ }
524
+
525
+ if (queued) {
526
+ if (actionType === 'task') {
527
+ mapDirty = true;
528
+ activeMoveTarget = null;
529
+ }
530
+ } else if (actionType === 'move' && !failed) {
531
+ consecutiveBlocks = 0;
532
+ const durationSecs = actionResult?.duration_secs ?? result?.duration_secs ?? 0;
533
+ const arrivalAt = Date.now() + Math.max(0.5, durationSecs) * 1000;
534
+ activeMoveTarget = activeMoveTargetFromPayload(payload, arrivalAt + 500);
535
+ } else if (!failed && (actionType === 'task' || actionType === 'report')) {
536
+ activeMoveTarget = null;
537
+ }
538
+
539
+ return { acted: true };
540
+ } catch (e: any) {
541
+ store.append({ type: 'auto', error: 'submit_action_failed', message: formatErrorMessage(e) });
542
+ ctx.reportCorpseTarget = null;
543
+ if (action.toJSON().action === 'move') {
544
+ activeMoveTarget = null;
545
+ }
546
+ return { acted: true };
547
+ }
548
+ };
549
+
550
+ while (running) {
551
+ let state: GameState | null;
552
+ try {
553
+ state = await client.getGameState();
554
+ } catch {
555
+ await sleep(2000);
556
+ continue;
557
+ }
558
+
559
+ if (!state) {
560
+ store.append({ type: 'auto', message: 'no active game, retrying' });
561
+ await sleep(2000);
562
+ continue;
563
+ }
564
+
565
+ store.appendNewEvents(state.new_events);
566
+ state.new_events = newEventsBackfill.correct(state.new_events);
567
+ currentRole = state.you.role ?? currentRole;
568
+ currentPlayerName = state.you.name ?? currentPlayerName;
569
+ if (currentRole === 'shrimp_pistol') {
570
+ if (!pistolKillInitialized) {
571
+ pistolKillUsed = pistolKillUsed || ownKillEvent(eventsSinceCurrentSession(store.tail(1000)), currentPlayerName);
572
+ pistolKillInitialized = true;
573
+ }
574
+ if (state.you.kills_remaining === 0 || ownKillEvent(state.new_events, currentPlayerName)) {
575
+ pistolKillUsed = true;
576
+ }
577
+ state.you.kills_remaining = pistolKillUsed ? 0 : (state.you.kills_remaining ?? 1);
578
+ }
579
+
580
+ learnTeammatesFromEvents(state.new_events, teammates);
581
+ learnSeatNames(state.all_players, playerNamesBySeat);
582
+ const terminalMove = ownMovementTerminalEvent(state.new_events, state.you.name);
583
+ if (terminalMove) {
584
+ activeMoveTarget = null;
585
+ }
586
+
587
+ // Process events through strategy-registered custom modules
588
+ const currentTick = state.tick ?? 0;
589
+ const newEvents = state.new_events ?? [];
590
+ for (const mod of ctx.customModules ?? []) {
591
+ mod.processEvents(newEvents, currentTick, state);
592
+ }
593
+
594
+ if (ctx.mySeat === 0 && state.you.seat != null) {
595
+ ctx.mySeat = state.you.seat;
596
+ }
597
+
598
+ const prevEmergency = ctx.emergency;
599
+ ctx.emergency = state.emergency ?? null;
600
+ if (ctx.emergency && !prevEmergency) {
601
+ const room = ctx.emergency.room ?? '未知';
602
+ ctx.notifications.push(`紧急任务出现!「${ctx.emergency.task_name}」在${room},剩余${Math.round(ctx.emergency.remaining_secs)}秒。`);
603
+ }
604
+
605
+ if (meetingStartedInEvents(state.new_events)) {
606
+ releasedMeetingPause = false;
607
+ }
608
+ if (state.phase !== 'meeting') {
609
+ releasedMeetingPause = false;
610
+ }
611
+ if (meetingEndedInEvents(state.new_events)) {
612
+ releasedMeetingPause = true;
613
+ }
614
+
615
+ if (state.phase === 'game_over') {
616
+ store.append({ type: 'auto', message: 'game over' });
617
+ break;
618
+ }
619
+ if (state.you.is_alive === false) {
620
+ store.append({ type: 'auto', message: 'player dead, stopping strategy', strategy: strategyId });
621
+ break;
622
+ }
623
+ if (shouldPauseForMeeting(state, releasedMeetingPause)) {
624
+ if (!pausedForMeeting) {
625
+ store.append({ type: 'auto', message: 'meeting started, pausing strategy', strategy: strategyId });
626
+ pausedForMeeting = true;
627
+ activeMoveTarget = null;
628
+ }
629
+ await sleep(2000);
630
+ continue;
631
+ }
632
+ if (pausedForMeeting) {
633
+ store.append({ type: 'auto', message: 'meeting ended, resuming strategy', strategy: strategyId });
634
+ pausedForMeeting = false;
635
+ releasedMeetingPause = true;
636
+ mapDirty = true;
637
+ activeMoveTarget = null;
638
+ blockedMoveTarget = null;
639
+ consecutiveBlocks = 0;
640
+ ctx.taskLocalBlockedUntil = 0;
641
+ ctx.reportCorpseTarget = null;
642
+ ctx.reportBlockedUntil = 0;
643
+ ctx.forcePatrolAdvance = false;
644
+ ctx.blockedMoveTarget = null;
645
+ // 开会后尸体清场:清空尸体记忆,避免回避/报告已不存在的尸体。
646
+ corpseMemory.reset();
647
+ ctx.knownCorpses = [];
648
+ // Notify strategy-registered custom modules (decay instead of full reset)
649
+ for (const mod of ctx.customModules ?? []) {
650
+ mod.onMeetingResume?.();
651
+ }
652
+ strategy.onMeetingResume?.();
653
+ }
654
+
655
+ if (mapDirty || Date.now() - lastMapRefreshAt >= MAP_REFRESH_INTERVAL_MS) {
656
+ try {
657
+ const mapData = await client.getMap();
658
+ const taskFactionByName = new Map<string, 'lobster' | 'crab'>(
659
+ (mapData?.all_task_locations ?? [])
660
+ .filter((t: any) => t?.name && (t.faction === 'lobster' || t.faction === 'crab'))
661
+ .map((t: any) => [t.name, t.faction]),
662
+ );
663
+ ctx.taskData = (mapData?.your_tasks ?? []).map((t: any) => ({
664
+ task_id: t.name ?? '',
665
+ task_name: t.name ?? '',
666
+ room: t.room ?? '',
667
+ status: t.status ?? 'normal',
668
+ x: t.x,
669
+ y: t.y,
670
+ is_fake_shrimp: t.is_fake_shrimp ?? false,
671
+ faction: taskFactionByName.get(t.name),
672
+ }));
673
+ ctx.taskLocations = (mapData?.all_task_locations ?? [])
674
+ .filter((t: any) => t && typeof t.name === 'string' && typeof t.x === 'number' && typeof t.y === 'number')
675
+ .map((t: any) => ({ name: t.name, room: t.room, x: t.x, y: t.y, faction: t.faction }));
676
+ if (Array.isArray(mapData?.rooms)) {
677
+ if (ctx.rooms.length === 0) {
678
+ const byRoom = taskLocationsByRoom(mapData?.all_task_locations);
679
+ ctx.rooms = mapData.rooms.map((r: any, index: number) => roomAnchor(r, index, byRoom));
680
+ }
681
+ // Notify custom modules of new map data
682
+ for (const mod of ctx.customModules ?? []) {
683
+ mod.onMapLoaded?.(mapData.rooms);
684
+ }
685
+ }
686
+ mapDirty = false;
687
+ lastMapRefreshAt = Date.now();
688
+ } catch (e: any) {
689
+ store.append({ type: 'auto', error: 'map_load_failed', message: formatErrorMessage(e) });
690
+ }
691
+ }
692
+
693
+ const activeBlockedMoveTarget = ((): { x: number; y: number; until: number } | null => blockedMoveTarget)();
694
+ ctx.blockedMoveTarget = activeBlockedMoveTarget && Date.now() < activeBlockedMoveTarget.until
695
+ ? { x: activeBlockedMoveTarget.x, y: activeBlockedMoveTarget.y }
696
+ : null;
697
+
698
+ if (knowledgePath) {
699
+ try {
700
+ const mtime = existsSync(knowledgePath) ? statSync(knowledgePath).mtimeMs : 0;
701
+ if (mtime !== lastKnowledgeMtimeMs) {
702
+ lastKnowledgeMtimeMs = mtime;
703
+ const result = readKnowledgeFileResult(knowledgePath);
704
+ if (result.status !== 'invalid') {
705
+ const scoped = result.status === 'ok' && result.file.gameId === knowledgeGameId ? result.file : null;
706
+ ctx.knowledge = buildKnowledgeView(scoped, { playerNamesBySeat });
707
+ }
708
+ }
709
+ } catch {}
710
+ }
711
+
712
+ // 更新跨 tick 尸体记忆,暴露给所有策略(检测/接近/任务回避/死亡确认统一读 ctx.knownCorpses)。
713
+ corpseMemory.observe(state, ctx.recentlyKilledTargets);
714
+ ctx.knownCorpses = corpseMemory.list();
715
+
716
+ let decisions = strategy.decide(state, ctx);
717
+
718
+ // Run custom module afterDecide pipeline (chained by registration order)
719
+ for (const mod of ctx.customModules ?? []) {
720
+ if (mod.afterDecide) {
721
+ decisions = mod.afterDecide(decisions, state, ctx);
722
+ }
723
+ }
724
+
725
+ const sidecarSpeech = supportsSidecarSpeech(strategyId) ? decisions.filter(isSpeechDecision) : [];
726
+ const mainDecisions = sidecarSpeech.length > 0 ? decisions.filter(d => !isSpeechDecision(d)) : decisions;
727
+
728
+ const decision = pickDecision(
729
+ mainDecisions,
730
+ activeBlockedMoveTarget,
731
+ activeMoveTarget,
732
+ );
733
+
734
+ if (decision) {
735
+ await submitAction(decision.action);
736
+ }
737
+ for (const speech of sidecarSpeech) {
738
+ await submitAction(speech.action);
739
+ }
740
+
741
+ if (ctx.notifications.length > 0) {
742
+ for (const msg of ctx.notifications) {
743
+ store.append({ type: 'auto', message: msg, strategy: strategyId });
744
+ }
745
+ ctx.notifications.length = 0;
746
+ }
747
+
748
+ if (ctx.agentAlerts.length > 0) {
749
+ const tick = typeof state.tick === 'number' ? state.tick : undefined;
750
+ for (const msg of ctx.agentAlerts) {
751
+ store.append({ type: 'strategy_alert', message: msg, strategy: strategyId, tick });
752
+ }
753
+ ctx.agentAlerts.length = 0;
754
+ }
755
+
756
+ if (ctx.speechNotifications.length > 0) {
757
+ for (const msg of ctx.speechNotifications) {
758
+ store.append({ type: 'robot_speak_rule', message: msg });
759
+ }
760
+ ctx.speechNotifications.length = 0;
761
+ }
762
+
763
+ await sleep(500);
764
+ }
765
+
766
+ process.off('SIGINT', onSignal);
767
+ process.off('SIGTERM', onSignal);
768
+ if (shouldWriteStrategyRuntimeFiles()) {
769
+ try { unlinkSync(pidPath); } catch {}
770
+ }
771
+ }