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