@myclaw163/clawclaw-cli 0.6.61 → 0.6.64

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 (33) hide show
  1. package/README.md +73 -86
  2. package/package.json +1 -1
  3. package/scripts/find-hide-spots.py +157 -0
  4. package/skills/clawclaw/SKILL.md +12 -12
  5. package/skills/clawclaw/references/CHATTERBOX.md +1 -1
  6. package/skills/clawclaw/references/COMMANDS.md +19 -0
  7. package/skills/clawclaw/references/GAME-MECHANICS.md +1 -1
  8. package/skills/clawclaw/references/STRATEGIES.md +2 -2
  9. package/skills/clawclaw/references/STREAM.md +60 -27
  10. package/src/commands/events.test.ts +71 -0
  11. package/src/commands/events.ts +140 -7
  12. package/src/commands/watch.test.ts +44 -47
  13. package/src/commands/watch.ts +53 -114
  14. package/src/pipeline/event-format.test.ts +135 -0
  15. package/src/pipeline/event-format.ts +376 -0
  16. package/src/pipeline/event-hints.ts +173 -0
  17. package/src/pipeline/event-store.test.ts +28 -0
  18. package/src/pipeline/event-store.ts +76 -7
  19. package/src/sdk/index.ts +2 -1
  20. package/src/sdk/types.ts +13 -0
  21. package/src/strategies/goals/anchor-linger.ts +77 -0
  22. package/src/strategies/goals/follow-companion-goal.ts +106 -0
  23. package/src/strategies/goals/hide-top.ts +197 -0
  24. package/src/strategies/goals/keep-away-goal.ts +15 -7
  25. package/src/strategies/goals/linger-corpse-goal.ts +9 -53
  26. package/src/strategies/goals/paradise-fish-top.ts +8 -20
  27. package/src/strategies/goals/warrior-shrimp-top.ts +253 -4
  28. package/src/strategies/hide-spots.ts +123 -0
  29. package/src/strategies/hide.ts +23 -0
  30. package/src/strategies/strategy-loop.ts +4 -0
  31. package/src/strategies/types.ts +7 -1
  32. package/src/strategies/warrior-memory.knowledge.md +3 -1
  33. package/src/strategies/warrior-memory.ts +1 -1
package/src/sdk/index.ts CHANGED
@@ -39,7 +39,7 @@ export { Action } from './action.js';
39
39
  export { GameClient } from '../lib/game-client.js';
40
40
  export type { GameClientConfig } from '../lib/game-client.js';
41
41
  export type {
42
- GameState, PlayerInfo, PlayerSelf, CorpseInfo, TaskInfo, EmergencyInfo,
42
+ GameState, PlayerInfo, PlayerSelf, CorpseInfo, TaskInfo, TaskLocation, EmergencyInfo,
43
43
  Position, GameEvent, MapRoom, GameResult,
44
44
  WsConnectionState, ActionErrorCode,
45
45
  } from './types.js';
@@ -104,6 +104,7 @@ export { FindPlayerTop } from '../strategies/goals/find-player-top.js';
104
104
  export { SocialTaskTop } from '../strategies/goals/social-task-top.js';
105
105
  export { AvoidLoneTop } from '../strategies/goals/avoid-lone-top.js';
106
106
  export { AvoidPlayersTop } from '../strategies/goals/avoid-players-top.js';
107
+ export { HideTop } from '../strategies/goals/hide-top.js';
107
108
  export { MoveRoomGoal } from '../strategies/goals/move-room-goal.js';
108
109
  export { ConversationGoal } from '../strategies/goals/conversation-goal.js';
109
110
 
package/src/sdk/types.ts CHANGED
@@ -105,6 +105,19 @@ export interface TaskInfo {
105
105
  faction?: 'lobster' | 'crab';
106
106
  }
107
107
 
108
+ /**
109
+ * 全局任务站点(map 的 all_task_locations):所有玩家共享、开局固定的任务点位置。与 `your_tasks` /
110
+ * `StrategyContext.taskData`(仅自己被分配、带 status)不同——这里只有位置,没有 status。
111
+ * faction='lobster' 是正常任务点,'crab' 是破坏点。
112
+ */
113
+ export interface TaskLocation {
114
+ name: string;
115
+ room?: string;
116
+ x: number;
117
+ y: number;
118
+ faction?: 'lobster' | 'crab';
119
+ }
120
+
108
121
  export interface EmergencyInfo extends TaskInfo {
109
122
  remaining_secs: number;
110
123
  }
@@ -0,0 +1,77 @@
1
+ import type { GameState, PlayerInfo, Position } from '../../sdk/types.js';
2
+ import { dist } from '../game-utils.js';
3
+
4
+ export interface AnchorLingerOptions {
5
+ /** 游走环最小半径(px)。 */
6
+ min?: number;
7
+ /** 游走环最大半径(px)。 */
8
+ max?: number;
9
+ /** 走到离游走点这么近就算到达、换下一个点。 */
10
+ reached?: number;
11
+ /** 单个游走点最多停留这么久就强制换点,防止点落在墙里不可达时卡死。 */
12
+ dwellMs?: number;
13
+ }
14
+
15
+ /**
16
+ * 围绕一个锚点 min-max px 的「粘性游走」:游走点一旦选定就粘住,直到走到 / 超时 / 换了锚点,
17
+ * 不每 tick 重随机——否则主循环对「同一移动目标」的去重永不触发,会每轮打断上一次移动、在原地抖
18
+ * 而走不出一条游走腿。有 avoid 目标时偏向锚点相对最近目标的对侧。贴尸游走(LingerCorpseGoal)
19
+ * 与跟随同伴在其任务点假装做任务(FollowCompanionGoal)复用同一套游走机制。
20
+ */
21
+ export class AnchorLinger {
22
+ private target: Position | null = null;
23
+ private until = 0;
24
+ private key = '';
25
+ private readonly min: number;
26
+ private readonly max: number;
27
+ private readonly reached: number;
28
+ private readonly dwellMs: number;
29
+
30
+ constructor(options: AnchorLingerOptions = {}) {
31
+ this.min = options.min ?? 40;
32
+ this.max = options.max ?? 100;
33
+ this.reached = options.reached ?? 24;
34
+ this.dwellMs = options.dwellMs ?? 4000;
35
+ }
36
+
37
+ /** 丢弃当前游走点:离开锚点(尸体出视野、同伴起身走动)时调用,下次重新挑点。 */
38
+ reset(): void {
39
+ this.target = null;
40
+ }
41
+
42
+ /** 围绕 anchor 取下一个游走点;key 变了(换了锚点)会立即重挑。 */
43
+ step(state: GameState, key: string, anchor: Position, avoid: PlayerInfo[] = []): Position {
44
+ const now = Date.now();
45
+ const reached = this.target != null
46
+ && dist(state.you.x, state.you.y, this.target.x, this.target.y) <= this.reached;
47
+ if (this.target == null || this.key !== key || reached || now >= this.until) {
48
+ this.key = key;
49
+ this.target = this.pickOffset(state, anchor, avoid);
50
+ this.until = now + this.dwellMs;
51
+ }
52
+ return this.target;
53
+ }
54
+
55
+ private pickOffset(state: GameState, anchor: Position, avoid: PlayerInfo[]): Position {
56
+ return avoid.length > 0 ? this.farSideOffset(state, anchor, avoid) : this.randomOffset(anchor);
57
+ }
58
+
59
+ private farSideOffset(state: GameState, anchor: Position, avoid: PlayerInfo[]): Position {
60
+ const nearest = avoid.reduce((best, p) => {
61
+ const d = p.distance ?? dist(state.you.x, state.you.y, p.x, p.y);
62
+ const bestD = best.distance ?? dist(state.you.x, state.you.y, best.x, best.y);
63
+ return d < bestD ? p : best;
64
+ });
65
+ const vx = anchor.x - nearest.x;
66
+ const vy = anchor.y - nearest.y;
67
+ const len = Math.hypot(vx, vy);
68
+ if (len === 0) return this.randomOffset(anchor);
69
+ return { x: anchor.x + (vx / len) * this.max, y: anchor.y + (vy / len) * this.max };
70
+ }
71
+
72
+ private randomOffset(anchor: Position): Position {
73
+ const angle = Math.random() * 2 * Math.PI;
74
+ const radius = this.min + Math.random() * (this.max - this.min);
75
+ return { x: anchor.x + Math.cos(angle) * radius, y: anchor.y + Math.sin(angle) * radius };
76
+ }
77
+ }
@@ -0,0 +1,106 @@
1
+ import type { GameState, PlayerInfo, Position } from '../../sdk/types.js';
2
+ import { Action } from '../../sdk/action.js';
3
+ import { dist, nearestPlayerByDistance } from '../game-utils.js';
4
+ import type { BehaviorDecision, StrategyContext } from '../types.js';
5
+ import { AnchorLinger } from './anchor-linger.js';
6
+ import { Goal } from './goal.js';
7
+
8
+ /** 同伴移动时跟到这么近(px)就停手,不再补发移动,免得贴身打转;停住后改在锚点附近 40-100 游走。 */
9
+ const FOLLOW_STOP = 80;
10
+ /** 同伴本帧位置离上次记下的停留点小于这么多(px)就算「没动」。走路一步上百 px,远大于此。 */
11
+ const STILL_EPS = 30;
12
+ /** 同伴「没动」持续这么久(ms)就当作在做任务,切到在其停留点假装做任务(≈ 一个轮询间隔的余量)。 */
13
+ const PARKED_MS = 1500;
14
+ /** 距上次 tick 超过这么久(ms,被开会/高优先级行为打断)就重置跟踪,避免拿陈旧停留点误判同伴已停住。 */
15
+ const STALE_MS = 5000;
16
+ /** 同伴停留点离某个真实任务站点这么近(px)就以站点为晃荡中心,否则就地在停留点附近晃。 */
17
+ const TASK_ANCHOR_RANGE = 100;
18
+
19
+ type CompanionResolver = (state: GameState, ctx: StrategyContext) => PlayerInfo[];
20
+
21
+ /**
22
+ * 跟随可信同伴:同伴走动时跟在其身后,同伴停下(推断在做任务)时就在其停留点附近 40-100 px 游走
23
+ * 「假装做任务」,融入人群躲投票——天堂鱼无刀、不做任务,靠贴着可信同伴显得正常。无法直接观测他人
24
+ * 是否在做任务(PlayerInfo 不含 doing_task),故以「同伴位置连续 PARKED_MS 没动」作为代理判据。
25
+ * 任务点游走复用 AnchorLinger(与贴尸 LingerCorpseGoal 同一套粘性游走)。优先级由调用方(天堂鱼
26
+ * tick 的 if 链顺序)控制:排在贴尸游走之后,故跟随天然低于在尸体上晃荡。
27
+ */
28
+ export class FollowCompanionGoal extends Goal {
29
+ private readonly wander = new AnchorLinger();
30
+ private mateKey = '';
31
+ private mateAnchor: Position | null = null;
32
+ private stillSince = 0;
33
+ private lastTickAt = 0;
34
+
35
+ constructor(
36
+ private readonly resolveCompanions: CompanionResolver,
37
+ private readonly resolveAvoid?: CompanionResolver,
38
+ ) {
39
+ super();
40
+ }
41
+
42
+ tick(state: GameState, ctx: StrategyContext): BehaviorDecision[] {
43
+ const now = Date.now();
44
+ const stale = now - this.lastTickAt > STALE_MS;
45
+ this.lastTickAt = now;
46
+
47
+ const mate = nearestPlayerByDistance(state, this.resolveCompanions(state, ctx));
48
+ if (!mate) {
49
+ this.resetTracking();
50
+ return [];
51
+ }
52
+
53
+ const key = this.mateKeyOf(mate);
54
+ const moved = this.mateAnchor == null
55
+ || dist(mate.x, mate.y, this.mateAnchor.x, this.mateAnchor.y) > STILL_EPS;
56
+ if (stale || this.mateKey !== key || moved) {
57
+ this.mateKey = key;
58
+ this.mateAnchor = { x: mate.x, y: mate.y };
59
+ this.stillSince = now;
60
+ }
61
+
62
+ if (now - this.stillSince >= PARKED_MS) {
63
+ // 同伴停住够久(多半在做任务):优先以其附近真实任务站点为晃荡中心——更像在做任务、坐标也比同伴
64
+ // 上报位置稳;附近没有任务站点(如只是站着闲聊)就退回在停留点附近晃。旁边有要避开的人就偏到对侧。
65
+ const avoid = this.resolveAvoid?.(state, ctx) ?? [];
66
+ const spot = this.taskAnchorNear(ctx, this.mateAnchor!)
67
+ ?? { x: this.mateAnchor!.x, y: this.mateAnchor!.y, key: `mate:${key}` };
68
+ return [{ action: Action.move(this.wander.step(state, spot.key, spot, avoid)) }];
69
+ }
70
+
71
+ // 同伴在移动:跟上去,跟到 FOLLOW_STOP 内就不再补发移动。
72
+ this.wander.reset();
73
+ const d = mate.distance ?? dist(state.you.x, state.you.y, mate.x, mate.y);
74
+ return d > FOLLOW_STOP ? [{ action: Action.move({ x: mate.x, y: mate.y }) }] : [];
75
+ }
76
+
77
+ isFinish(state: GameState, ctx: StrategyContext): boolean {
78
+ return this.resolveCompanions(state, ctx).length === 0;
79
+ }
80
+
81
+ private mateKeyOf(mate: PlayerInfo): string {
82
+ return mate.name || (mate.seat != null ? String(mate.seat) : `${Math.round(mate.x)},${Math.round(mate.y)}`);
83
+ }
84
+
85
+ /** 同伴停留点附近最近的真实任务站点(蟹的破坏点除外),带稳定 key 供 AnchorLinger 粘住游走。 */
86
+ private taskAnchorNear(ctx: StrategyContext, near: Position): { x: number; y: number; key: string } | null {
87
+ let best: { x: number; y: number; key: string } | null = null;
88
+ let bestD = TASK_ANCHOR_RANGE;
89
+ for (const t of ctx.taskLocations ?? []) {
90
+ if (t.faction === 'crab') continue;
91
+ const d = dist(near.x, near.y, t.x, t.y);
92
+ if (d <= bestD) {
93
+ best = { x: t.x, y: t.y, key: `task:${t.name}` };
94
+ bestD = d;
95
+ }
96
+ }
97
+ return best;
98
+ }
99
+
100
+ private resetTracking(): void {
101
+ this.wander.reset();
102
+ this.mateKey = '';
103
+ this.mateAnchor = null;
104
+ this.stillSince = 0;
105
+ }
106
+ }
@@ -0,0 +1,197 @@
1
+ import type { GameState, PlayerInfo, Position } from '../../sdk/types.js';
2
+ import { Action } from '../../sdk/action.js';
3
+ import {
4
+ canUseKill,
5
+ dist,
6
+ killCommitRange,
7
+ nonTeammatesVisible,
8
+ PROGRESS_INTERVAL_MS,
9
+ SHRIMP_KILL_RANGE,
10
+ } from '../game-utils.js';
11
+ import { nearestSafeHideSpot, type HideSpot } from '../hide-spots.js';
12
+ import { planEscape, type EscapeOptions } from '../pathfind/escape-planner.js';
13
+ import type { BehaviorDecision, StrategyContext } from '../types.js';
14
+ import { Goal } from './goal.js';
15
+ import { KeepAwayGoal } from './keep-away-goal.js';
16
+
17
+ const SUB_PRIORITY = 0.5;
18
+ const FLEE_KEY = 'hide-flee';
19
+ /** 走到躲藏点这个距离内就算到位,原地潜伏不动。 */
20
+ const REACHED_DISTANCE = 40;
21
+ /** 威胁离开视野后,其位置仍在选点时被回避这么久——逃完不会立刻又选回威胁刚待过的点。 */
22
+ const THREAT_MEMORY_MS = 5_000;
23
+ /**
24
+ * 「无处可逃」用的逃跑推演参数:与 KeepAwayGoal 同款保守模型(killRange=160,按枪虾/武士虾兜底)。
25
+ * 理由是「连我自己用来逃跑的同一套推演都判定必被追上,逃就没意义,才出刀自保」,判据自洽。
26
+ */
27
+ const ESCAPE_OPTS: EscapeOptions = { killRange: SHRIMP_KILL_RANGE, steps: 7, beamWidth: 8, directions: 16, fieldRadius: 900 };
28
+
29
+ type ProgressTier = 'flee' | 'relocate' | 'hidden';
30
+ type ThreatPointResolver = () => Position[];
31
+
32
+ /**
33
+ * 躲藏点劳作循环:与 SafeTaskOrPatrolGoal 同构——planSpot 边逃边预重算(粘性、威胁硬排除),
34
+ * tick 执行(走向躲藏点;到位则原地不动)。没有可用点时不动。
35
+ */
36
+ class HideSpotGoal extends Goal {
37
+ private current: HideSpot | null = null;
38
+
39
+ constructor(private readonly threatPoints: ThreatPointResolver) {
40
+ super();
41
+ }
42
+
43
+ get currentSpot(): HideSpot | null {
44
+ return this.current;
45
+ }
46
+
47
+ reset(): void {
48
+ this.current = null;
49
+ }
50
+
51
+ /** 重算并记住安全躲藏点,不发移动指令(逃跑期间可安全调用)。 */
52
+ planSpot(state: GameState, ctx: StrategyContext): HideSpot | null {
53
+ const spot = nearestSafeHideSpot(
54
+ { x: state.you.x, y: state.you.y },
55
+ this.threatPoints(),
56
+ { stickyTo: this.current, blockedTarget: ctx.blockedMoveTarget },
57
+ );
58
+ this.current = spot;
59
+ return spot;
60
+ }
61
+
62
+ tick(state: GameState, ctx: StrategyContext): BehaviorDecision[] {
63
+ const spot = this.planSpot(state, ctx);
64
+ if (!spot) return [];
65
+ if (dist(state.you.x, state.you.y, spot.x, spot.y) <= REACHED_DISTANCE) return []; // 到位,原地潜伏
66
+ return [{ action: Action.move({ x: spot.x, y: spot.y }).withThinking(`前往躲藏点${spot.room}潜伏`) }];
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 躲藏策略顶层:在离线算好的藏身角落里选测地最近且不挨威胁的一个潜伏不动,直到有人进入视野。
72
+ * 有人来就 ① 用 KeepAwayGoal 保持距离甩开,② 边逃边重算排除该威胁的新躲藏点——与 shrimp-memory 的
73
+ * 「躲人 + 重选任务」同构,只是把任务换成躲藏点。蟹不躲队友(威胁统一用 nonTeammatesVisible 过滤队友)。
74
+ *
75
+ * killWhenCornered:被**单个**对手逼进出刀距离、且 planEscape 判定无路可逃时才出刀自保。
76
+ * null = 按角色默认:蟹 / 章鱼开,武士虾 / 枪虾(及无刀角色)关。
77
+ */
78
+ export class HideTop extends Goal {
79
+ private readonly spotGoal = new HideSpotGoal(() => this.threatPointsFor());
80
+ private readonly recentThreats = new Map<string, { x: number; y: number; until: number }>();
81
+
82
+ constructor(private readonly killWhenCornered: boolean | null = null) {
83
+ super();
84
+ }
85
+
86
+ onMeetingResume(): void {
87
+ this.spotGoal.reset();
88
+ this.recentThreats.clear();
89
+ if (this.subGoal) this.removeSubGoal();
90
+ }
91
+
92
+ tick(state: GameState, ctx: StrategyContext): BehaviorDecision[] {
93
+ const visible = nonTeammatesVisible(state, ctx);
94
+ this.rememberThreats(visible);
95
+
96
+ // ── 被单个对手逼到死角且刀好 → 出刀自保(按角色 / 参数开关) ──
97
+ if (this.killEnabled(state) && canUseKill(state)) {
98
+ const victim = this.corneredVictim(state, visible);
99
+ if (victim) {
100
+ if (this.subGoal) this.removeSubGoal();
101
+ ctx.notifications.push(`被${victim.name}逼到无处可逃,出刀自保!`);
102
+ return [{ action: Action.kill(victim.name).withThinking(`被${victim.name}逼进出刀距离又逃不掉,先下手自保。`) }];
103
+ }
104
+ }
105
+
106
+ // ── 视野里有人 → 保持距离甩开 + 边逃边重算安全躲藏点 ──
107
+ if (visible.length > 0) {
108
+ this.spotGoal.planSpot(state, ctx); // 重算(排除威胁),逃完落到新点
109
+ this.setFlee();
110
+ this.emitProgress(state, ctx, visible, 'flee');
111
+ return [];
112
+ }
113
+
114
+ // ── 无人 → 走向最近安全躲藏点并潜伏 ──
115
+ if (this.subGoal) this.removeSubGoal();
116
+ const decisions = this.spotGoal.tick(state, ctx);
117
+ this.emitProgress(state, ctx, visible, decisions.length === 0 ? 'hidden' : 'relocate');
118
+ return decisions;
119
+ }
120
+
121
+ private killEnabled(state: GameState): boolean {
122
+ if (!roleHasKnife(state)) return false; // 无刀角色(普通虾)永不出刀,传 kill 参数也不行
123
+ return this.killWhenCornered ?? defaultKillWhenCornered(state);
124
+ }
125
+
126
+ /** 单个对手、进入出刀距离、且 planEscape 判定必被追上 → 返回该对手,否则 null。 */
127
+ private corneredVictim(state: GameState, visible: PlayerInfo[]): PlayerInfo | null {
128
+ // 只在单个追兵时自保:2 人以上当众动手仍会被另一人举报,逃跑(推演偏悲观,真人有视野盲区)反而更优。
129
+ if (visible.length !== 1) return null;
130
+ const target = visible[0];
131
+ const d = target.distance ?? dist(state.you.x, state.you.y, target.x, target.y);
132
+ if (d > killCommitRange(state.you.role)) return null;
133
+ const plan = planEscape({ x: state.you.x, y: state.you.y }, [{ x: target.x, y: target.y }], ESCAPE_OPTS);
134
+ return plan.caught ? target : null;
135
+ }
136
+
137
+ private setFlee(): void {
138
+ const worker = this.subGoal;
139
+ if (worker instanceof KeepAwayGoal && worker.key === FLEE_KEY) return;
140
+ if (this.subGoal) this.removeSubGoal();
141
+ this.setSubGoal(new KeepAwayGoal(FLEE_KEY, (s, c) => nonTeammatesVisible(s, c), {
142
+ threatRadius: Infinity,
143
+ noun: '靠近的人',
144
+ }), SUB_PRIORITY);
145
+ }
146
+
147
+ /** 记住近期见过的非队友位置(按名字去重 + TTL),供威胁离开视野后选点继续回避。 */
148
+ private rememberThreats(visible: PlayerInfo[]): void {
149
+ const now = Date.now();
150
+ for (const p of visible) {
151
+ const key = p.name || String(p.seat ?? '');
152
+ if (key) this.recentThreats.set(key, { x: p.x, y: p.y, until: now + THREAT_MEMORY_MS });
153
+ }
154
+ for (const [key, v] of this.recentThreats) if (v.until <= now) this.recentThreats.delete(key);
155
+ }
156
+
157
+ private threatPointsFor(): Position[] {
158
+ const now = Date.now();
159
+ const out: Position[] = [];
160
+ for (const v of this.recentThreats.values()) if (v.until > now) out.push({ x: v.x, y: v.y });
161
+ return out;
162
+ }
163
+
164
+ private emitProgress(state: GameState, ctx: StrategyContext, visible: PlayerInfo[], tier: ProgressTier): void {
165
+ const now = Date.now();
166
+ if (now - ctx.lastProgressNotifyAt < PROGRESS_INTERVAL_MS) return;
167
+ ctx.lastProgressNotifyAt = now;
168
+
169
+ const room = state.you.room ?? '未知';
170
+ const spot = this.spotGoal.currentSpot;
171
+ let msg = `[进度] 当前在${room}`;
172
+ if (tier === 'flee') {
173
+ const names = visible.map(p => p.name).join('、');
174
+ msg += `,${names}靠近,保持距离甩开${spot ? `,改藏到${spot.room}` : ''}。`;
175
+ } else if (tier === 'relocate') {
176
+ msg += spot ? `,前往躲藏点${spot.room}潜伏。` : ',寻找躲藏点。';
177
+ } else {
178
+ msg += spot ? `,已在${spot.room}潜伏,按兵不动。` : ',附近无可用躲藏点,原地待命。';
179
+ }
180
+ ctx.notifications.push(msg);
181
+ }
182
+ }
183
+
184
+ /** 是否带刀(有击杀能力):蟹 / 章鱼 / 武士虾 / 枪虾。普通虾无刀。 */
185
+ function roleHasKnife(state: GameState): boolean {
186
+ const role = state.you.role ?? '';
187
+ const faction = state.you.faction ?? '';
188
+ return faction === 'crab' || role.includes('crab') || role.includes('octopus')
189
+ || role === 'shrimp_warrior' || role === 'shrimp_pistol';
190
+ }
191
+
192
+ /** 角色默认是否「死角自保出刀」:蟹 / 章鱼开;武士虾 / 枪虾及其余关。 */
193
+ function defaultKillWhenCornered(state: GameState): boolean {
194
+ const role = state.you.role ?? '';
195
+ const faction = state.you.faction ?? '';
196
+ return faction === 'crab' || role.includes('crab') || role.includes('octopus');
197
+ }
@@ -14,7 +14,19 @@ const MAX_THREATS = 4;
14
14
  const CRITICAL_REPLAN_INTERVAL_MS = 1600;
15
15
  const PANIC_REPLAN_INTERVAL_MS = 2500;
16
16
  const PANIC_REPLAN_TARGET_SHIFT = 220;
17
- const PLAN_OPTS: EscapeOptions = { steps: 7, beamWidth: 8, directions: 16, fieldRadius: 900 };
17
+ /**
18
+ * keep-away 逃跑推演基线参数。各档(FLEE 不覆盖;陌生人覆盖 steps:4)在此基础上合并 options.planOpts。
19
+ * killRange 取 SHRIMP_KILL_RANGE(160) 而非 planner 默认 80:本层威胁只有坐标、没有角色(resolver
20
+ * 返回可见非队友的裸位置,本游戏看不到他人身份),无法逐威胁取 killRangeFor,故统一用带刀虾兜底,
21
+ * 宁可对蟹/章鱼多拉开也不对带刀好人少逃。武士虾自卫判定(warrior-shrimp-top)复用它,保证「避不开」口径一致。
22
+ */
23
+ export const KEEP_AWAY_BASE_PLAN_OPTS: EscapeOptions = {
24
+ killRange: SHRIMP_KILL_RANGE,
25
+ steps: 7,
26
+ beamWidth: 8,
27
+ directions: 16,
28
+ fieldRadius: 900,
29
+ };
18
30
  const FALLBACK_DISTANCES = [330, 260, 200, 140];
19
31
  const FALLBACK_ANGLE_OFFSETS = [0, Math.PI / 6, -Math.PI / 6, Math.PI / 3, -Math.PI / 3, Math.PI / 2, -Math.PI / 2, Math.PI];
20
32
 
@@ -101,11 +113,7 @@ export class KeepAwayGoal extends Goal {
101
113
  ) {
102
114
  super();
103
115
  this.threatRadius = options.threatRadius;
104
- // 逃跑推演对「会不会被追上」用保守击杀距离:威胁在本层只有坐标、没有角色(resolver 返回
105
- // 的是可见非队友的裸位置,本游戏看不到他人身份),无法逐威胁取 killRangeFor(role),故统一用
106
- // 武士虾/枪虾的 SHRIMP_KILL_RANGE(160) 兜底,宁可对蟹/章鱼多拉开距离也不对带刀好人少逃。
107
- // escape-planner 自身默认 80(服务端 base kill_range),此处刻意覆盖。
108
- this.planOpts = { killRange: SHRIMP_KILL_RANGE, ...PLAN_OPTS, ...options.planOpts };
116
+ this.planOpts = { ...KEEP_AWAY_BASE_PLAN_OPTS, ...options.planOpts };
109
117
  }
110
118
 
111
119
  tick(state: GameState, ctx: StrategyContext): BehaviorDecision[] {
@@ -165,7 +173,7 @@ export class KeepAwayGoal extends Goal {
165
173
  }
166
174
 
167
175
  const target = this.committed!;
168
- return [{ action: Action.move(target).withThinking(`躲避${threats.map(p => p.name).join('、')},前往 (${Math.round(target.x)}, ${Math.round(target.y)})`) }];
176
+ return [{ action: Action.move(target).withThinking(`躲避${threats.map(p => p.name).join('、')},拉开距离`) }];
169
177
  }
170
178
 
171
179
  isFinish(state: GameState, ctx: StrategyContext): boolean {
@@ -1,20 +1,12 @@
1
1
  import type { GameState, PlayerInfo } from '../../sdk/types.js';
2
2
  import { Action } from '../../sdk/action.js';
3
- import { dist, nearestKnownCorpse, nearestKnownCorpseNav } from '../game-utils.js';
4
- import type { BehaviorDecision, CorpseTarget, StrategyContext } from '../types.js';
3
+ import { nearestKnownCorpse, nearestKnownCorpseNav } from '../game-utils.js';
4
+ import type { BehaviorDecision, StrategyContext } from '../types.js';
5
+ import { AnchorLinger } from './anchor-linger.js';
5
6
  import { Goal } from './goal.js';
6
7
 
7
- const LINGER_MIN = 40;
8
- const LINGER_MAX = 100;
9
- /** 走到离游走点这么近就算到达、换下一个点(贴尸游走半径 40-100,单步约 144px)。 */
10
- const LINGER_REACHED = 24;
11
- /** 单个游走点最多停留这么久就强制换点,防止点落在墙里不可达时卡死。 */
12
- const LINGER_DWELL_MS = 4000;
13
-
14
8
  export class LingerCorpseGoal extends Goal {
15
- private wanderTarget: { x: number; y: number } | null = null;
16
- private wanderUntil = 0;
17
- private anchorKey = '';
9
+ private readonly wander = new AnchorLinger();
18
10
 
19
11
  constructor(private readonly avoidResolver?: (state: GameState, ctx: StrategyContext) => PlayerInfo[]) {
20
12
  super();
@@ -24,13 +16,14 @@ export class LingerCorpseGoal extends Goal {
24
16
  const body = nearestKnownCorpse(state, ctx);
25
17
  if (!body) {
26
18
  // 还没拿到尸体坐标:朝尸体所在房间靠近,人进视野后坐标自然补全,再切到下面的贴尸游走。
27
- this.wanderTarget = null;
19
+ this.wander.reset();
28
20
  const nav = nearestKnownCorpseNav(state, ctx);
29
21
  return nav ? [{ action: Action.move({ x: nav.x, y: nav.y }) }] : [];
30
22
  }
31
- // 有坐标:在尸体周围 40-100 徘徊。目标「粘住」直到到点 / 超时 / 换了尸体——不再每 tick 重随机,
32
- // 否则主循环对「同一移动目标」的去重永不触发,会每轮打断上一次移动、在原地抖而走不出一条游走腿。
33
- return [{ action: Action.move(this.resolveWanderTarget(state, ctx, body)) }];
23
+ // 有坐标:在尸体周围 40-100 粘性徘徊(AnchorLinger 负责粘住游走点、按 avoid 偏到对侧)。
24
+ const key = body.name ?? `${Math.round(body.x)},${Math.round(body.y)}`;
25
+ const avoid = this.avoidResolver?.(state, ctx) ?? [];
26
+ return [{ action: Action.move(this.wander.step(state, key, body, avoid)) }];
34
27
  }
35
28
 
36
29
  // Nav / 非 Nav 的不对称是有意的:tick 取坐标用严格的 nearestKnownCorpse(没坐标无法定点游走),
@@ -39,41 +32,4 @@ export class LingerCorpseGoal extends Goal {
39
32
  isFinish(state: GameState, ctx: StrategyContext): boolean {
40
33
  return nearestKnownCorpseNav(state, ctx) == null;
41
34
  }
42
-
43
- private resolveWanderTarget(state: GameState, ctx: StrategyContext, corpse: CorpseTarget): { x: number; y: number } {
44
- const key = corpse.name ?? `${Math.round(corpse.x)},${Math.round(corpse.y)}`;
45
- const now = Date.now();
46
- const reached = this.wanderTarget != null
47
- && dist(state.you.x, state.you.y, this.wanderTarget.x, this.wanderTarget.y) <= LINGER_REACHED;
48
- if (this.wanderTarget == null || this.anchorKey !== key || reached || now >= this.wanderUntil) {
49
- this.anchorKey = key;
50
- this.wanderTarget = this.pickOffset(state, ctx, corpse);
51
- this.wanderUntil = now + LINGER_DWELL_MS;
52
- }
53
- return this.wanderTarget;
54
- }
55
-
56
- private pickOffset(state: GameState, ctx: StrategyContext, corpse: CorpseTarget): { x: number; y: number } {
57
- const avoid = this.avoidResolver?.(state, ctx) ?? [];
58
- return avoid.length > 0 ? this.farSideOffset(state, corpse, avoid) : this.randomOffset(corpse);
59
- }
60
-
61
- private farSideOffset(state: GameState, corpse: CorpseTarget, avoid: PlayerInfo[]): { x: number; y: number } {
62
- const nearest = avoid.reduce((best, p) => {
63
- const d = p.distance ?? dist(state.you.x, state.you.y, p.x, p.y);
64
- const bestD = best.distance ?? dist(state.you.x, state.you.y, best.x, best.y);
65
- return d < bestD ? p : best;
66
- });
67
- const vx = corpse.x - nearest.x;
68
- const vy = corpse.y - nearest.y;
69
- const len = Math.hypot(vx, vy);
70
- if (len === 0) return this.randomOffset(corpse);
71
- return { x: corpse.x + (vx / len) * LINGER_MAX, y: corpse.y + (vy / len) * LINGER_MAX };
72
- }
73
-
74
- private randomOffset(corpse: CorpseTarget): { x: number; y: number } {
75
- const angle = Math.random() * 2 * Math.PI;
76
- const radius = LINGER_MIN + Math.random() * (LINGER_MAX - LINGER_MIN);
77
- return { x: corpse.x + Math.cos(angle) * radius, y: corpse.y + Math.sin(angle) * radius };
78
- }
79
35
  }
@@ -1,8 +1,6 @@
1
1
  import type { GameState, PlayerInfo, Position } from '../../sdk/types.js';
2
- import { Action } from '../../sdk/action.js';
3
2
  import {
4
3
  corpseAtScene,
5
- dist,
6
4
  hasKnownCorpse,
7
5
  isKnowledgeThreat,
8
6
  isKnowledgeTrusted,
@@ -13,12 +11,12 @@ import {
13
11
  } from '../game-utils.js';
14
12
  import type { BehaviorDecision, StrategyContext } from '../types.js';
15
13
  import { GreetingTracker } from '../greeting.js';
14
+ import { FollowCompanionGoal } from './follow-companion-goal.js';
16
15
  import { Goal } from './goal.js';
17
16
  import { KeepAwayGoal, type KeepAwayGoalOptions, type ThreatResolver } from './keep-away-goal.js';
18
17
  import { LingerCorpseGoal } from './linger-corpse-goal.js';
19
18
 
20
19
  const SUB_PRIORITY = 0.5;
21
- const HUDDLE_DISTANCE = 80;
22
20
  const AVOID_ROUTE_MEMORY_MS = 10_000;
23
21
  const WITNESS_EXEMPT_COUNT = 2;
24
22
 
@@ -26,6 +24,10 @@ type Tier = 'corpse' | 'flee-bad' | 'keep-distance' | 'huddle' | 'wander';
26
24
 
27
25
  export class ParadiseFishTop extends Goal {
28
26
  private readonly linger = new LingerCorpseGoal((state, ctx) => this.visibleAvoidTargets(state, ctx));
27
+ private readonly follow = new FollowCompanionGoal(
28
+ (state, ctx) => nonTeammatesVisible(state, ctx).filter(p => this.isTrusted(p, ctx)),
29
+ (state, ctx) => this.visibleAvoidTargets(state, ctx),
30
+ );
29
31
  private readonly patrol = new PatrolState();
30
32
  private readonly greeting: GreetingTracker;
31
33
  private readonly recentAvoidPoints = new Map<string, { point: Position; until: number; threat: boolean }>();
@@ -77,11 +79,11 @@ export class ParadiseFishTop extends Goal {
77
79
  }
78
80
  }
79
81
 
80
- const trusted = trustedTargets;
81
- if (trusted.length > 0) {
82
+ if (trustedTargets.length > 0) {
82
83
  this.clearSub();
84
+ const follow = this.follow.tick(state, ctx);
83
85
  this.emitProgress(state, ctx, visible, 'huddle');
84
- return [...this.huddle(state, trusted), ...decisions];
86
+ return [...follow, ...decisions];
85
87
  }
86
88
 
87
89
  this.clearSub();
@@ -110,20 +112,6 @@ export class ParadiseFishTop extends Goal {
110
112
  return threats.length > 0 ? threats : this.strangerAvoidTargets(visible, ctx);
111
113
  }
112
114
 
113
- private huddle(state: GameState, trusted: PlayerInfo[]): BehaviorDecision[] {
114
- if (trusted.length === 0) return [];
115
- const mate = trusted.reduce((best, p) => {
116
- const d = p.distance ?? dist(state.you.x, state.you.y, p.x, p.y);
117
- const bestD = best.distance ?? dist(state.you.x, state.you.y, best.x, best.y);
118
- return d < bestD ? p : best;
119
- });
120
- const d = mate.distance ?? dist(state.you.x, state.you.y, mate.x, mate.y);
121
- if (d > HUDDLE_DISTANCE) {
122
- return [{ action: Action.move({ x: mate.x, y: mate.y }) }];
123
- }
124
- return [];
125
- }
126
-
127
115
  private wander(state: GameState, ctx: StrategyContext): BehaviorDecision[] {
128
116
  return safePatrolStep(state, ctx, this.patrol, false, {
129
117
  threatPoints: this.activeAvoidPoints(),