@myclaw163/clawclaw-cli 0.6.57 → 0.6.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +440 -440
  2. package/package.json +48 -48
  3. package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
  4. package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
  5. package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
  6. package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
  7. package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
  8. package/scripts/postinstall.mjs +20 -20
  9. package/scripts/sync-bundled-skill.mjs +244 -244
  10. package/scripts/sync-bundled-skill.test.mjs +152 -152
  11. package/skills/clawclaw/SKILL.md +244 -244
  12. package/skills/clawclaw/references/CHATTERBOX.md +142 -142
  13. package/skills/clawclaw/references/COMMANDS.md +132 -132
  14. package/skills/clawclaw/references/GAME-MECHANICS.md +186 -186
  15. package/skills/clawclaw/references/HUB.md +48 -48
  16. package/skills/clawclaw/references/KNOWLEDGE.md +43 -43
  17. package/skills/clawclaw/references/STRATEGIES.md +57 -57
  18. package/skills/clawclaw/references/STREAM.md +59 -59
  19. package/skills/clawclaw/references/TACTICS.md +65 -65
  20. package/src/assets/clawclaw-ascii-map.txt +40 -40
  21. package/src/cli.ts +110 -110
  22. package/src/commands/_schema.ts +109 -109
  23. package/src/commands/account.ts +209 -209
  24. package/src/commands/config.ts +30 -30
  25. package/src/commands/do.test.ts +37 -37
  26. package/src/commands/do.ts +95 -95
  27. package/src/commands/events.ts +22 -22
  28. package/src/commands/game-map.test.ts +28 -28
  29. package/src/commands/game-start-plan.test.ts +84 -84
  30. package/src/commands/game.ts +1027 -1027
  31. package/src/commands/history-player.test.ts +102 -102
  32. package/src/commands/history.ts +573 -573
  33. package/src/commands/hub.test.ts +96 -96
  34. package/src/commands/hub.ts +234 -234
  35. package/src/commands/knowledge.test.ts +19 -19
  36. package/src/commands/knowledge.ts +168 -168
  37. package/src/commands/load.test.ts +51 -51
  38. package/src/commands/load.ts +13 -13
  39. package/src/commands/meeting-history.test.ts +106 -106
  40. package/src/commands/memory.ts +40 -40
  41. package/src/commands/peek.ts +45 -45
  42. package/src/commands/persona.ts +57 -57
  43. package/src/commands/setup/codex.ts +248 -248
  44. package/src/commands/setup/hermes.test.ts +96 -96
  45. package/src/commands/setup/hermes.ts +76 -76
  46. package/src/commands/setup/index.ts +13 -13
  47. package/src/commands/setup/openclaw.test.ts +114 -114
  48. package/src/commands/setup/openclaw.ts +147 -147
  49. package/src/commands/skill.ts +128 -128
  50. package/src/commands/state.ts +46 -46
  51. package/src/commands/strategy.test.ts +135 -135
  52. package/src/commands/strategy.ts +180 -180
  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 +969 -969
  57. package/src/commands/watch.ts +720 -720
  58. package/src/lib/auth.test.ts +59 -59
  59. package/src/lib/auth.ts +186 -186
  60. package/src/lib/command-meta.ts +37 -37
  61. package/src/lib/game-client.ts +391 -391
  62. package/src/lib/host-config-patcher.test.ts +130 -130
  63. package/src/lib/host-config-patcher.ts +151 -151
  64. package/src/lib/http-keepalive.ts +15 -15
  65. package/src/lib/http-transport.test.ts +42 -42
  66. package/src/lib/http-transport.ts +113 -113
  67. package/src/lib/hub-client.test.ts +56 -56
  68. package/src/lib/hub-client.ts +88 -88
  69. package/src/lib/hub-install.test.ts +98 -98
  70. package/src/lib/hub-install.ts +121 -121
  71. package/src/lib/hub-reminder.ts +56 -56
  72. package/src/lib/hub-unzip.test.ts +69 -69
  73. package/src/lib/hub-unzip.ts +62 -62
  74. package/src/lib/init-command.test.ts +75 -75
  75. package/src/lib/init-command.ts +120 -120
  76. package/src/lib/knowledge-store.test.ts +180 -180
  77. package/src/lib/knowledge-store.ts +374 -374
  78. package/src/lib/load-context.test.ts +52 -52
  79. package/src/lib/load-context.ts +52 -52
  80. package/src/lib/match-state.test.ts +134 -134
  81. package/src/lib/match-state.ts +94 -94
  82. package/src/lib/netease-tts.ts +83 -83
  83. package/src/lib/normalize.ts +42 -42
  84. package/src/lib/persona.test.ts +41 -41
  85. package/src/lib/persona.ts +72 -72
  86. package/src/lib/server-registry.ts +152 -152
  87. package/src/lib/skill-version.test.ts +48 -48
  88. package/src/lib/skill-version.ts +19 -19
  89. package/src/lib/strategy-export.test.ts +232 -232
  90. package/src/lib/strategy-export.ts +242 -242
  91. package/src/lib/tts-keys.ts +7 -7
  92. package/src/lib/tts-speech.test.ts +63 -63
  93. package/src/lib/tts-speech.ts +76 -76
  94. package/src/lib/workspace-argv.test.ts +49 -49
  95. package/src/lib/workspace-argv.ts +44 -44
  96. package/src/perception/player-history-store.test.ts +87 -87
  97. package/src/perception/player-history-store.ts +194 -194
  98. package/src/pipeline/event-store.ts +124 -124
  99. package/src/pipeline/pipeline.ts +35 -35
  100. package/src/runtime/auto-upgrade.test.ts +66 -66
  101. package/src/runtime/auto-upgrade.ts +31 -31
  102. package/src/runtime/event-daemon.test.ts +107 -107
  103. package/src/runtime/event-daemon.ts +409 -409
  104. package/src/runtime/owner-control.ts +150 -150
  105. package/src/runtime/raw-ws-log.test.ts +33 -33
  106. package/src/runtime/raw-ws-log.ts +32 -32
  107. package/src/runtime/runtime-logger.ts +107 -107
  108. package/src/runtime/ws-client.test.ts +104 -104
  109. package/src/runtime/ws-client.ts +272 -272
  110. package/src/sdk/action.ts +166 -166
  111. package/src/sdk/index.ts +110 -110
  112. package/src/sdk/types.ts +146 -146
  113. package/src/strategies/avoid-lone.ts +11 -11
  114. package/src/strategies/avoid-players.knowledge.md +20 -20
  115. package/src/strategies/avoid-players.ts +15 -15
  116. package/src/strategies/corpse-patrol.ts +22 -22
  117. package/src/strategies/crab-sabotage.ts +21 -21
  118. package/src/strategies/custom-module.test.ts +269 -269
  119. package/src/strategies/find-player.ts +16 -16
  120. package/src/strategies/game-utils.test.ts +190 -164
  121. package/src/strategies/game-utils.ts +744 -737
  122. package/src/strategies/goals/avoid-lone-top.ts +168 -168
  123. package/src/strategies/goals/avoid-players-top.test.ts +83 -83
  124. package/src/strategies/goals/avoid-players-top.ts +121 -121
  125. package/src/strategies/goals/conversation-goal.ts +51 -51
  126. package/src/strategies/goals/corpse-patrol-top.ts +91 -91
  127. package/src/strategies/goals/crab-octopus-reflexes.ts +93 -93
  128. package/src/strategies/goals/crab-sabotage-top.ts +197 -197
  129. package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
  130. package/src/strategies/goals/find-player-top.ts +93 -93
  131. package/src/strategies/goals/flee-players-goal.ts +53 -53
  132. package/src/strategies/goals/goal-manager.ts +41 -41
  133. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  134. package/src/strategies/goals/goal.ts +28 -28
  135. package/src/strategies/goals/keep-away-goal.ts +209 -206
  136. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  137. package/src/strategies/goals/kill-lone-top.ts +160 -160
  138. package/src/strategies/goals/kill-target-goal.ts +59 -59
  139. package/src/strategies/goals/kill-target-top.ts +109 -109
  140. package/src/strategies/goals/leaf-goal.ts +25 -25
  141. package/src/strategies/goals/linger-corpse-goal.ts +79 -79
  142. package/src/strategies/goals/lone-kill-core.ts +82 -82
  143. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  144. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  145. package/src/strategies/goals/lone-kill-task-top.ts +86 -86
  146. package/src/strategies/goals/move-room-goal.ts +60 -60
  147. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  148. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  149. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  150. package/src/strategies/goals/paradise-fish-top.ts +219 -219
  151. package/src/strategies/goals/patrol-top.ts +57 -57
  152. package/src/strategies/goals/report-patrol-top.ts +80 -80
  153. package/src/strategies/goals/safe-task-goal.ts +102 -102
  154. package/src/strategies/goals/social-task-top.ts +161 -161
  155. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  156. package/src/strategies/goals/task-only-top.ts +57 -57
  157. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  158. package/src/strategies/goals/task-report-top.ts +57 -57
  159. package/src/strategies/goals/wander-task-goal.ts +33 -33
  160. package/src/strategies/goals/warrior-shrimp-top.test.ts +86 -86
  161. package/src/strategies/goals/warrior-shrimp-top.ts +248 -248
  162. package/src/strategies/greeting.ts +53 -53
  163. package/src/strategies/kill-frenzy.ts +12 -12
  164. package/src/strategies/kill-lone.knowledge.md +20 -20
  165. package/src/strategies/kill-lone.ts +13 -13
  166. package/src/strategies/kill-target.ts +18 -18
  167. package/src/strategies/loader.test.ts +678 -678
  168. package/src/strategies/loader.ts +172 -172
  169. package/src/strategies/lone-kill-task.ts +21 -21
  170. package/src/strategies/meeting-gate.test.ts +59 -59
  171. package/src/strategies/meeting-gate.ts +23 -23
  172. package/src/strategies/move-room.ts +15 -15
  173. package/src/strategies/new-events-backfill.ts +98 -98
  174. package/src/strategies/paradise-fish.knowledge.md +20 -20
  175. package/src/strategies/paradise-fish.ts +25 -25
  176. package/src/strategies/pathfind/distance-field.ts +150 -150
  177. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  178. package/src/strategies/pathfind/escape-planner.ts +355 -348
  179. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  180. package/src/strategies/patrol.ts +11 -11
  181. package/src/strategies/player-targets.ts +13 -13
  182. package/src/strategies/report-patrol.ts +11 -11
  183. package/src/strategies/shrimp-memory.knowledge.md +20 -20
  184. package/src/strategies/shrimp-memory.ts +25 -25
  185. package/src/strategies/social-task.test.ts +28 -28
  186. package/src/strategies/social-task.ts +49 -49
  187. package/src/strategies/spawn.ts +82 -82
  188. package/src/strategies/speech-module.ts +123 -123
  189. package/src/strategies/strategy-loop.ts +763 -763
  190. package/src/strategies/task-kill-report.ts +17 -17
  191. package/src/strategies/task-only.ts +11 -11
  192. package/src/strategies/task-report.ts +22 -22
  193. package/src/strategies/types.ts +96 -96
  194. package/src/strategies/warrior-memory.knowledge.md +20 -20
  195. package/src/strategies/warrior-memory.ts +16 -16
@@ -1,737 +1,744 @@
1
- import type { GameState, PlayerInfo, Position, TaskInfo, EmergencyInfo, CorpseInfo } from '../sdk/types.js';
2
- import { Action } from '../sdk/action.js';
3
- import type { BehaviorDecision, CorpseTarget, RoomTarget, StrategyContext } from './types.js';
4
- import { emptyKnowledgeView } from '../lib/knowledge-store.js';
5
- import { assessRoutes } from './pathfind/escape-planner.js';
6
-
7
- export const TASK_SUBMIT_RADIUS = 12;
8
- /** 蟹/章鱼/兜底角色的服务端击杀距离(= 后端全局 kill_range)。 */
9
- export const BASE_KILL_RANGE = 80;
10
- /** 武士虾/枪虾的服务端击杀距离(带刀好人,射程是蟹/章鱼的两倍)。 */
11
- export const SHRIMP_KILL_RANGE = 160;
12
- export const REPORT_RANGE = 150;
13
- export const SHRIMP_VISION_RANGE = 270;
14
- export const SHRIMP_VISION_EXIT_BUFFER = 30;
15
- export const SHRIMP_VISION_RELEASE_RANGE = SHRIMP_VISION_RANGE + SHRIMP_VISION_EXIT_BUFFER;
16
- export const FOLLOW_RANGE = 300;
17
- export const PATROL_REACHED_DISTANCE = 150;
18
- export const PROGRESS_INTERVAL_MS = 30_000;
19
- export const LONE_IGNORE_MS = 10_000;
20
- export const LONE_FOLLOW_DISTANCE = 10;
21
- /** 任务点离任一可见尸体多近就算「在尸体旁」,会做任务伪装的坏人据此跳过该任务、避免站在命案现场做任务。 */
22
- export const CORPSE_TASK_AVOID_RANGE = 100;
23
- /** 判「同一具尸体」的极近半径:尸体不会移动,跨 tick 只有坐标取整抖动,远小于两具不同尸体的间距。 */
24
- export const CORPSE_SAME_BODY_RADIUS = 20;
25
- /** corpse_spotted 事件坐标离自己多近才算「人就在命案现场」——超出视为别处尸体的迟到/补发事件,不触发现场反射。 */
26
- export const CORPSE_SCENE_RANGE = REPORT_RANGE;
27
-
28
- export function killCooldownSecs(state: GameState): number {
29
- return state.you.kill_cooldown_secs ?? 0;
30
- }
31
-
32
- export function hasKillUseRemaining(state: GameState): boolean {
33
- return state.you.kills_remaining == null || state.you.kills_remaining > 0;
34
- }
35
-
36
- export function canUseKill(state: GameState): boolean {
37
- return hasKillUseRemaining(state) && killCooldownSecs(state) <= 0;
38
- }
39
-
40
- const KILL_COMMIT_FACTOR = 0.9;
41
- const LONG_RANGE_KILL_ROLES = new Set(['shrimp_warrior', 'shrimp_pistol']);
42
-
43
- /** 角色的服务端实际击杀距离,镜像后端 effective_kill_range。武士虾/枪虾 160,其余 80。 */
44
- export function killRangeFor(role: string | undefined): number {
45
- return role && LONG_RANGE_KILL_ROLES.has(role) ? SHRIMP_KILL_RANGE : BASE_KILL_RANGE;
46
- }
47
-
48
- /** 机器人主动出刀的距离阈值:服务端击杀距离打 9 折(80→72,160→144),留余量避免目标在边界漂移导致空刀。 */
49
- export function killCommitRange(role: string | undefined): number {
50
- return killRangeFor(role) * KILL_COMMIT_FACTOR;
51
- }
52
-
53
- export function dist(x1: number, y1: number, x2: number, y2: number): number {
54
- return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
55
- }
56
-
57
- export function firstAvailableTask(
58
- tasks: TaskInfo[],
59
- predicate: (task: TaskInfo) => boolean,
60
- emergency?: EmergencyInfo | null,
61
- blockedTarget?: { x: number; y: number } | null,
62
- avoidCorpses?: CorpseInfo[] | null,
63
- ): TaskInfo | null {
64
- const nearCorpse = (x: number, y: number): boolean =>
65
- !!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
66
-
67
- // 紧急任务(维修点)始终自动优先,不做尸体回避:大家都会涌向维修点,在那儿做任务并不可疑,
68
- // 且紧急维修有倒计时、时间敏感。尸体回避只针对下面的常规伪装任务。
69
- if (emergency && emergency.status !== 'completed' && emergency.x != null && emergency.y != null && predicate(emergency)) {
70
- if (!blockedTarget || dist(emergency.x, emergency.y, blockedTarget.x, blockedTarget.y) > 10) {
71
- return emergency;
72
- }
73
- }
74
- return tasks.find(t => {
75
- if (t.status === 'completed' || t.status === 'in_progress') return false;
76
- if (t.x == null || t.y == null) return false;
77
- if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
78
- if (nearCorpse(t.x, t.y)) return false;
79
- return predicate(t);
80
- }) ?? null;
81
- }
82
-
83
- /** 任务点离任一威胁点多近就算「在威胁旁」而被硬排除(欧氏即可——隔薄墙的威胁照样危险)。 */
84
- export const TASK_THREAT_EXCLUDE_RANGE = 500;
85
- /** 测地路径离任一威胁点多近就算「必经之路有危险」而被硬排除(治路上有人把守时的来回逡巡)。 */
86
- export const TASK_PATH_THREAT_RADIUS = 350;
87
-
88
- export interface SafeTaskOptions {
89
- /** 硬排除:这些点 threatExcludeRadius 内的任务直接丢弃。 */
90
- threatPoints?: Position[];
91
- threatExcludeRadius?: number;
92
- /** 路径硬排除半径:去任务点的测地路径经过威胁点这个距离内也丢弃,默认 TASK_PATH_THREAT_RADIUS。 */
93
- pathThreatRadius?: number;
94
- /** 粘性:该任务仍是合法候选就直接返回它,不重新比距离,杜绝等距摇摆。 */
95
- stickyTaskName?: string | null;
96
- }
97
-
98
- export interface SafePatrolOptions {
99
- threatPoints?: Position[];
100
- threatExcludeRadius?: number;
101
- pathThreatRadius?: number;
102
- }
103
-
104
- /**
105
- * firstAvailableTask 的进阶版:同样的紧急任务优先与候选过滤,但常规任务改选
106
- * 测地最近(distance-field 一次扫描),并支持威胁硬排除(任务点旁有威胁,或
107
- * 必经之路经过威胁附近)与当前任务粘性。粘性任务同样要过路径检查——路被人
108
- * 把守时直接换任务,而不是「后撤→粘回原任务→再走近→再后撤」地振荡。
109
- * 测地不可达的候选按欧氏距离垫底(仍可选,避免全图无任务可做)。
110
- */
111
- export function nearestSafeTask(
112
- state: GameState,
113
- tasks: TaskInfo[],
114
- predicate: (task: TaskInfo) => boolean,
115
- emergency?: EmergencyInfo | null,
116
- blockedTarget?: { x: number; y: number } | null,
117
- avoidCorpses?: CorpseInfo[] | null,
118
- opts: SafeTaskOptions = {},
119
- ): TaskInfo | null {
120
- // 紧急任务语义与 firstAvailableTask 一致:时间敏感且人人都涌过去,不做尸体/威胁回避。
121
- if (emergency && emergency.status !== 'completed' && emergency.x != null && emergency.y != null && predicate(emergency)) {
122
- if (!blockedTarget || dist(emergency.x, emergency.y, blockedTarget.x, blockedTarget.y) > 10) {
123
- return emergency;
124
- }
125
- }
126
-
127
- const threatPoints = opts.threatPoints ?? [];
128
- const threatRadius = opts.threatExcludeRadius ?? TASK_THREAT_EXCLUDE_RANGE;
129
- const nearCorpse = (x: number, y: number): boolean =>
130
- !!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
131
-
132
- const candidates = tasks.filter(t => {
133
- if (t.status === 'completed' || t.status === 'in_progress') return false;
134
- if (t.x == null || t.y == null) return false;
135
- if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
136
- if (nearCorpse(t.x, t.y)) return false;
137
- if (threatPoints.some(p => dist(t.x!, t.y!, p.x, p.y) <= threatRadius)) return false;
138
- return predicate(t);
139
- });
140
- if (candidates.length === 0) return null;
141
-
142
- // 无威胁时粘性可以零成本短路(不必为路径检查扫距离场)。
143
- if (opts.stickyTaskName && threatPoints.length === 0) {
144
- const sticky = candidates.find(t => t.task_name === opts.stickyTaskName);
145
- if (sticky) return sticky;
146
- }
147
-
148
- const routes = assessRoutes(
149
- { x: state.you.x, y: state.you.y },
150
- candidates.map(t => ({ x: t.x!, y: t.y! })),
151
- threatPoints,
152
- opts.pathThreatRadius ?? TASK_PATH_THREAT_RADIUS,
153
- );
154
- // routes null(自己不在可走格上,极少见)时退回欧氏排序,无路径排除。
155
- const viable = routes == null
156
- ? candidates.map((t, i) => ({ t, i }))
157
- : candidates.map((t, i) => ({ t, i })).filter(({ i }) => !routes[i].nearThreat);
158
- if (viable.length === 0) return null;
159
-
160
- if (opts.stickyTaskName) {
161
- const sticky = viable.find(({ t }) => t.task_name === opts.stickyTaskName);
162
- if (sticky) return sticky.t;
163
- }
164
-
165
- let best = viable[0].t;
166
- let bestScore = Infinity;
167
- for (const { t, i } of viable) {
168
- const euclid = dist(state.you.x, state.you.y, t.x!, t.y!);
169
- const geoPx = routes?.[i].distancePx;
170
- const score = geoPx == null ? euclid : geoPx !== Infinity ? geoPx : euclid + 1e6;
171
- if (score < bestScore) {
172
- bestScore = score;
173
- best = t;
174
- }
175
- }
176
- return best;
177
- }
178
-
179
- /**
180
- * 巡逻一步:朝当前巡逻房间移动,已到达就推进到下一个房间再走。rooms 为空返回 [](无处可去)。
181
- * stopOnPlayer 控制路上撞见玩家是否立刻停下(猎杀型策略=true,借此撞见落单猎物即停、下一 tick 出刀)。
182
- * 抽自各 *-top 里重复的 patrolDecision。
183
- */
184
- export function patrolStep(
185
- state: GameState,
186
- ctx: StrategyContext,
187
- patrol: PatrolState,
188
- stopOnPlayer = true,
189
- ): BehaviorDecision[] {
190
- let room = patrol.nextRoom(ctx);
191
- if (!room) return [];
192
- if (dist(state.you.x, state.you.y, room.x, room.y) <= PATROL_REACHED_DISTANCE) {
193
- patrol.advance(ctx);
194
- const next = patrol.nextRoom(ctx);
195
- if (next) room = next;
196
- }
197
- return [{ action: Action.move({ x: room.x, y: room.y }, stopOnPlayer) }];
198
- }
199
-
200
- export function safePatrolStep(
201
- state: GameState,
202
- ctx: StrategyContext,
203
- patrol: PatrolState,
204
- stopOnPlayer = true,
205
- opts: SafePatrolOptions = {},
206
- ): BehaviorDecision[] {
207
- const room = nextSafePatrolRoom(state, ctx, patrol, opts);
208
- return room ? [{ action: Action.move({ x: room.x, y: room.y }, stopOnPlayer) }] : [];
209
- }
210
-
211
- function nextSafePatrolRoom(
212
- state: GameState,
213
- ctx: StrategyContext,
214
- patrol: PatrolState,
215
- opts: SafePatrolOptions,
216
- ): RoomTarget | null {
217
- let current = patrol.nextRoom(ctx);
218
- if (!current) return null;
219
- if (dist(state.you.x, state.you.y, current.x, current.y) <= PATROL_REACHED_DISTANCE) {
220
- patrol.advance(ctx);
221
- current = patrol.nextRoom(ctx);
222
- if (!current) return null;
223
- }
224
-
225
- const ordered = orderedPatrolRooms(ctx, current);
226
- if (ordered.length === 0) return null;
227
- const threatPoints = opts.threatPoints ?? [];
228
- const notBlocked = ordered.filter(room =>
229
- !ctx.blockedMoveTarget || dist(room.x, room.y, ctx.blockedMoveTarget.x, ctx.blockedMoveTarget.y) > 10);
230
- const candidates = notBlocked.length > 0 ? notBlocked : ordered;
231
- if (threatPoints.length === 0) {
232
- movePatrolToRoom(ctx, patrol, current, candidates[0]);
233
- return candidates[0];
234
- }
235
-
236
- const threatRadius = opts.threatExcludeRadius ?? TASK_THREAT_EXCLUDE_RANGE;
237
- const endpointSafe = candidates.filter(room =>
238
- !threatPoints.some(p => dist(room.x, room.y, p.x, p.y) <= threatRadius));
239
- if (endpointSafe.length === 0) return null;
240
-
241
- const routes = assessRoutes(
242
- { x: state.you.x, y: state.you.y },
243
- endpointSafe.map(room => ({ x: room.x, y: room.y })),
244
- threatPoints,
245
- opts.pathThreatRadius ?? TASK_PATH_THREAT_RADIUS,
246
- );
247
- const viable = routes == null
248
- ? endpointSafe
249
- : endpointSafe.filter((_room, i) => !routes[i].nearThreat);
250
- const selected = viable[0] ?? null;
251
- if (!selected) return null;
252
- movePatrolToRoom(ctx, patrol, current, selected);
253
- return selected;
254
- }
255
-
256
- function orderedPatrolRooms(ctx: StrategyContext, current: RoomTarget): RoomTarget[] {
257
- if (ctx.rooms.length === 0) return [];
258
- const start = Math.max(0, ctx.rooms.findIndex(room => room.name === current.name));
259
- return ctx.rooms.map((_room, offset) => ctx.rooms[(start + offset) % ctx.rooms.length]);
260
- }
261
-
262
- function movePatrolToRoom(ctx: StrategyContext, patrol: PatrolState, current: RoomTarget, selected: RoomTarget): void {
263
- if (selected.name === current.name || ctx.rooms.length === 0) return;
264
- for (let i = 0; i < ctx.rooms.length; i += 1) {
265
- const room = patrol.nextRoom(ctx);
266
- if (!room || room.name === selected.name) return;
267
- patrol.advance(ctx);
268
- }
269
- }
270
-
271
- /**
272
- * 靠近 → 到点提交单个任务;到点但本地仍被冷却(not_at_task_location 等)返回 null,让上层兜底巡逻、
273
- * 避免死盯一个刚到却提交失败的点。供「会做任务伪装」的坏人策略(crab-sabotage / lone-kill-task)复用。
274
- */
275
- export function approachOrDoTaskStep(
276
- state: GameState,
277
- ctx: StrategyContext,
278
- task: TaskInfo,
279
- stopOnPlayer = false,
280
- ): BehaviorDecision[] | null {
281
- const d = dist(state.you.x, state.you.y, task.x!, task.y!);
282
- if (d <= TASK_SUBMIT_RADIUS) {
283
- if (Date.now() >= ctx.taskLocalBlockedUntil) return [{ action: Action.doTask(task.task_name) }];
284
- return null;
285
- }
286
- return [{ action: Action.move({ x: task.x!, y: task.y! }, stopOnPlayer) }];
287
- }
288
-
289
- /** 单个任务的「靠近 到点提交」决策:在提交半径内且本地未被冷却就 doTask,否则移动过去。 */
290
- export function taskMoveDecision(state: GameState, ctx: StrategyContext, task: TaskInfo): BehaviorDecision {
291
- const d = dist(state.you.x, state.you.y, task.x!, task.y!);
292
- if (d <= TASK_SUBMIT_RADIUS && Date.now() >= ctx.taskLocalBlockedUntil) {
293
- return { action: Action.doTask(task.task_name) };
294
- }
295
- return { action: Action.move({ x: task.x!, y: task.y! }) };
296
- }
297
-
298
- export function nearestReportableCorpse(state: GameState): GameState['corpses'][number] | null {
299
- const nearby = state.corpses
300
- .map(corpse => {
301
- const distance = corpse.distance ?? (
302
- corpse.x != null && corpse.y != null
303
- ? dist(state.you.x, state.you.y, corpse.x, corpse.y)
304
- : Infinity
305
- );
306
- return { corpse, distance };
307
- })
308
- .filter(({ distance }) => distance <= REPORT_RANGE)
309
- .sort((a, b) => a.distance - b.distance);
310
- return nearby[0]?.corpse ?? null;
311
- }
312
-
313
- /** 已知尸体中离自己最近、且有坐标可导航的一具(含已离开视野、之前记下的)。供接近/徘徊用。 */
314
- export function nearestKnownCorpse(state: GameState, ctx: StrategyContext): CorpseTarget | null {
315
- const candidates = (ctx.knownCorpses ?? []).filter(c => c.x != null && c.y != null);
316
- if (candidates.length === 0) return null;
317
- const corpse = candidates.reduce((best, c) =>
318
- dist(state.you.x, state.you.y, c.x!, c.y!) < dist(state.you.x, state.you.y, best.x!, best.y!) ? c : best,
319
- );
320
- return { x: corpse.x!, y: corpse.y!, name: corpse.name, room: corpse.room };
321
- }
322
-
323
- /**
324
- * 可导航的尸体目标:优先返回带坐标的最近尸体;若已知尸体都还没拿到坐标(state.corpses 只给 distance,
325
- * 坐标得等 corpse_spotted 事件补上),退化为「尸体所在房间」的锚点,先把 bot 带进房间——人进视野后坐标
326
- * 自然补全,避免「明知有尸体却因暂时没坐标而巡逻走开」。两者皆无才 null。接近/徘徊类**导航**用,区别于
327
- * 严格要坐标的 nearestKnownCorpse(报尸等必须站在尸体上的场景仍用后者)。
328
- */
329
- export function nearestKnownCorpseNav(state: GameState, ctx: StrategyContext): CorpseTarget | null {
330
- const withCoords = nearestKnownCorpse(state, ctx);
331
- if (withCoords) return withCoords;
332
- for (const c of ctx.knownCorpses ?? []) {
333
- if (!c.room) continue;
334
- const room = ctx.rooms.find(r => r.name === c.room);
335
- if (room) return { x: room.x, y: room.y, name: c.name, room: c.room };
336
- }
337
- return null;
338
- }
339
-
340
- /** 是否已知任何尸体(含看见过、已离开视野的)。开会清场后由 strategy-loop 清空。供「该不该去报尸/回避」类**导航**判断用。 */
341
- export function hasKnownCorpse(ctx: StrategyContext): boolean {
342
- return (ctx.knownCorpses?.length ?? 0) > 0;
343
- }
344
-
345
- /**
346
- * 当前这一帧我就**站在命案现场**(≤CORPSE_SCENE_RANGE,150px)——视野里有尸体或本 tick 刚冒出
347
- * `corpse_spotted`,且尸体确实落在现场半径内。贴脸灭口目击者 / 报尸自证 / 标注尸体旁目击者这类
348
- * **现场反射**必须用它,**不能**用 `hasKnownCorpse`:后者只表示「记得地图某处有尸体」,会把「记得有
349
- * 尸体」误当成「人就在尸体旁」,导致随便撞见一个人就灭口 / 误把后来遇见者当目击者 / 生成错误的栽赃简报。
350
- */
351
- export function corpseAtScene(state: GameState): boolean {
352
- // state.corpses 按「视野半径」(SHRIMP_VISION_RANGE)过滤,比命案现场半径(CORPSE_SCENE_RANGE=150)大得多——视野里有
353
- // 尸体 人就站在尸体旁。不加距离门控会让 300px 外的尸体也触发灭口/目击者标注/栽赃,故同样按现场半径筛。
354
- const corpseInScene = state.corpses.some(c => {
355
- const d = c.distance ?? (
356
- c.x != null && c.y != null ? dist(state.you.x, state.you.y, c.x, c.y) : Infinity
357
- );
358
- return d <= CORPSE_SCENE_RANGE;
359
- });
360
- if (corpseInScene) return true;
361
- // corpse_spotted 事件可能被 backfill 补入、或在本轮移动开始后才到达,单看「增量里有该事件」会把地图别处
362
- // 的尸体误当成脚下的命案现场。尸体不会移动,故只认坐标确实落在现场半径内的事件。
363
- return spottedCorpsesFromEvents(state).some(c =>
364
- dist(state.you.x, state.you.y, c.x, c.y) <= CORPSE_SCENE_RANGE);
365
- }
366
-
367
- /** tick 增量里**全部** corpse_spotted 事件(按到达顺序)。同批多具尸体不能只留最后一具——
368
- * server state.corpses 没坐标,漏掉的尸体无法再补回。 */
369
- export function spottedCorpsesFromEvents(state: GameState): CorpseTarget[] {
370
- return spottedCorpsesFromEventList(state.new_events);
371
- }
372
-
373
- /** corpse_spotted 坐标提取的底层版:可作用于任意事件数组(如 action 结果里的 new_events,绕开主循环游标竞态)。 */
374
- export function spottedCorpsesFromEventList(events: any[] | undefined): CorpseTarget[] {
375
- const out: CorpseTarget[] = [];
376
- for (const evt of events ?? []) {
377
- if (evt?.type !== 'corpse_spotted') continue;
378
- const x = Number(evt.corpse_x);
379
- const y = Number(evt.corpse_y);
380
- if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
381
- out.push({
382
- x,
383
- y,
384
- name: typeof evt.corpse_name === 'string' ? evt.corpse_name : undefined,
385
- room: typeof evt.corpse_room === 'string' ? evt.corpse_room : undefined,
386
- });
387
- }
388
- return out;
389
- }
390
-
391
- /**
392
- * 已知尸体的跨 tick 记忆,全局唯一一份由 strategy-loop 持有,每 tick `observe` 后写入 `ctx.knownCorpses`,
393
- * 供所有策略统一读取(检测 hasKnownCorpse、接近 nearestKnownCorpse、任务回避 firstAvailableTask/nearestSafeTask、
394
- * 死亡确认按名/座位匹配)。`state.corpses` 只含当前视野(nearby)尸体,`corpse_spotted` 事件又只在尸体出现的
395
- * 那一 tick 进增量——两者都没有持久性,尸体一离开视野下一 tick 就会丢。CorpseMemory 把见过的尸体按身份累积
396
- * 去重,使「看见过、已离开视野」的尸体仍持续可见。尸体在游戏里出现后一直存在到开会才被清场,故 strategy-loop
397
- * 在 `onMeetingResume` 时 `reset` 并清空 `ctx.knownCorpses`。报尸本身仍按近距触发(nearestReportableCorpse
398
- * 读 state.corpses),记忆负责把 bot 带回尸体旁。
399
- */
400
- export class CorpseMemory {
401
- private readonly seen: CorpseInfo[] = [];
402
- private readonly lastSeenPlayers = new Map<string, PlayerInfo>();
403
-
404
- /**
405
- * tick 调用:缓存可见玩家位置,并入已确认击杀、当前视野尸体与本 tick 全部 corpse_spotted 事件。
406
- * 服务端可能直到杀手再次移动才生成 corpse_spotted,因此成功击杀先用目标最后目击位置兜底。
407
- */
408
- observe(state: GameState, recentlyKilledTargets?: Map<string, number>): void {
409
- for (const player of state.players) this.rememberPlayer(player);
410
- for (const evt of state.new_events ?? []) {
411
- if (evt?.type !== 'player_spotted') continue;
412
- const name = typeof evt.spotted_name === 'string' ? evt.spotted_name : '';
413
- const x = Number(evt.spotted_x);
414
- const y = Number(evt.spotted_y);
415
- if (!name || !Number.isFinite(x) || !Number.isFinite(y)) continue;
416
- this.rememberPlayer({
417
- name,
418
- room: typeof evt.spotted_room === 'string' ? evt.spotted_room : '',
419
- x,
420
- y,
421
- seat: typeof evt.spotted_seat === 'number' ? evt.spotted_seat : undefined,
422
- });
423
- }
424
-
425
- const now = Date.now();
426
- for (const [target, until] of recentlyKilledTargets ?? []) {
427
- if (until <= now) continue;
428
- const player = this.lastSeenPlayers.get(target.trim().toLowerCase());
429
- if (player) this.remember(player);
430
- }
431
-
432
- // state.corpses 只含当前视野尸体,且可能无坐标(server 仅给 distance);仍记下来,使无坐标尸体
433
- // 至少能被「存在性检测」(hasKnownCorpse) 与「按名/座位死亡确认」命中,坐标随后由 corpse_spotted /
434
- // 击杀回填补全。这样 knownCorpses 成为 state.corpses 的严格超集,跨 tick 持久、开会才清空。
435
- for (const c of state.corpses) this.remember(c);
436
- for (const spotted of spottedCorpsesFromEvents(state)) this.remember(spotted);
437
- }
438
-
439
- /**
440
- * 从任意事件数组补全 corpse_spotted 坐标——供 action 结果里的 new_events 用,绕开主循环 state.new_events
441
- * 的共享游标竞态(runtime WS listener 可能先通过 WS 消费掉该事件,strategy 的 HTTP 轮询就再也看不到,尸体坐标永远进
442
- * 不了记忆,导航类策略只能巡逻走开)。
443
- */
444
- ingestEvents(events: any[] | undefined): void {
445
- for (const spotted of spottedCorpsesFromEventList(events)) this.remember(spotted);
446
- }
447
-
448
- /** 累积的已知尸体(含已离开视野的)。供 firstAvailableTask / nearestSafeTask 的尸体回避参数。 */
449
- list(): CorpseInfo[] {
450
- return this.seen;
451
- }
452
-
453
- /** 会议后尸体清场 → 清空记忆。 */
454
- reset(): void {
455
- this.seen.length = 0;
456
- this.lastSeenPlayers.clear();
457
- }
458
-
459
- private rememberPlayer(player: PlayerInfo): void {
460
- const key = player.name.trim().toLowerCase();
461
- if (key) this.lastSeenPlayers.set(key, player);
462
- }
463
-
464
- private remember(corpse: { x?: number; y?: number; name?: string; room?: string; seat?: number }): void {
465
- // 身份去重,不能用任务回避半径(100px)——那会把相距 90px 的两具不同尸体并成一具,
466
- // 漏掉第二具另一侧的任务回避。优先按尸体名判同,无名时退化为极近坐标(CORPSE_SAME_BODY_RADIUS)
467
- const name = corpse.name ?? '';
468
- const existing = this.seen.find(c => this.isSameBody(c, corpse, name));
469
- if (!existing) {
470
- this.seen.push({ name, room: corpse.room ?? '', x: corpse.x, y: corpse.y, seat: corpse.seat });
471
- return;
472
- }
473
- // 同一具尸体的后续观测:补全先前缺失的坐标 / 座位(首见常是无坐标的 state.corpses 条目)。
474
- if (existing.x == null && corpse.x != null) { existing.x = corpse.x; existing.y = corpse.y; }
475
- if (existing.seat == null && corpse.seat != null) existing.seat = corpse.seat;
476
- }
477
-
478
- private isSameBody(c: CorpseInfo, other: { x?: number; y?: number }, name: string): boolean {
479
- if (name && c.name) return c.name === name; // 两边都有名:同名才是同一具
480
- if (c.x != null && c.y != null && other.x != null && other.y != null) {
481
- return dist(c.x, c.y, other.x, other.y) <= CORPSE_SAME_BODY_RADIUS;
482
- }
483
- return false; // 无名且坐标不全:无法判同,按新尸体处理(极退化,几乎不出现)
484
- }
485
- }
486
-
487
- export function reportCorpseDecision(
488
- state: GameState,
489
- ctx: StrategyContext,
490
- opts: { respectBlock?: boolean } = {},
491
- ): BehaviorDecision | null {
492
- const reportBlocked = opts.respectBlock && Date.now() < ctx.reportBlockedUntil;
493
-
494
- const reportable = nearestReportableCorpse(state);
495
- if (reportable && !reportBlocked) {
496
- ctx.reportCorpseTarget = null;
497
- ctx.notifications.push(`发现尸体,正在报告!`);
498
- return { action: Action.report(reportable.name, reportable.room) };
499
- }
500
-
501
- const corpse = nearestKnownCorpse(state, ctx);
502
- if (corpse) {
503
- if (!ctx.reportCorpseTarget) {
504
- ctx.notifications.push(`发现尸体,正在靠近准备报告。`);
505
- }
506
- ctx.reportCorpseTarget = corpse;
507
- return { action: Action.move({ x: corpse.x, y: corpse.y }) };
508
- }
509
-
510
- ctx.reportCorpseTarget = null;
511
- return null;
512
- }
513
-
514
- export function nonTeammatesVisible(state: GameState, ctx: StrategyContext): PlayerInfo[] {
515
- const players = visibleLivePlayers(state, ctx);
516
- return ctx.teammates.size > 0
517
- ? players.filter(p => !ctx.teammates.has(p.name))
518
- : players;
519
- }
520
-
521
- export function matchesTarget(
522
- target: string,
523
- player: { name?: string; seat?: number },
524
- ctx: StrategyContext,
525
- ): boolean {
526
- const normalized = target.trim().toLowerCase();
527
- if (player.name && player.name.toLowerCase() === normalized) return true;
528
- if (/^\d+$/.test(target.trim())) {
529
- const nameFromSeat = ctx.playerNamesBySeat[target.trim()];
530
- if (nameFromSeat && player.name && player.name.toLowerCase() === nameFromSeat.toLowerCase()) return true;
531
- if (player.seat != null && String(player.seat) === target.trim()) return true;
532
- }
533
- return false;
534
- }
535
-
536
- export function matchesAnyTarget(
537
- player: { name?: string; seat?: number },
538
- targets: string[],
539
- ctx: StrategyContext,
540
- ): boolean {
541
- return targets.some(target => matchesTarget(target, player, ctx));
542
- }
543
-
544
- export function isTargetAlive(
545
- state: GameState,
546
- target: string,
547
- ctx: StrategyContext,
548
- ): boolean {
549
- if (isRecentlyKilledTarget(target, ctx)) return false;
550
- const player = state.all_players?.find(p => matchesTarget(target, p, ctx));
551
- return player ? player.is_alive !== false : true;
552
- }
553
-
554
- export function isRecentlyKilledTarget(target: string, ctx: StrategyContext): boolean {
555
- const names = targetNames(target, ctx);
556
- for (const name of names) {
557
- const until = ctx.recentlyKilledTargets?.get(name);
558
- if (!until) continue;
559
- if (until > Date.now()) return true;
560
- ctx.recentlyKilledTargets?.delete(name);
561
- }
562
- return false;
563
- }
564
-
565
- function targetNames(target: string, ctx: StrategyContext): string[] {
566
- const normalized = target.trim().toLowerCase();
567
- if (!normalized) return [];
568
- const names = [normalized];
569
- if (/^\d+$/.test(target.trim())) {
570
- const nameFromSeat = ctx.playerNamesBySeat[target.trim()];
571
- if (nameFromSeat) names.push(nameFromSeat.toLowerCase());
572
- }
573
- return names;
574
- }
575
-
576
- export function visibleLivePlayers(state: GameState, ctx: StrategyContext): PlayerInfo[] {
577
- return state.players.filter(p => isTargetAlive(state, p.name, ctx));
578
- }
579
-
580
- export function nearestPlayerByDistance(state: GameState, players: PlayerInfo[]): PlayerInfo | null {
581
- return players
582
- .map(p => ({ p, d: p.distance ?? dist(state.you.x, state.you.y, p.x, p.y) }))
583
- .sort((a, b) => a.d - b.d)[0]?.p ?? null;
584
- }
585
-
586
- export function nearestVisibleTarget(
587
- state: GameState,
588
- ctx: StrategyContext,
589
- targets: string[],
590
- ): PlayerInfo | null {
591
- return nearestPlayerByDistance(
592
- state,
593
- visibleLivePlayers(state, ctx).filter(p => targets.some(t => matchesTarget(t, p, ctx))),
594
- );
595
- }
596
-
597
- export function pursueVisibleTarget(
598
- state: GameState,
599
- target: PlayerInfo,
600
- opts: { kill: boolean; followDistance: number },
601
- ): BehaviorDecision | null {
602
- const d = target.distance ?? dist(state.you.x, state.you.y, target.x, target.y);
603
- if (opts.kill && !hasKillUseRemaining(state)) return null;
604
- if (opts.kill && d <= killCommitRange(state.you.role) && canUseKill(state)) {
605
- return { action: Action.kill(target.name) };
606
- }
607
- if (d > opts.followDistance) {
608
- return { action: Action.move({ x: target.x, y: target.y }) };
609
- }
610
- return null;
611
- }
612
-
613
- // ── 知识系统语义助手 ────────────────────────────────────────────────
614
- // ctx.knowledge 的查询统一成「躲/杀/护」三种意图,供内置与用户策略复用。
615
- // docs/策略知识系统-by-claude.md。
616
-
617
- type PlayerRef = { name?: string; seat?: number };
618
- const FALLBACK_KNOWLEDGE = emptyKnowledgeView();
619
-
620
- function knowledgeOf(ctx: StrategyContext) {
621
- return ctx.knowledge ?? FALLBACK_KNOWLEDGE;
622
- }
623
-
624
- /** hostile:带刀策略可主动攻击;无刀策略应回避。 */
625
- export function isKnowledgeHostile(p: PlayerRef, ctx: StrategyContext, minConfidence?: number): boolean {
626
- return knowledgeOf(ctx).isHostile(p, minConfidence);
627
- }
628
-
629
- /** suspect 或 hostile:任何生存型策略都应视为威胁并回避。 */
630
- export function isKnowledgeThreat(p: PlayerRef, ctx: StrategyContext, minConfidence?: number): boolean {
631
- const mark = knowledgeOf(ctx).markOf(p, minConfidence);
632
- return mark === 'suspect' || mark === 'hostile';
633
- }
634
-
635
- /** trusted:可信同伴,永远优先于回避与击杀。 */
636
- export function isKnowledgeTrusted(p: PlayerRef, ctx: StrategyContext, minConfidence?: number): boolean {
637
- return knowledgeOf(ctx).isTrusted(p, minConfidence);
638
- }
639
-
640
- /** @deprecated Use isKnowledgeHostile. */
641
- export const isKnowledgeKillTarget = isKnowledgeHostile;
642
- /** @deprecated Use isKnowledgeThreat. */
643
- export const isKnowledgeAvoid = isKnowledgeThreat;
644
- /** @deprecated Use isKnowledgeTrusted. */
645
- export const isKnowledgeProtected = isKnowledgeTrusted;
646
-
647
- export function resolveRoom(name: string, ctx: StrategyContext): RoomTarget | null {
648
- const folded = name.trim().toLowerCase();
649
- if (!folded) return null;
650
- return ctx.rooms.find(room => room.name.toLowerCase() === folded) ?? null;
651
- }
652
-
653
- export function isInRoom(name: string, state: GameState): boolean {
654
- const room = state.you.room;
655
- if (!room) return false;
656
- return room.toLowerCase() === name.trim().toLowerCase();
657
- }
658
-
659
- export function hasReachedRoomTarget(state: GameState, target: RoomTarget): boolean {
660
- return dist(state.you.x, state.you.y, target.x, target.y) <= PATROL_REACHED_DISTANCE;
661
- }
662
-
663
- export function roomAwayFromPlayers(
664
- state: GameState,
665
- ctx: StrategyContext,
666
- players: PlayerInfo[],
667
- ): RoomTarget | null {
668
- if (ctx.rooms.length === 0 || players.length === 0) return null;
669
- const playerRooms = new Set(players.map(player => player.room?.toLowerCase()).filter(Boolean));
670
-
671
- const nearestPlayer = players
672
- .map(player => ({
673
- player,
674
- distance: player.distance ?? dist(state.you.x, state.you.y, player.x, player.y),
675
- }))
676
- .sort((a, b) => a.distance - b.distance)[0]?.player;
677
- if (!nearestPlayer) return null;
678
-
679
- const awayX = state.you.x - nearestPlayer.x;
680
- const awayY = state.you.y - nearestPlayer.y;
681
- const awayLen = Math.hypot(awayX, awayY);
682
-
683
- const scored = ctx.rooms.map(room => {
684
- const toRoomX = room.x - state.you.x;
685
- const toRoomY = room.y - state.you.y;
686
- const toRoomLen = Math.hypot(toRoomX, toRoomY);
687
- const direction = awayLen > 0 && toRoomLen > 0
688
- ? (toRoomX * awayX + toRoomY * awayY) / (toRoomLen * awayLen)
689
- : 0;
690
- const nearestDistance = Math.min(...players.map(player => dist(room.x, room.y, player.x, player.y)));
691
- return {
692
- room,
693
- direction,
694
- nearestDistance,
695
- selfDistance: dist(state.you.x, state.you.y, room.x, room.y),
696
- };
697
- });
698
-
699
- const viable = scored.filter(item => {
700
- if (playerRooms.has(item.room.name.toLowerCase())) return false;
701
- if (ctx.blockedMoveTarget && dist(item.room.x, item.room.y, ctx.blockedMoveTarget.x, ctx.blockedMoveTarget.y) <= 10) return false;
702
- return true;
703
- });
704
- const base = viable.length > 0 ? viable : scored;
705
- const oppositeRooms = base.filter(item => item.direction > 0.15);
706
- const candidates = oppositeRooms.length > 0 ? oppositeRooms : base;
707
- candidates.sort((a, b) => {
708
- const distanceDiff = b.nearestDistance - a.nearestDistance;
709
- if (Math.abs(distanceDiff) > 1) return distanceDiff;
710
- const directionDiff = b.direction - a.direction;
711
- if (Math.abs(directionDiff) > 0.01) return directionDiff;
712
- return a.selfDistance - b.selfDistance;
713
- });
714
- return candidates[0]?.room ?? null;
715
- }
716
-
717
- export class PatrolState {
718
- private roomIndex = 0;
719
-
720
- nextRoom(ctx: StrategyContext): RoomTarget | null {
721
- if (ctx.rooms.length === 0) return null;
722
- if (ctx.forcePatrolAdvance) {
723
- ctx.forcePatrolAdvance = false;
724
- this.roomIndex = (this.roomIndex + 1) % ctx.rooms.length;
725
- }
726
- return ctx.rooms[this.roomIndex % ctx.rooms.length];
727
- }
728
-
729
- advance(ctx: StrategyContext): void {
730
- if (ctx.rooms.length === 0) return;
731
- this.roomIndex = (this.roomIndex + 1) % ctx.rooms.length;
732
- }
733
-
734
- reset(): void {
735
- this.roomIndex = 0;
736
- }
737
- }
1
+ import type { GameState, PlayerInfo, Position, TaskInfo, EmergencyInfo, CorpseInfo } from '../sdk/types.js';
2
+ import { Action } from '../sdk/action.js';
3
+ import type { BehaviorDecision, CorpseTarget, RoomTarget, StrategyContext } from './types.js';
4
+ import { emptyKnowledgeView } from '../lib/knowledge-store.js';
5
+ import { assessRoutes } from './pathfind/escape-planner.js';
6
+
7
+ export const TASK_SUBMIT_RADIUS = 12;
8
+ /** 蟹/章鱼/兜底角色的服务端击杀距离(= 后端全局 kill_range)。 */
9
+ export const BASE_KILL_RANGE = 80;
10
+ /** 武士虾/枪虾的服务端击杀距离(带刀好人,射程是蟹/章鱼的两倍)。 */
11
+ export const SHRIMP_KILL_RANGE = 160;
12
+ export const REPORT_RANGE = 150;
13
+ export const SHRIMP_VISION_RANGE = 270;
14
+ export const SHRIMP_VISION_EXIT_BUFFER = 30;
15
+ export const SHRIMP_VISION_RELEASE_RANGE = SHRIMP_VISION_RANGE + SHRIMP_VISION_EXIT_BUFFER;
16
+ export const FOLLOW_RANGE = 300;
17
+ export const PATROL_REACHED_DISTANCE = 150;
18
+ export const PROGRESS_INTERVAL_MS = 30_000;
19
+ export const LONE_IGNORE_MS = 10_000;
20
+ export const LONE_FOLLOW_DISTANCE = 10;
21
+ /** 任务点离任一可见尸体多近就算「在尸体旁」,会做任务伪装的坏人据此跳过该任务、避免站在命案现场做任务。 */
22
+ export const CORPSE_TASK_AVOID_RANGE = 100;
23
+ /** 判「同一具尸体」的极近半径:尸体不会移动,跨 tick 只有坐标取整抖动,远小于两具不同尸体的间距。 */
24
+ export const CORPSE_SAME_BODY_RADIUS = 20;
25
+ /** corpse_spotted 事件坐标离自己多近才算「人就在命案现场」——超出视为别处尸体的迟到/补发事件,不触发现场反射。 */
26
+ export const CORPSE_SCENE_RANGE = REPORT_RANGE;
27
+
28
+ export function killCooldownSecs(state: GameState): number {
29
+ return state.you.kill_cooldown_secs ?? 0;
30
+ }
31
+
32
+ export function hasKillUseRemaining(state: GameState): boolean {
33
+ return state.you.kills_remaining == null || state.you.kills_remaining > 0;
34
+ }
35
+
36
+ export function canUseKill(state: GameState): boolean {
37
+ return hasKillUseRemaining(state) && killCooldownSecs(state) <= 0;
38
+ }
39
+
40
+ const KILL_COMMIT_FACTOR = 0.9;
41
+ const LONG_RANGE_KILL_ROLES = new Set(['shrimp_warrior', 'shrimp_pistol']);
42
+
43
+ /** 角色的服务端实际击杀距离,镜像后端 effective_kill_range。武士虾/枪虾 160,其余 80。 */
44
+ export function killRangeFor(role: string | undefined): number {
45
+ return role && LONG_RANGE_KILL_ROLES.has(role) ? SHRIMP_KILL_RANGE : BASE_KILL_RANGE;
46
+ }
47
+
48
+ /** 机器人主动出刀的距离阈值:服务端击杀距离打 9 折(80→72,160→144),留余量避免目标在边界漂移导致空刀。 */
49
+ export function killCommitRange(role: string | undefined): number {
50
+ return killRangeFor(role) * KILL_COMMIT_FACTOR;
51
+ }
52
+
53
+ export function dist(x1: number, y1: number, x2: number, y2: number): number {
54
+ return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
55
+ }
56
+
57
+ export function firstAvailableTask(
58
+ tasks: TaskInfo[],
59
+ predicate: (task: TaskInfo) => boolean,
60
+ emergency?: EmergencyInfo | null,
61
+ blockedTarget?: { x: number; y: number } | null,
62
+ avoidCorpses?: CorpseInfo[] | null,
63
+ ): TaskInfo | null {
64
+ const nearCorpse = (x: number, y: number): boolean =>
65
+ !!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
66
+
67
+ // 紧急任务(维修点)始终自动优先,不做尸体回避:大家都会涌向维修点,在那儿做任务并不可疑,
68
+ // 且紧急维修有倒计时、时间敏感。尸体回避只针对下面的常规伪装任务。
69
+ if (emergency && emergency.status !== 'completed' && emergency.x != null && emergency.y != null && predicate(emergency)) {
70
+ if (!blockedTarget || dist(emergency.x, emergency.y, blockedTarget.x, blockedTarget.y) > 10) {
71
+ return emergency;
72
+ }
73
+ }
74
+ return tasks.find(t => {
75
+ if (t.status === 'completed' || t.status === 'in_progress') return false;
76
+ if (t.x == null || t.y == null) return false;
77
+ if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
78
+ if (nearCorpse(t.x, t.y)) return false;
79
+ return predicate(t);
80
+ }) ?? null;
81
+ }
82
+
83
+ // 路径半径可以 > 视野(270):pathNearAny 已豁免「起点 radiusPx 邻域」,逃跑那刻起点紧贴
84
+ // 威胁不再把整条背向路线判危,所以恢复成真正的「绕开把守者」避让距离。端点半径
85
+ // TASK_THREAT_EXCLUDE_RANGE 只看任务点离威胁多近、与起点采样无关,按玩法风险单独取值。
86
+ // 约束靠 game-utils.test.ts 的路线场景测试钉死(不加数值断言,因为旧的「< 视野」断言现在反而是错的)。
87
+ /** 任务点离任一威胁点多近就算「在威胁旁」而被硬排除(欧氏即可——隔薄墙的威胁照样危险)。 */
88
+ export const TASK_THREAT_EXCLUDE_RANGE = 500;
89
+ /** 测地路径离任一威胁点多近就算「必经之路有危险」而被硬排除(治路上有人把守时的来回逡巡)。 */
90
+ export const TASK_PATH_THREAT_RADIUS = 350;
91
+
92
+ export interface SafeTaskOptions {
93
+ /** 硬排除:这些点 threatExcludeRadius 内的任务直接丢弃。 */
94
+ threatPoints?: Position[];
95
+ threatExcludeRadius?: number;
96
+ /** 路径硬排除半径:去任务点的测地路径经过威胁点这个距离内也丢弃,默认 TASK_PATH_THREAT_RADIUS。 */
97
+ pathThreatRadius?: number;
98
+ /** 粘性:该任务仍是合法候选就直接返回它,不重新比距离,杜绝等距摇摆。 */
99
+ stickyTaskName?: string | null;
100
+ }
101
+
102
+ export interface SafePatrolOptions {
103
+ threatPoints?: Position[];
104
+ threatExcludeRadius?: number;
105
+ pathThreatRadius?: number;
106
+ }
107
+
108
+ /**
109
+ * firstAvailableTask 的进阶版:同样的紧急任务优先与候选过滤,但常规任务改选
110
+ * 测地最近(distance-field 一次扫描),并支持威胁硬排除(任务点旁有威胁,或
111
+ * 必经之路经过威胁附近)与当前任务粘性。粘性任务同样要过路径检查——路被人
112
+ * 把守时直接换任务,而不是「后撤→粘回原任务→再走近→再后撤」地振荡。
113
+ * 测地不可达的候选按欧氏距离垫底(仍可选,避免全图无任务可做)。
114
+ */
115
+ export function nearestSafeTask(
116
+ state: GameState,
117
+ tasks: TaskInfo[],
118
+ predicate: (task: TaskInfo) => boolean,
119
+ emergency?: EmergencyInfo | null,
120
+ blockedTarget?: { x: number; y: number } | null,
121
+ avoidCorpses?: CorpseInfo[] | null,
122
+ opts: SafeTaskOptions = {},
123
+ ): TaskInfo | null {
124
+ // 紧急任务语义与 firstAvailableTask 一致:时间敏感且人人都涌过去,不做尸体/威胁回避。
125
+ if (emergency && emergency.status !== 'completed' && emergency.x != null && emergency.y != null && predicate(emergency)) {
126
+ if (!blockedTarget || dist(emergency.x, emergency.y, blockedTarget.x, blockedTarget.y) > 10) {
127
+ return emergency;
128
+ }
129
+ }
130
+
131
+ const threatPoints = opts.threatPoints ?? [];
132
+ const threatRadius = opts.threatExcludeRadius ?? TASK_THREAT_EXCLUDE_RANGE;
133
+ const nearCorpse = (x: number, y: number): boolean =>
134
+ !!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
135
+
136
+ const candidates = tasks.filter(t => {
137
+ if (t.status === 'completed' || t.status === 'in_progress') return false;
138
+ if (t.x == null || t.y == null) return false;
139
+ if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
140
+ if (nearCorpse(t.x, t.y)) return false;
141
+ if (threatPoints.some(p => dist(t.x!, t.y!, p.x, p.y) <= threatRadius)) return false;
142
+ return predicate(t);
143
+ });
144
+ if (candidates.length === 0) return null;
145
+
146
+ // 无威胁时粘性可以零成本短路(不必为路径检查扫距离场)。
147
+ if (opts.stickyTaskName && threatPoints.length === 0) {
148
+ const sticky = candidates.find(t => t.task_name === opts.stickyTaskName);
149
+ if (sticky) return sticky;
150
+ }
151
+
152
+ const routes = assessRoutes(
153
+ { x: state.you.x, y: state.you.y },
154
+ candidates.map(t => ({ x: t.x!, y: t.y! })),
155
+ threatPoints,
156
+ opts.pathThreatRadius ?? TASK_PATH_THREAT_RADIUS,
157
+ );
158
+ // routes null(自己不在可走格上,极少见)时退回欧氏排序,无路径排除。
159
+ const viable = routes == null
160
+ ? candidates.map((t, i) => ({ t, i }))
161
+ : candidates.map((t, i) => ({ t, i })).filter(({ i }) => !routes[i].nearThreat);
162
+ if (viable.length === 0) return null;
163
+
164
+ if (opts.stickyTaskName) {
165
+ const sticky = viable.find(({ t }) => t.task_name === opts.stickyTaskName);
166
+ if (sticky) return sticky.t;
167
+ }
168
+
169
+ let best = viable[0].t;
170
+ let bestScore = Infinity;
171
+ for (const { t, i } of viable) {
172
+ const euclid = dist(state.you.x, state.you.y, t.x!, t.y!);
173
+ const geoPx = routes?.[i].distancePx;
174
+ const score = geoPx == null ? euclid : geoPx !== Infinity ? geoPx : euclid + 1e6;
175
+ if (score < bestScore) {
176
+ bestScore = score;
177
+ best = t;
178
+ }
179
+ }
180
+ return best;
181
+ }
182
+
183
+ /**
184
+ * 巡逻一步:朝当前巡逻房间移动,已到达就推进到下一个房间再走。rooms 为空返回 [](无处可去)。
185
+ * stopOnPlayer 控制路上撞见玩家是否立刻停下(猎杀型策略=true,借此撞见落单猎物即停、下一 tick 出刀)。
186
+ * 抽自各 *-top 里重复的 patrolDecision。
187
+ */
188
+ export function patrolStep(
189
+ state: GameState,
190
+ ctx: StrategyContext,
191
+ patrol: PatrolState,
192
+ stopOnPlayer = true,
193
+ ): BehaviorDecision[] {
194
+ let room = patrol.nextRoom(ctx);
195
+ if (!room) return [];
196
+ if (dist(state.you.x, state.you.y, room.x, room.y) <= PATROL_REACHED_DISTANCE) {
197
+ patrol.advance(ctx);
198
+ const next = patrol.nextRoom(ctx);
199
+ if (next) room = next;
200
+ }
201
+ return [{ action: Action.move({ x: room.x, y: room.y }, stopOnPlayer) }];
202
+ }
203
+
204
+ export function safePatrolStep(
205
+ state: GameState,
206
+ ctx: StrategyContext,
207
+ patrol: PatrolState,
208
+ stopOnPlayer = true,
209
+ opts: SafePatrolOptions = {},
210
+ ): BehaviorDecision[] {
211
+ const room = nextSafePatrolRoom(state, ctx, patrol, opts);
212
+ return room ? [{ action: Action.move({ x: room.x, y: room.y }, stopOnPlayer) }] : [];
213
+ }
214
+
215
+ function nextSafePatrolRoom(
216
+ state: GameState,
217
+ ctx: StrategyContext,
218
+ patrol: PatrolState,
219
+ opts: SafePatrolOptions,
220
+ ): RoomTarget | null {
221
+ let current = patrol.nextRoom(ctx);
222
+ if (!current) return null;
223
+ if (dist(state.you.x, state.you.y, current.x, current.y) <= PATROL_REACHED_DISTANCE) {
224
+ patrol.advance(ctx);
225
+ current = patrol.nextRoom(ctx);
226
+ if (!current) return null;
227
+ }
228
+
229
+ const ordered = orderedPatrolRooms(ctx, current);
230
+ if (ordered.length === 0) return null;
231
+ const threatPoints = opts.threatPoints ?? [];
232
+ const notBlocked = ordered.filter(room =>
233
+ !ctx.blockedMoveTarget || dist(room.x, room.y, ctx.blockedMoveTarget.x, ctx.blockedMoveTarget.y) > 10);
234
+ const candidates = notBlocked.length > 0 ? notBlocked : ordered;
235
+ if (threatPoints.length === 0) {
236
+ movePatrolToRoom(ctx, patrol, current, candidates[0]);
237
+ return candidates[0];
238
+ }
239
+
240
+ const threatRadius = opts.threatExcludeRadius ?? TASK_THREAT_EXCLUDE_RANGE;
241
+ const endpointSafe = candidates.filter(room =>
242
+ !threatPoints.some(p => dist(room.x, room.y, p.x, p.y) <= threatRadius));
243
+ if (endpointSafe.length === 0) return null;
244
+
245
+ const routes = assessRoutes(
246
+ { x: state.you.x, y: state.you.y },
247
+ endpointSafe.map(room => ({ x: room.x, y: room.y })),
248
+ threatPoints,
249
+ opts.pathThreatRadius ?? TASK_PATH_THREAT_RADIUS,
250
+ );
251
+ const viable = routes == null
252
+ ? endpointSafe
253
+ : endpointSafe.filter((_room, i) => !routes[i].nearThreat);
254
+ // 所有路线被路径闸判危时退回端点已安全的 endpointSafe[0](端点远离威胁、仅路线稍擦威胁边,
255
+ // 对「游荡制造嫌疑」已足够),而非 null 触发上层静默僵住;绝不退回不带过滤的 patrolStep
256
+ //(会径直走向正在躲的陌生人)。
257
+ const selected = viable[0] ?? endpointSafe[0] ?? null;
258
+ if (!selected) return null;
259
+ movePatrolToRoom(ctx, patrol, current, selected);
260
+ return selected;
261
+ }
262
+
263
+ function orderedPatrolRooms(ctx: StrategyContext, current: RoomTarget): RoomTarget[] {
264
+ if (ctx.rooms.length === 0) return [];
265
+ const start = Math.max(0, ctx.rooms.findIndex(room => room.name === current.name));
266
+ return ctx.rooms.map((_room, offset) => ctx.rooms[(start + offset) % ctx.rooms.length]);
267
+ }
268
+
269
+ function movePatrolToRoom(ctx: StrategyContext, patrol: PatrolState, current: RoomTarget, selected: RoomTarget): void {
270
+ if (selected.name === current.name || ctx.rooms.length === 0) return;
271
+ for (let i = 0; i < ctx.rooms.length; i += 1) {
272
+ const room = patrol.nextRoom(ctx);
273
+ if (!room || room.name === selected.name) return;
274
+ patrol.advance(ctx);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * 靠近 → 到点提交单个任务;到点但本地仍被冷却(not_at_task_location 等)返回 null,让上层兜底巡逻、
280
+ * 避免死盯一个刚到却提交失败的点。供「会做任务伪装」的坏人策略(crab-sabotage / lone-kill-task)复用。
281
+ */
282
+ export function approachOrDoTaskStep(
283
+ state: GameState,
284
+ ctx: StrategyContext,
285
+ task: TaskInfo,
286
+ stopOnPlayer = false,
287
+ ): BehaviorDecision[] | null {
288
+ const d = dist(state.you.x, state.you.y, task.x!, task.y!);
289
+ if (d <= TASK_SUBMIT_RADIUS) {
290
+ if (Date.now() >= ctx.taskLocalBlockedUntil) return [{ action: Action.doTask(task.task_name) }];
291
+ return null;
292
+ }
293
+ return [{ action: Action.move({ x: task.x!, y: task.y! }, stopOnPlayer) }];
294
+ }
295
+
296
+ /** 单个任务的「靠近 → 到点提交」决策:在提交半径内且本地未被冷却就 doTask,否则移动过去。 */
297
+ export function taskMoveDecision(state: GameState, ctx: StrategyContext, task: TaskInfo): BehaviorDecision {
298
+ const d = dist(state.you.x, state.you.y, task.x!, task.y!);
299
+ if (d <= TASK_SUBMIT_RADIUS && Date.now() >= ctx.taskLocalBlockedUntil) {
300
+ return { action: Action.doTask(task.task_name) };
301
+ }
302
+ return { action: Action.move({ x: task.x!, y: task.y! }) };
303
+ }
304
+
305
+ export function nearestReportableCorpse(state: GameState): GameState['corpses'][number] | null {
306
+ const nearby = state.corpses
307
+ .map(corpse => {
308
+ const distance = corpse.distance ?? (
309
+ corpse.x != null && corpse.y != null
310
+ ? dist(state.you.x, state.you.y, corpse.x, corpse.y)
311
+ : Infinity
312
+ );
313
+ return { corpse, distance };
314
+ })
315
+ .filter(({ distance }) => distance <= REPORT_RANGE)
316
+ .sort((a, b) => a.distance - b.distance);
317
+ return nearby[0]?.corpse ?? null;
318
+ }
319
+
320
+ /** 已知尸体中离自己最近、且有坐标可导航的一具(含已离开视野、之前记下的)。供接近/徘徊用。 */
321
+ export function nearestKnownCorpse(state: GameState, ctx: StrategyContext): CorpseTarget | null {
322
+ const candidates = (ctx.knownCorpses ?? []).filter(c => c.x != null && c.y != null);
323
+ if (candidates.length === 0) return null;
324
+ const corpse = candidates.reduce((best, c) =>
325
+ dist(state.you.x, state.you.y, c.x!, c.y!) < dist(state.you.x, state.you.y, best.x!, best.y!) ? c : best,
326
+ );
327
+ return { x: corpse.x!, y: corpse.y!, name: corpse.name, room: corpse.room };
328
+ }
329
+
330
+ /**
331
+ * 可导航的尸体目标:优先返回带坐标的最近尸体;若已知尸体都还没拿到坐标(state.corpses 只给 distance,
332
+ * 坐标得等 corpse_spotted 事件补上),退化为「尸体所在房间」的锚点,先把 bot 带进房间——人进视野后坐标
333
+ * 自然补全,避免「明知有尸体却因暂时没坐标而巡逻走开」。两者皆无才 null。接近/徘徊类**导航**用,区别于
334
+ * 严格要坐标的 nearestKnownCorpse(报尸等必须站在尸体上的场景仍用后者)。
335
+ */
336
+ export function nearestKnownCorpseNav(state: GameState, ctx: StrategyContext): CorpseTarget | null {
337
+ const withCoords = nearestKnownCorpse(state, ctx);
338
+ if (withCoords) return withCoords;
339
+ for (const c of ctx.knownCorpses ?? []) {
340
+ if (!c.room) continue;
341
+ const room = ctx.rooms.find(r => r.name === c.room);
342
+ if (room) return { x: room.x, y: room.y, name: c.name, room: c.room };
343
+ }
344
+ return null;
345
+ }
346
+
347
+ /** 是否已知任何尸体(含看见过、已离开视野的)。开会清场后由 strategy-loop 清空。供「该不该去报尸/回避」类**导航**判断用。 */
348
+ export function hasKnownCorpse(ctx: StrategyContext): boolean {
349
+ return (ctx.knownCorpses?.length ?? 0) > 0;
350
+ }
351
+
352
+ /**
353
+ * 当前这一帧我就**站在命案现场**(≤CORPSE_SCENE_RANGE,150px)——视野里有尸体或本 tick 刚冒出
354
+ * `corpse_spotted`,且尸体确实落在现场半径内。贴脸灭口目击者 / 报尸自证 / 标注尸体旁目击者这类
355
+ * **现场反射**必须用它,**不能**用 `hasKnownCorpse`:后者只表示「记得地图某处有尸体」,会把「记得有
356
+ * 尸体」误当成「人就在尸体旁」,导致随便撞见一个人就灭口 / 误把后来遇见者当目击者 / 生成错误的栽赃简报。
357
+ */
358
+ export function corpseAtScene(state: GameState): boolean {
359
+ // state.corpses 按「视野半径」(SHRIMP_VISION_RANGE)过滤,比命案现场半径(CORPSE_SCENE_RANGE=150)大得多——视野里有
360
+ // 尸体 人就站在尸体旁。不加距离门控会让 300px 外的尸体也触发灭口/目击者标注/栽赃,故同样按现场半径筛。
361
+ const corpseInScene = state.corpses.some(c => {
362
+ const d = c.distance ?? (
363
+ c.x != null && c.y != null ? dist(state.you.x, state.you.y, c.x, c.y) : Infinity
364
+ );
365
+ return d <= CORPSE_SCENE_RANGE;
366
+ });
367
+ if (corpseInScene) return true;
368
+ // corpse_spotted 事件可能被 backfill 补入、或在本轮移动开始后才到达,单看「增量里有该事件」会把地图别处
369
+ // 的尸体误当成脚下的命案现场。尸体不会移动,故只认坐标确实落在现场半径内的事件。
370
+ return spottedCorpsesFromEvents(state).some(c =>
371
+ dist(state.you.x, state.you.y, c.x, c.y) <= CORPSE_SCENE_RANGE);
372
+ }
373
+
374
+ /** tick 增量里**全部** corpse_spotted 事件(按到达顺序)。同批多具尸体不能只留最后一具——
375
+ * server state.corpses 没坐标,漏掉的尸体无法再补回。 */
376
+ export function spottedCorpsesFromEvents(state: GameState): CorpseTarget[] {
377
+ return spottedCorpsesFromEventList(state.new_events);
378
+ }
379
+
380
+ /** corpse_spotted 坐标提取的底层版:可作用于任意事件数组(如 action 结果里的 new_events,绕开主循环游标竞态)。 */
381
+ export function spottedCorpsesFromEventList(events: any[] | undefined): CorpseTarget[] {
382
+ const out: CorpseTarget[] = [];
383
+ for (const evt of events ?? []) {
384
+ if (evt?.type !== 'corpse_spotted') continue;
385
+ const x = Number(evt.corpse_x);
386
+ const y = Number(evt.corpse_y);
387
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
388
+ out.push({
389
+ x,
390
+ y,
391
+ name: typeof evt.corpse_name === 'string' ? evt.corpse_name : undefined,
392
+ room: typeof evt.corpse_room === 'string' ? evt.corpse_room : undefined,
393
+ });
394
+ }
395
+ return out;
396
+ }
397
+
398
+ /**
399
+ * 已知尸体的跨 tick 记忆,全局唯一一份由 strategy-loop 持有,每 tick `observe` 后写入 `ctx.knownCorpses`,
400
+ * 供所有策略统一读取(检测 hasKnownCorpse、接近 nearestKnownCorpse、任务回避 firstAvailableTask/nearestSafeTask、
401
+ * 死亡确认按名/座位匹配)。`state.corpses` 只含当前视野(nearby)尸体,`corpse_spotted` 事件又只在尸体出现的
402
+ * 那一 tick 进增量——两者都没有持久性,尸体一离开视野下一 tick 就会丢。CorpseMemory 把见过的尸体按身份累积
403
+ * 去重,使「看见过、已离开视野」的尸体仍持续可见。尸体在游戏里出现后一直存在到开会才被清场,故 strategy-loop
404
+ * 在 `onMeetingResume` 时 `reset` 并清空 `ctx.knownCorpses`。报尸本身仍按近距触发(nearestReportableCorpse
405
+ * state.corpses),记忆负责把 bot 带回尸体旁。
406
+ */
407
+ export class CorpseMemory {
408
+ private readonly seen: CorpseInfo[] = [];
409
+ private readonly lastSeenPlayers = new Map<string, PlayerInfo>();
410
+
411
+ /**
412
+ * tick 调用:缓存可见玩家位置,并入已确认击杀、当前视野尸体与本 tick 全部 corpse_spotted 事件。
413
+ * 服务端可能直到杀手再次移动才生成 corpse_spotted,因此成功击杀先用目标最后目击位置兜底。
414
+ */
415
+ observe(state: GameState, recentlyKilledTargets?: Map<string, number>): void {
416
+ for (const player of state.players) this.rememberPlayer(player);
417
+ for (const evt of state.new_events ?? []) {
418
+ if (evt?.type !== 'player_spotted') continue;
419
+ const name = typeof evt.spotted_name === 'string' ? evt.spotted_name : '';
420
+ const x = Number(evt.spotted_x);
421
+ const y = Number(evt.spotted_y);
422
+ if (!name || !Number.isFinite(x) || !Number.isFinite(y)) continue;
423
+ this.rememberPlayer({
424
+ name,
425
+ room: typeof evt.spotted_room === 'string' ? evt.spotted_room : '',
426
+ x,
427
+ y,
428
+ seat: typeof evt.spotted_seat === 'number' ? evt.spotted_seat : undefined,
429
+ });
430
+ }
431
+
432
+ const now = Date.now();
433
+ for (const [target, until] of recentlyKilledTargets ?? []) {
434
+ if (until <= now) continue;
435
+ const player = this.lastSeenPlayers.get(target.trim().toLowerCase());
436
+ if (player) this.remember(player);
437
+ }
438
+
439
+ // state.corpses 只含当前视野尸体,且可能无坐标(server 仅给 distance);仍记下来,使无坐标尸体
440
+ // 至少能被「存在性检测」(hasKnownCorpse) 与「按名/座位死亡确认」命中,坐标随后由 corpse_spotted /
441
+ // 击杀回填补全。这样 knownCorpses 成为 state.corpses 的严格超集,跨 tick 持久、开会才清空。
442
+ for (const c of state.corpses) this.remember(c);
443
+ for (const spotted of spottedCorpsesFromEvents(state)) this.remember(spotted);
444
+ }
445
+
446
+ /**
447
+ * 从任意事件数组补全 corpse_spotted 坐标——供 action 结果里的 new_events 用,绕开主循环 state.new_events
448
+ * 的共享游标竞态(runtime WS listener 可能先通过 WS 消费掉该事件,strategy 的 HTTP 轮询就再也看不到,尸体坐标永远进
449
+ * 不了记忆,导航类策略只能巡逻走开)。
450
+ */
451
+ ingestEvents(events: any[] | undefined): void {
452
+ for (const spotted of spottedCorpsesFromEventList(events)) this.remember(spotted);
453
+ }
454
+
455
+ /** 累积的已知尸体(含已离开视野的)。供 firstAvailableTask / nearestSafeTask 的尸体回避参数。 */
456
+ list(): CorpseInfo[] {
457
+ return this.seen;
458
+ }
459
+
460
+ /** 会议后尸体清场 清空记忆。 */
461
+ reset(): void {
462
+ this.seen.length = 0;
463
+ this.lastSeenPlayers.clear();
464
+ }
465
+
466
+ private rememberPlayer(player: PlayerInfo): void {
467
+ const key = player.name.trim().toLowerCase();
468
+ if (key) this.lastSeenPlayers.set(key, player);
469
+ }
470
+
471
+ private remember(corpse: { x?: number; y?: number; name?: string; room?: string; seat?: number }): void {
472
+ // 身份去重,不能用任务回避半径(100px)——那会把相距 90px 的两具不同尸体并成一具,
473
+ // 漏掉第二具另一侧的任务回避。优先按尸体名判同,无名时退化为极近坐标(CORPSE_SAME_BODY_RADIUS)。
474
+ const name = corpse.name ?? '';
475
+ const existing = this.seen.find(c => this.isSameBody(c, corpse, name));
476
+ if (!existing) {
477
+ this.seen.push({ name, room: corpse.room ?? '', x: corpse.x, y: corpse.y, seat: corpse.seat });
478
+ return;
479
+ }
480
+ // 同一具尸体的后续观测:补全先前缺失的坐标 / 座位(首见常是无坐标的 state.corpses 条目)。
481
+ if (existing.x == null && corpse.x != null) { existing.x = corpse.x; existing.y = corpse.y; }
482
+ if (existing.seat == null && corpse.seat != null) existing.seat = corpse.seat;
483
+ }
484
+
485
+ private isSameBody(c: CorpseInfo, other: { x?: number; y?: number }, name: string): boolean {
486
+ if (name && c.name) return c.name === name; // 两边都有名:同名才是同一具
487
+ if (c.x != null && c.y != null && other.x != null && other.y != null) {
488
+ return dist(c.x, c.y, other.x, other.y) <= CORPSE_SAME_BODY_RADIUS;
489
+ }
490
+ return false; // 无名且坐标不全:无法判同,按新尸体处理(极退化,几乎不出现)
491
+ }
492
+ }
493
+
494
+ export function reportCorpseDecision(
495
+ state: GameState,
496
+ ctx: StrategyContext,
497
+ opts: { respectBlock?: boolean } = {},
498
+ ): BehaviorDecision | null {
499
+ const reportBlocked = opts.respectBlock && Date.now() < ctx.reportBlockedUntil;
500
+
501
+ const reportable = nearestReportableCorpse(state);
502
+ if (reportable && !reportBlocked) {
503
+ ctx.reportCorpseTarget = null;
504
+ ctx.notifications.push(`发现尸体,正在报告!`);
505
+ return { action: Action.report(reportable.name, reportable.room) };
506
+ }
507
+
508
+ const corpse = nearestKnownCorpse(state, ctx);
509
+ if (corpse) {
510
+ if (!ctx.reportCorpseTarget) {
511
+ ctx.notifications.push(`发现尸体,正在靠近准备报告。`);
512
+ }
513
+ ctx.reportCorpseTarget = corpse;
514
+ return { action: Action.move({ x: corpse.x, y: corpse.y }) };
515
+ }
516
+
517
+ ctx.reportCorpseTarget = null;
518
+ return null;
519
+ }
520
+
521
+ export function nonTeammatesVisible(state: GameState, ctx: StrategyContext): PlayerInfo[] {
522
+ const players = visibleLivePlayers(state, ctx);
523
+ return ctx.teammates.size > 0
524
+ ? players.filter(p => !ctx.teammates.has(p.name))
525
+ : players;
526
+ }
527
+
528
+ export function matchesTarget(
529
+ target: string,
530
+ player: { name?: string; seat?: number },
531
+ ctx: StrategyContext,
532
+ ): boolean {
533
+ const normalized = target.trim().toLowerCase();
534
+ if (player.name && player.name.toLowerCase() === normalized) return true;
535
+ if (/^\d+$/.test(target.trim())) {
536
+ const nameFromSeat = ctx.playerNamesBySeat[target.trim()];
537
+ if (nameFromSeat && player.name && player.name.toLowerCase() === nameFromSeat.toLowerCase()) return true;
538
+ if (player.seat != null && String(player.seat) === target.trim()) return true;
539
+ }
540
+ return false;
541
+ }
542
+
543
+ export function matchesAnyTarget(
544
+ player: { name?: string; seat?: number },
545
+ targets: string[],
546
+ ctx: StrategyContext,
547
+ ): boolean {
548
+ return targets.some(target => matchesTarget(target, player, ctx));
549
+ }
550
+
551
+ export function isTargetAlive(
552
+ state: GameState,
553
+ target: string,
554
+ ctx: StrategyContext,
555
+ ): boolean {
556
+ if (isRecentlyKilledTarget(target, ctx)) return false;
557
+ const player = state.all_players?.find(p => matchesTarget(target, p, ctx));
558
+ return player ? player.is_alive !== false : true;
559
+ }
560
+
561
+ export function isRecentlyKilledTarget(target: string, ctx: StrategyContext): boolean {
562
+ const names = targetNames(target, ctx);
563
+ for (const name of names) {
564
+ const until = ctx.recentlyKilledTargets?.get(name);
565
+ if (!until) continue;
566
+ if (until > Date.now()) return true;
567
+ ctx.recentlyKilledTargets?.delete(name);
568
+ }
569
+ return false;
570
+ }
571
+
572
+ function targetNames(target: string, ctx: StrategyContext): string[] {
573
+ const normalized = target.trim().toLowerCase();
574
+ if (!normalized) return [];
575
+ const names = [normalized];
576
+ if (/^\d+$/.test(target.trim())) {
577
+ const nameFromSeat = ctx.playerNamesBySeat[target.trim()];
578
+ if (nameFromSeat) names.push(nameFromSeat.toLowerCase());
579
+ }
580
+ return names;
581
+ }
582
+
583
+ export function visibleLivePlayers(state: GameState, ctx: StrategyContext): PlayerInfo[] {
584
+ return state.players.filter(p => isTargetAlive(state, p.name, ctx));
585
+ }
586
+
587
+ export function nearestPlayerByDistance(state: GameState, players: PlayerInfo[]): PlayerInfo | null {
588
+ return players
589
+ .map(p => ({ p, d: p.distance ?? dist(state.you.x, state.you.y, p.x, p.y) }))
590
+ .sort((a, b) => a.d - b.d)[0]?.p ?? null;
591
+ }
592
+
593
+ export function nearestVisibleTarget(
594
+ state: GameState,
595
+ ctx: StrategyContext,
596
+ targets: string[],
597
+ ): PlayerInfo | null {
598
+ return nearestPlayerByDistance(
599
+ state,
600
+ visibleLivePlayers(state, ctx).filter(p => targets.some(t => matchesTarget(t, p, ctx))),
601
+ );
602
+ }
603
+
604
+ export function pursueVisibleTarget(
605
+ state: GameState,
606
+ target: PlayerInfo,
607
+ opts: { kill: boolean; followDistance: number },
608
+ ): BehaviorDecision | null {
609
+ const d = target.distance ?? dist(state.you.x, state.you.y, target.x, target.y);
610
+ if (opts.kill && !hasKillUseRemaining(state)) return null;
611
+ if (opts.kill && d <= killCommitRange(state.you.role) && canUseKill(state)) {
612
+ return { action: Action.kill(target.name) };
613
+ }
614
+ if (d > opts.followDistance) {
615
+ return { action: Action.move({ x: target.x, y: target.y }) };
616
+ }
617
+ return null;
618
+ }
619
+
620
+ // ── 知识系统语义助手 ────────────────────────────────────────────────
621
+ // ctx.knowledge 的查询统一成「躲/杀/护」三种意图,供内置与用户策略复用。
622
+ // 见 docs/策略知识系统-by-claude.md。
623
+
624
+ type PlayerRef = { name?: string; seat?: number };
625
+ const FALLBACK_KNOWLEDGE = emptyKnowledgeView();
626
+
627
+ function knowledgeOf(ctx: StrategyContext) {
628
+ return ctx.knowledge ?? FALLBACK_KNOWLEDGE;
629
+ }
630
+
631
+ /** hostile:带刀策略可主动攻击;无刀策略应回避。 */
632
+ export function isKnowledgeHostile(p: PlayerRef, ctx: StrategyContext, minConfidence?: number): boolean {
633
+ return knowledgeOf(ctx).isHostile(p, minConfidence);
634
+ }
635
+
636
+ /** suspect hostile:任何生存型策略都应视为威胁并回避。 */
637
+ export function isKnowledgeThreat(p: PlayerRef, ctx: StrategyContext, minConfidence?: number): boolean {
638
+ const mark = knowledgeOf(ctx).markOf(p, minConfidence);
639
+ return mark === 'suspect' || mark === 'hostile';
640
+ }
641
+
642
+ /** trusted:可信同伴,永远优先于回避与击杀。 */
643
+ export function isKnowledgeTrusted(p: PlayerRef, ctx: StrategyContext, minConfidence?: number): boolean {
644
+ return knowledgeOf(ctx).isTrusted(p, minConfidence);
645
+ }
646
+
647
+ /** @deprecated Use isKnowledgeHostile. */
648
+ export const isKnowledgeKillTarget = isKnowledgeHostile;
649
+ /** @deprecated Use isKnowledgeThreat. */
650
+ export const isKnowledgeAvoid = isKnowledgeThreat;
651
+ /** @deprecated Use isKnowledgeTrusted. */
652
+ export const isKnowledgeProtected = isKnowledgeTrusted;
653
+
654
+ export function resolveRoom(name: string, ctx: StrategyContext): RoomTarget | null {
655
+ const folded = name.trim().toLowerCase();
656
+ if (!folded) return null;
657
+ return ctx.rooms.find(room => room.name.toLowerCase() === folded) ?? null;
658
+ }
659
+
660
+ export function isInRoom(name: string, state: GameState): boolean {
661
+ const room = state.you.room;
662
+ if (!room) return false;
663
+ return room.toLowerCase() === name.trim().toLowerCase();
664
+ }
665
+
666
+ export function hasReachedRoomTarget(state: GameState, target: RoomTarget): boolean {
667
+ return dist(state.you.x, state.you.y, target.x, target.y) <= PATROL_REACHED_DISTANCE;
668
+ }
669
+
670
+ export function roomAwayFromPlayers(
671
+ state: GameState,
672
+ ctx: StrategyContext,
673
+ players: PlayerInfo[],
674
+ ): RoomTarget | null {
675
+ if (ctx.rooms.length === 0 || players.length === 0) return null;
676
+ const playerRooms = new Set(players.map(player => player.room?.toLowerCase()).filter(Boolean));
677
+
678
+ const nearestPlayer = players
679
+ .map(player => ({
680
+ player,
681
+ distance: player.distance ?? dist(state.you.x, state.you.y, player.x, player.y),
682
+ }))
683
+ .sort((a, b) => a.distance - b.distance)[0]?.player;
684
+ if (!nearestPlayer) return null;
685
+
686
+ const awayX = state.you.x - nearestPlayer.x;
687
+ const awayY = state.you.y - nearestPlayer.y;
688
+ const awayLen = Math.hypot(awayX, awayY);
689
+
690
+ const scored = ctx.rooms.map(room => {
691
+ const toRoomX = room.x - state.you.x;
692
+ const toRoomY = room.y - state.you.y;
693
+ const toRoomLen = Math.hypot(toRoomX, toRoomY);
694
+ const direction = awayLen > 0 && toRoomLen > 0
695
+ ? (toRoomX * awayX + toRoomY * awayY) / (toRoomLen * awayLen)
696
+ : 0;
697
+ const nearestDistance = Math.min(...players.map(player => dist(room.x, room.y, player.x, player.y)));
698
+ return {
699
+ room,
700
+ direction,
701
+ nearestDistance,
702
+ selfDistance: dist(state.you.x, state.you.y, room.x, room.y),
703
+ };
704
+ });
705
+
706
+ const viable = scored.filter(item => {
707
+ if (playerRooms.has(item.room.name.toLowerCase())) return false;
708
+ if (ctx.blockedMoveTarget && dist(item.room.x, item.room.y, ctx.blockedMoveTarget.x, ctx.blockedMoveTarget.y) <= 10) return false;
709
+ return true;
710
+ });
711
+ const base = viable.length > 0 ? viable : scored;
712
+ const oppositeRooms = base.filter(item => item.direction > 0.15);
713
+ const candidates = oppositeRooms.length > 0 ? oppositeRooms : base;
714
+ candidates.sort((a, b) => {
715
+ const distanceDiff = b.nearestDistance - a.nearestDistance;
716
+ if (Math.abs(distanceDiff) > 1) return distanceDiff;
717
+ const directionDiff = b.direction - a.direction;
718
+ if (Math.abs(directionDiff) > 0.01) return directionDiff;
719
+ return a.selfDistance - b.selfDistance;
720
+ });
721
+ return candidates[0]?.room ?? null;
722
+ }
723
+
724
+ export class PatrolState {
725
+ private roomIndex = 0;
726
+
727
+ nextRoom(ctx: StrategyContext): RoomTarget | null {
728
+ if (ctx.rooms.length === 0) return null;
729
+ if (ctx.forcePatrolAdvance) {
730
+ ctx.forcePatrolAdvance = false;
731
+ this.roomIndex = (this.roomIndex + 1) % ctx.rooms.length;
732
+ }
733
+ return ctx.rooms[this.roomIndex % ctx.rooms.length];
734
+ }
735
+
736
+ advance(ctx: StrategyContext): void {
737
+ if (ctx.rooms.length === 0) return;
738
+ this.roomIndex = (this.roomIndex + 1) % ctx.rooms.length;
739
+ }
740
+
741
+ reset(): void {
742
+ this.roomIndex = 0;
743
+ }
744
+ }