@myclaw163/clawclaw-cli 0.6.71 → 0.6.74

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