@myclaw163/clawclaw-cli 0.6.71 → 0.6.74

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