@myclaw163/clawclaw-cli 0.6.76 → 0.6.77

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 (209) hide show
  1. package/README.md +387 -387
  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 -116
  10. package/scripts/find-hide-spots.py +157 -157
  11. package/scripts/postinstall.mjs +20 -20
  12. package/scripts/sync-bundled-skill.mjs +254 -245
  13. package/scripts/sync-bundled-skill.test.mjs +152 -152
  14. package/skills/clawclaw/SKILL.md +248 -248
  15. package/skills/clawclaw/references/CHATTERBOX.md +141 -141
  16. package/skills/clawclaw/references/COMMANDS.md +160 -160
  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 -42
  20. package/skills/clawclaw/references/STRATEGIES.md +59 -59
  21. package/skills/clawclaw/references/STREAM.md +93 -93
  22. package/skills/clawclaw/references/TACTICS.md +65 -65
  23. package/src/assets/clawclaw-ascii-map.txt +40 -40
  24. package/src/cli.ts +112 -112
  25. package/src/commands/_schema.ts +124 -124
  26. package/src/commands/account.ts +209 -209
  27. package/src/commands/data.test.ts +33 -33
  28. package/src/commands/data.ts +22 -22
  29. package/src/commands/do.test.ts +84 -84
  30. package/src/commands/do.ts +130 -130
  31. package/src/commands/events.test.ts +100 -100
  32. package/src/commands/events.ts +250 -250
  33. package/src/commands/game-map.test.ts +28 -28
  34. package/src/commands/game-start-plan.test.ts +84 -84
  35. package/src/commands/game.ts +1113 -1113
  36. package/src/commands/history-player.test.ts +102 -102
  37. package/src/commands/history.ts +573 -573
  38. package/src/commands/hub.test.ts +96 -96
  39. package/src/commands/hub.ts +234 -234
  40. package/src/commands/knowledge.test.ts +13 -13
  41. package/src/commands/knowledge.ts +139 -139
  42. package/src/commands/load.test.ts +51 -51
  43. package/src/commands/load.ts +13 -13
  44. package/src/commands/meeting-history.test.ts +106 -106
  45. package/src/commands/memory.ts +40 -40
  46. package/src/commands/peek.ts +45 -45
  47. package/src/commands/persona.ts +57 -57
  48. package/src/commands/setup/codex.ts +266 -266
  49. package/src/commands/skill.ts +128 -128
  50. package/src/commands/state.ts +46 -46
  51. package/src/commands/strategy.test.ts +153 -153
  52. package/src/commands/strategy.ts +183 -183
  53. package/src/commands/tts.ts +128 -128
  54. package/src/commands/upgrade.test.ts +82 -82
  55. package/src/commands/upgrade.ts +148 -148
  56. package/src/commands/watch.test.ts +999 -999
  57. package/src/commands/watch.ts +660 -660
  58. package/src/lib/auth.test.ts +86 -86
  59. package/src/lib/auth.ts +223 -223
  60. package/src/lib/command-meta.ts +37 -37
  61. package/src/lib/game-client.ts +403 -403
  62. package/src/lib/game-context.ts +92 -92
  63. package/src/lib/http-keepalive.ts +15 -15
  64. package/src/lib/http-transport.test.ts +42 -42
  65. package/src/lib/http-transport.ts +113 -113
  66. package/src/lib/hub-client.test.ts +56 -56
  67. package/src/lib/hub-client.ts +88 -88
  68. package/src/lib/hub-install.test.ts +98 -98
  69. package/src/lib/hub-install.ts +160 -160
  70. package/src/lib/hub-reminder.ts +78 -78
  71. package/src/lib/hub-unzip.test.ts +69 -69
  72. package/src/lib/hub-unzip.ts +62 -62
  73. package/src/lib/init-command.test.ts +75 -75
  74. package/src/lib/init-command.ts +130 -130
  75. package/src/lib/knowledge-store.test.ts +170 -170
  76. package/src/lib/knowledge-store.ts +369 -369
  77. package/src/lib/load-context.test.ts +52 -52
  78. package/src/lib/load-context.ts +52 -52
  79. package/src/lib/match-state.test.ts +134 -134
  80. package/src/lib/match-state.ts +94 -94
  81. package/src/lib/netease-tts.ts +83 -83
  82. package/src/lib/normalize.ts +42 -42
  83. package/src/lib/persona.test.ts +41 -41
  84. package/src/lib/persona.ts +72 -72
  85. package/src/lib/server-registry.ts +152 -152
  86. package/src/lib/skill-version.test.ts +48 -48
  87. package/src/lib/skill-version.ts +19 -19
  88. package/src/lib/strategy-export.test.ts +240 -240
  89. package/src/lib/strategy-export.ts +247 -247
  90. package/src/lib/tts-keys.ts +7 -7
  91. package/src/lib/tts-speech.test.ts +63 -63
  92. package/src/lib/tts-speech.ts +76 -76
  93. package/src/lib/user-data.test.ts +96 -96
  94. package/src/lib/user-data.ts +400 -400
  95. package/src/lib/workspace-argv.test.ts +49 -49
  96. package/src/lib/workspace-argv.ts +44 -44
  97. package/src/perception/player-history-store.test.ts +87 -87
  98. package/src/perception/player-history-store.ts +194 -194
  99. package/src/pipeline/event-format.test.ts +243 -243
  100. package/src/pipeline/event-format.ts +501 -501
  101. package/src/pipeline/event-hints.ts +195 -195
  102. package/src/pipeline/event-store.test.ts +28 -28
  103. package/src/pipeline/event-store.ts +193 -193
  104. package/src/pipeline/pipeline.ts +35 -35
  105. package/src/pipeline/player-projection.test.ts +168 -168
  106. package/src/pipeline/player-projection.ts +370 -370
  107. package/src/runtime/auto-upgrade.test.ts +66 -66
  108. package/src/runtime/auto-upgrade.ts +31 -31
  109. package/src/runtime/event-daemon.test.ts +209 -209
  110. package/src/runtime/event-daemon.ts +519 -519
  111. package/src/runtime/owner-control.ts +150 -150
  112. package/src/runtime/raw-ws-log.test.ts +33 -33
  113. package/src/runtime/raw-ws-log.ts +32 -32
  114. package/src/runtime/runtime-logger.ts +107 -107
  115. package/src/runtime/ws-client.test.ts +125 -125
  116. package/src/runtime/ws-client.ts +287 -287
  117. package/src/sdk/action.ts +166 -166
  118. package/src/sdk/index.ts +110 -110
  119. package/src/sdk/types.ts +161 -161
  120. package/src/strategies/avoid-lone.ts +12 -12
  121. package/src/strategies/avoid-players.knowledge.md +19 -19
  122. package/src/strategies/avoid-players.ts +16 -16
  123. package/src/strategies/corpse-patrol.ts +23 -23
  124. package/src/strategies/crab-sabotage.ts +22 -22
  125. package/src/strategies/custom-module.test.ts +270 -270
  126. package/src/strategies/find-player.ts +17 -17
  127. package/src/strategies/game-utils.test.ts +242 -242
  128. package/src/strategies/game-utils.ts +846 -846
  129. package/src/strategies/goals/anchor-linger.ts +77 -77
  130. package/src/strategies/goals/avoid-lone-top.ts +168 -168
  131. package/src/strategies/goals/avoid-players-top.test.ts +83 -83
  132. package/src/strategies/goals/avoid-players-top.ts +121 -121
  133. package/src/strategies/goals/conversation-goal.ts +51 -51
  134. package/src/strategies/goals/corpse-patrol-top.ts +113 -113
  135. package/src/strategies/goals/crab-octopus-reflexes.ts +101 -101
  136. package/src/strategies/goals/crab-sabotage-top.ts +197 -197
  137. package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
  138. package/src/strategies/goals/find-player-top.ts +93 -93
  139. package/src/strategies/goals/flee-players-goal.ts +53 -53
  140. package/src/strategies/goals/follow-companion-goal.ts +106 -106
  141. package/src/strategies/goals/goal-manager.ts +41 -41
  142. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  143. package/src/strategies/goals/goal.ts +28 -28
  144. package/src/strategies/goals/hide-top.ts +197 -197
  145. package/src/strategies/goals/keep-away-goal.ts +221 -221
  146. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  147. package/src/strategies/goals/kill-lone-top.ts +160 -160
  148. package/src/strategies/goals/kill-target-goal.ts +59 -59
  149. package/src/strategies/goals/kill-target-top.ts +109 -109
  150. package/src/strategies/goals/leaf-goal.ts +27 -27
  151. package/src/strategies/goals/linger-corpse-goal.ts +35 -35
  152. package/src/strategies/goals/lone-kill-core.ts +82 -82
  153. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  154. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  155. package/src/strategies/goals/lone-kill-task-top.ts +133 -133
  156. package/src/strategies/goals/move-room-goal.ts +60 -60
  157. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  158. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  159. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  160. package/src/strategies/goals/paradise-fish-top.ts +224 -224
  161. package/src/strategies/goals/patrol-top.ts +57 -57
  162. package/src/strategies/goals/report-patrol-top.ts +80 -80
  163. package/src/strategies/goals/safe-task-goal.ts +102 -102
  164. package/src/strategies/goals/social-task-top.ts +161 -161
  165. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  166. package/src/strategies/goals/task-only-top.ts +57 -57
  167. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  168. package/src/strategies/goals/task-report-top.ts +57 -57
  169. package/src/strategies/goals/wander-task-goal.ts +33 -33
  170. package/src/strategies/goals/warrior-shrimp-top.test.ts +87 -87
  171. package/src/strategies/goals/warrior-shrimp-top.ts +267 -267
  172. package/src/strategies/greeting.ts +53 -53
  173. package/src/strategies/hide-spots.ts +59 -59
  174. package/src/strategies/hide.ts +24 -24
  175. package/src/strategies/kill-frenzy.ts +13 -13
  176. package/src/strategies/kill-lone.knowledge.md +17 -17
  177. package/src/strategies/kill-lone.ts +14 -14
  178. package/src/strategies/kill-target.ts +19 -19
  179. package/src/strategies/loader.test.ts +678 -678
  180. package/src/strategies/loader.ts +181 -181
  181. package/src/strategies/lone-kill-task.ts +22 -22
  182. package/src/strategies/meeting-gate.test.ts +59 -59
  183. package/src/strategies/meeting-gate.ts +23 -23
  184. package/src/strategies/move-room.ts +16 -16
  185. package/src/strategies/new-events-backfill.ts +98 -98
  186. package/src/strategies/off-route-points.ts +105 -105
  187. package/src/strategies/paradise-fish.knowledge.md +19 -19
  188. package/src/strategies/paradise-fish.ts +26 -26
  189. package/src/strategies/pathfind/distance-field.ts +150 -150
  190. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  191. package/src/strategies/pathfind/escape-planner.ts +355 -355
  192. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  193. package/src/strategies/patrol.ts +12 -12
  194. package/src/strategies/player-targets.ts +13 -13
  195. package/src/strategies/report-patrol.ts +12 -12
  196. package/src/strategies/shrimp-memory.knowledge.md +19 -19
  197. package/src/strategies/shrimp-memory.ts +26 -26
  198. package/src/strategies/social-task.test.ts +28 -28
  199. package/src/strategies/social-task.ts +50 -50
  200. package/src/strategies/spawn.ts +82 -82
  201. package/src/strategies/speech-module.ts +123 -123
  202. package/src/strategies/strategy-loop.test.ts +15 -15
  203. package/src/strategies/strategy-loop.ts +776 -776
  204. package/src/strategies/task-kill-report.ts +18 -18
  205. package/src/strategies/task-only.ts +12 -12
  206. package/src/strategies/task-report.ts +23 -23
  207. package/src/strategies/types.ts +109 -109
  208. package/src/strategies/warrior-memory.knowledge.md +21 -21
  209. package/src/strategies/warrior-memory.ts +17 -17
@@ -1,776 +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
- 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
- }
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
+ }