@myclaw163/clawclaw-cli 0.6.67 → 0.6.69

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 (54) hide show
  1. package/bin/clawclaw-cli.mjs +3 -3
  2. package/package.json +1 -1
  3. package/scripts/sync-bundled-skill.mjs +1 -1
  4. package/skills/clawclaw/references/COMMANDS.md +4 -4
  5. package/skills/clawclaw/references/KNOWLEDGE.md +14 -12
  6. package/src/commands/config.ts +30 -30
  7. package/src/commands/game.ts +15 -0
  8. package/src/commands/knowledge.test.ts +4 -10
  9. package/src/commands/knowledge.ts +10 -39
  10. package/src/commands/setup/hermes.test.ts +96 -96
  11. package/src/commands/setup/hermes.ts +76 -76
  12. package/src/commands/setup/index.ts +13 -13
  13. package/src/commands/setup/openclaw.test.ts +114 -114
  14. package/src/commands/setup/openclaw.ts +147 -147
  15. package/src/commands/watch.test.ts +11 -0
  16. package/src/commands/watch.ts +2 -3
  17. package/src/lib/auth.test.ts +15 -0
  18. package/src/lib/host-config-patcher.test.ts +130 -130
  19. package/src/lib/host-config-patcher.ts +151 -151
  20. package/src/lib/hub-reminder.ts +19 -19
  21. package/src/lib/knowledge-store.test.ts +28 -38
  22. package/src/lib/knowledge-store.ts +52 -57
  23. package/src/pipeline/event-format.test.ts +82 -2
  24. package/src/pipeline/event-format.ts +114 -5
  25. package/src/pipeline/event-hints.ts +20 -3
  26. package/src/runtime/event-daemon.test.ts +34 -0
  27. package/src/runtime/event-daemon.ts +51 -3
  28. package/src/sdk/index.ts +2 -3
  29. package/src/sdk/types.ts +2 -0
  30. package/src/strategies/avoid-players.knowledge.md +7 -8
  31. package/src/strategies/avoid-players.ts +1 -1
  32. package/src/strategies/corpse-patrol.ts +1 -1
  33. package/src/strategies/game-utils.test.ts +53 -1
  34. package/src/strategies/game-utils.ts +92 -28
  35. package/src/strategies/goals/avoid-players-top.ts +3 -3
  36. package/src/strategies/goals/corpse-patrol-top.ts +23 -1
  37. package/src/strategies/goals/crab-octopus-reflexes.ts +11 -3
  38. package/src/strategies/goals/keep-away-goal.ts +9 -5
  39. package/src/strategies/goals/leaf-goal.ts +2 -0
  40. package/src/strategies/goals/lone-kill-task-top.ts +58 -11
  41. package/src/strategies/goals/normal-shrimp-top.ts +11 -11
  42. package/src/strategies/goals/paradise-fish-top.ts +32 -15
  43. package/src/strategies/goals/warrior-shrimp-top.test.ts +4 -3
  44. package/src/strategies/goals/warrior-shrimp-top.ts +62 -295
  45. package/src/strategies/hide-spots.ts +11 -75
  46. package/src/strategies/kill-lone.knowledge.md +6 -9
  47. package/src/strategies/lone-kill-task.ts +1 -1
  48. package/src/strategies/off-route-points.ts +105 -0
  49. package/src/strategies/paradise-fish.knowledge.md +7 -8
  50. package/src/strategies/paradise-fish.ts +1 -1
  51. package/src/strategies/shrimp-memory.knowledge.md +7 -8
  52. package/src/strategies/shrimp-memory.ts +1 -1
  53. package/src/strategies/warrior-memory.knowledge.md +9 -10
  54. package/src/strategies/warrior-memory.ts +1 -1
@@ -5,8 +5,9 @@
5
5
  * - 跨进程:Agent(短进程 CLI)写,strategy(长进程 loop)按 mtime 轮询读,免重启。
6
6
  * - 快照而非日志:单个 JSON 对象,del/clear 直接重写;写入用临时文件 + rename 原子替换。
7
7
  * - gameId 作用域:每份知识标记所属对局(= 最新 session 文件名),换局自动失效。
8
- * - 价值中立:只存推断 + confidence/source,对 value 不做价值判断;
8
+ * - 价值中立:只存推断 + source,对 value 不做价值判断;
9
9
  * 确定事实(role/队友/尸体…)走 state/ctx.teammates,不入库。
10
+ * - 玩家两档标记:只显式标 hostile(坏人)/ trusted(好人),未标记的一律默认「被怀疑」,不再有 suspect 标记,也不存置信度。
10
11
  */
11
12
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
12
13
  import { basename, dirname, join } from 'path';
@@ -20,14 +21,12 @@ export type KnowledgeScope = 'game' | 'session' | 'persistent';
20
21
 
21
22
  export interface FactMeta {
22
23
  value: unknown;
23
- confidence?: number;
24
24
  note?: string;
25
25
  source?: KnowledgeSource;
26
26
  ts: number;
27
27
  }
28
28
 
29
29
  export interface TagMeta {
30
- confidence?: number;
31
30
  note?: string;
32
31
  source?: KnowledgeSource;
33
32
  ts: number;
@@ -38,7 +37,7 @@ export interface SubjectEntry {
38
37
  type: string; // 'player' | 'room' | 'task' | 'global' | <自定义>
39
38
  selector: string; // player→座位号或名字;room→房名;global→''
40
39
  facts?: Record<string, FactMeta>; // 键值事实,如 role: { value: 'impostor' }
41
- tags?: Record<string, TagMeta>; // 玩家策略主标记:suspect / hostile / trusted;也支持自定义标签
40
+ tags?: Record<string, TagMeta>; // 玩家策略主标记:hostile / trusted(未标记默认被怀疑);也支持自定义标签
42
41
  scope?: KnowledgeScope;
43
42
  }
44
43
 
@@ -56,7 +55,8 @@ export type KnowledgeFileReadResult =
56
55
  export type PlayerRef = { name?: string; seat?: number };
57
56
  export type SubjectRef = { type: string; selector: string };
58
57
  export type KnowledgeRef = PlayerRef | SubjectRef;
59
- export type PlayerMark = 'suspect' | 'hostile' | 'trusted';
58
+ /** 玩家显式标记只有两档:hostile(坏人)/ trusted(好人)。未标记 = 被怀疑(markOf 返回 undefined)。 */
59
+ export type PlayerMark = 'hostile' | 'trusted';
60
60
 
61
61
  export interface KnowledgeView {
62
62
  /** 当前没有任何知识时为 true。 */
@@ -65,28 +65,24 @@ export interface KnowledgeView {
65
65
  subject(type: string, selector: string): SubjectEntry | undefined;
66
66
  /** 取某 subject 的键值事实。player ref 会按座位↔名字归一化解析。 */
67
67
  getFact(ref: KnowledgeRef, key: string): FactMeta | undefined;
68
- /** 该 subject 是否带某标签(可选最低置信度门槛)。玩家三档 mark 及旧别名按最新结论解析。 */
69
- hasTag(ref: KnowledgeRef, tag: string, minConfidence?: number): boolean;
70
- /** 按 type / tag / factKey / 置信度筛选 subject。玩家 mark 标签同样遵守最新结论。 */
71
- query(q: { type?: string; tag?: string; factKey?: string; minConfidence?: number }): SubjectEntry[];
68
+ /** 该 subject 是否带某标签。玩家两档 mark 及旧别名按最新结论解析。 */
69
+ hasTag(ref: KnowledgeRef, tag: string): boolean;
70
+ /** 按 type / tag / factKey 筛选 subject。玩家 mark 标签同样遵守最新结论。 */
71
+ query(q: { type?: string; tag?: string; factKey?: string }): SubjectEntry[];
72
72
  /** 某 type 下带某标签的全部 selector(喂给 matchesAnyTarget 用)。玩家 mark 标签同样过滤旧结论。 */
73
- selectorsWithTag(type: string, tag: string, minConfidence?: number): string[];
73
+ selectorsWithTag(type: string, tag: string): string[];
74
74
  // —— player 便捷语法糖 ——
75
75
  /** 最近一次 role 事实。身份事实与策略 mark 完全解耦。 */
76
76
  roleOf(p: PlayerRef): string | undefined;
77
- markOf(p: PlayerRef, minConfidence?: number): PlayerMark | undefined;
78
- isSuspect(p: PlayerRef, minConfidence?: number): boolean;
79
- isHostile(p: PlayerRef, minConfidence?: number): boolean;
80
- isTrusted(p: PlayerRef, minConfidence?: number): boolean;
81
- /** @deprecated Use isHostile. */
82
- isConfirmedHostile(p: PlayerRef, minConfidence?: number): boolean;
83
- /** @deprecated Use isTrusted. */
84
- isCleared(p: PlayerRef): boolean;
77
+ /** 最新显式标记;未标记返回 undefined(= 被怀疑)。 */
78
+ markOf(p: PlayerRef): PlayerMark | undefined;
79
+ /** 被怀疑 = 既非 hostile 也非 trusted(含完全未标记者)。 */
80
+ isSuspect(p: PlayerRef): boolean;
81
+ isHostile(p: PlayerRef): boolean;
82
+ isTrusted(p: PlayerRef): boolean;
85
83
  }
86
84
 
87
85
  const PLAYER_MARK_ALIASES: Record<string, PlayerMark> = {
88
- suspect: 'suspect',
89
- avoid: 'suspect',
90
86
  hostile: 'hostile',
91
87
  kill_if_armed: 'hostile',
92
88
  trusted: 'trusted',
@@ -95,6 +91,14 @@ const PLAYER_MARK_ALIASES: Record<string, PlayerMark> = {
95
91
  do_not_kill: 'trusted',
96
92
  };
97
93
 
94
+ /**
95
+ * 两档化后被废弃的旧玩家标记:一律视同未标记(= 被怀疑),不映射到任何 canonical mark
96
+ *(「被怀疑」就是默认态,没有对应标签)。读取侧与写入侧共用这一份清单,避免漂移:
97
+ * - 读取侧(buildKnowledgeView.tagActive):旧数据里的 tags.suspect / tags.avoid 不再被 hasTag / query / selectorsWithTag 命中。
98
+ * - 写入侧(knowledge mark 的 removeTags):改判玩家时顺手删掉残留的 raw tag,否则 knowledge get / subject() 仍会暴露 tags.suspect/avoid 误导人。
99
+ */
100
+ export const DROPPED_PLAYER_MARK_TAGS = new Set(['suspect', 'avoid']);
101
+
98
102
  function nowTs(): number {
99
103
  return Date.now();
100
104
  }
@@ -180,7 +184,6 @@ export interface KnowledgeSetInput {
180
184
  value?: unknown;
181
185
  tags?: string[];
182
186
  removeTags?: string[];
183
- confidence?: number;
184
187
  note?: string;
185
188
  source?: KnowledgeSource;
186
189
  scope?: KnowledgeScope;
@@ -210,9 +213,6 @@ export class KnowledgeStore {
210
213
 
211
214
  set(input: KnowledgeSetInput): SubjectEntry {
212
215
  if (!input.type || !input.selector) throw new Error('knowledge set requires type and selector');
213
- if (input.confidence != null && (!Number.isFinite(input.confidence) || input.confidence < 0 || input.confidence > 1)) {
214
- throw new Error('confidence must be a finite number between 0 and 1');
215
- }
216
216
  const file = this.load();
217
217
  const key = subjectKey(input.type, input.selector);
218
218
  const entry: SubjectEntry = file.subjects[key] ?? { type: input.type, selector: input.selector };
@@ -223,7 +223,6 @@ export class KnowledgeStore {
223
223
  entry.facts = entry.facts ?? {};
224
224
  entry.facts[input.key] = {
225
225
  value: input.value === undefined ? true : input.value,
226
- confidence: input.confidence,
227
226
  note: input.note,
228
227
  source,
229
228
  ts,
@@ -233,7 +232,7 @@ export class KnowledgeStore {
233
232
  entry.tags = entry.tags ?? {};
234
233
  for (const tag of input.tags) {
235
234
  if (!tag) continue;
236
- entry.tags[tag] = { confidence: input.confidence, note: input.note, source, ts };
235
+ entry.tags[tag] = { note: input.note, source, ts };
237
236
  }
238
237
  }
239
238
  if (input.removeTags && entry.tags) {
@@ -283,7 +282,6 @@ export function buildKnowledgeView(
283
282
  ): KnowledgeView {
284
283
  const subjects = file?.subjects ?? {};
285
284
  const namesBySeat = opts.playerNamesBySeat ?? {};
286
- const confOk = (conf: number | undefined, min?: number): boolean => min == null || (conf ?? 1) >= min;
287
285
 
288
286
  const resolveAll = (ref: KnowledgeRef): SubjectEntry[] => {
289
287
  if (isSubjectRef(ref)) {
@@ -302,8 +300,9 @@ export function buildKnowledgeView(
302
300
  return best;
303
301
  };
304
302
 
305
- type MarkVerdict = { kind: PlayerMark; ts: number; confidence?: number };
306
- const markRank = (kind: PlayerMark): number => kind === 'trusted' ? 3 : kind === 'hostile' ? 2 : 1;
303
+ type MarkVerdict = { kind: PlayerMark; ts: number };
304
+ // 同一时刻打了冲突标记时,trusted 优先级高于 hostile(好人判断更慎重,不轻易被覆盖)。
305
+ const markRank = (kind: PlayerMark): number => kind === 'trusted' ? 2 : 1;
307
306
  const latestMark = (ref: KnowledgeRef): MarkVerdict | undefined => {
308
307
  let best: MarkVerdict | undefined;
309
308
  const take = (v: MarkVerdict) => {
@@ -316,56 +315,52 @@ export function buildKnowledgeView(
316
315
  for (const s of resolveAll(ref)) {
317
316
  for (const [tag, meta] of Object.entries(s.tags ?? {})) {
318
317
  const kind = PLAYER_MARK_ALIASES[tag];
319
- if (kind) take({ kind, ts: meta.ts, confidence: meta.confidence });
318
+ if (kind) take({ kind, ts: meta.ts });
320
319
  }
321
320
  }
322
321
  return best;
323
322
  };
324
323
 
325
324
  const markKindForTag = (tag: string): PlayerMark | undefined => PLAYER_MARK_ALIASES[tag];
326
- const tagActive = (s: SubjectEntry, tag: string, min?: number): boolean => {
327
- const requestedMark = markKindForTag(tag);
328
- if (s.type === 'player' && requestedMark) {
329
- const carriesRequestedMark = Object.keys(s.tags ?? {}).some(candidate => markKindForTag(candidate) === requestedMark);
330
- if (!carriesRequestedMark) return false;
331
- const ref: PlayerRef = /^\d+$/.test(s.selector.trim()) ? { seat: Number(s.selector) } : { name: s.selector };
332
- const mark = latestMark(ref);
333
- return !!mark && mark.kind === requestedMark && confOk(mark.confidence, min);
325
+ const tagActive = (s: SubjectEntry, tag: string): boolean => {
326
+ if (s.type === 'player') {
327
+ const requestedMark = markKindForTag(tag);
328
+ if (requestedMark) {
329
+ const carriesRequestedMark = Object.keys(s.tags ?? {}).some(candidate => markKindForTag(candidate) === requestedMark);
330
+ if (!carriesRequestedMark) return false;
331
+ const ref: PlayerRef = /^\d+$/.test(s.selector.trim()) ? { seat: Number(s.selector) } : { name: s.selector };
332
+ const mark = latestMark(ref);
333
+ return !!mark && mark.kind === requestedMark;
334
+ }
335
+ // 废弃的旧玩家标记(suspect/avoid)= 未标记 = 被怀疑,绝不命中;其余自定义标签(如 paradise_fish)照常按原值判定。
336
+ if (DROPPED_PLAYER_MARK_TAGS.has(tag)) return false;
334
337
  }
335
- const meta = s.tags?.[tag];
336
- return !!meta && confOk(meta.confidence, min);
338
+ return !!s.tags?.[tag];
337
339
  };
338
340
 
339
341
  return {
340
342
  empty: Object.keys(subjects).length === 0,
341
343
  subject: (type, selector) => subjects[subjectKey(type, selector)],
342
344
  getFact: (ref, key) => latestFact(ref, key),
343
- hasTag: (ref, tag, min) => resolveAll(ref).some((s) => tagActive(s, tag, min)),
345
+ hasTag: (ref, tag) => resolveAll(ref).some((s) => tagActive(s, tag)),
344
346
  query: (q) => Object.values(subjects).filter((s) => {
345
347
  if (q.type && s.type !== q.type) return false;
346
- if (q.factKey) {
347
- const f = s.facts?.[q.factKey];
348
- if (!f || !confOk(f.confidence, q.minConfidence)) return false;
349
- }
350
- if (q.tag && !tagActive(s, q.tag, q.minConfidence)) return false;
348
+ if (q.factKey && !s.facts?.[q.factKey]) return false;
349
+ if (q.tag && !tagActive(s, q.tag)) return false;
351
350
  return true;
352
351
  }),
353
- selectorsWithTag: (type, tag, min) => Object.values(subjects)
354
- .filter((s) => s.type === type && tagActive(s, tag, min))
352
+ selectorsWithTag: (type, tag) => Object.values(subjects)
353
+ .filter((s) => s.type === type && tagActive(s, tag))
355
354
  .map((s) => s.selector),
356
355
  roleOf: (p) => {
357
356
  const fact = latestFact(p, 'role');
358
357
  return typeof fact?.value === 'string' ? fact.value : undefined;
359
358
  },
360
- markOf: (p, min) => {
361
- const mark = latestMark(p);
362
- return mark && confOk(mark.confidence, min) ? mark.kind : undefined;
363
- },
364
- isSuspect: (p, min) => latestMark(p)?.kind === 'suspect' && confOk(latestMark(p)?.confidence, min),
365
- isHostile: (p, min) => latestMark(p)?.kind === 'hostile' && confOk(latestMark(p)?.confidence, min),
366
- isTrusted: (p, min) => latestMark(p)?.kind === 'trusted' && confOk(latestMark(p)?.confidence, min),
367
- isConfirmedHostile: (p, min) => latestMark(p)?.kind === 'hostile' && confOk(latestMark(p)?.confidence, min),
368
- isCleared: (p) => latestMark(p)?.kind === 'trusted',
359
+ markOf: (p) => latestMark(p)?.kind,
360
+ // 未标记 hostile/trusted 的玩家一律「被怀疑」——这就是「除好人坏人外剩下全是被怀疑」的落点。
361
+ isSuspect: (p) => !latestMark(p),
362
+ isHostile: (p) => latestMark(p)?.kind === 'hostile',
363
+ isTrusted: (p) => latestMark(p)?.kind === 'trusted',
369
364
  };
370
365
  }
371
366
 
@@ -80,6 +80,87 @@ describe('event-format', () => {
80
80
  expect(JSON.stringify(formatted)).not.toContain('单钳渔夫');
81
81
  });
82
82
 
83
+ it('formats meeting result events as meeting-ended notices with seat-aware votes', () => {
84
+ const ctx = {
85
+ summary: {
86
+ game: {
87
+ all_seats: {
88
+ 菜逼油条: 8,
89
+ 单钳渔夫: 1,
90
+ 暗礁壳壳: 5,
91
+ },
92
+ },
93
+ },
94
+ };
95
+ const exile = {
96
+ type: 'exile',
97
+ tick: 3411,
98
+ actor_name: '单钳渔夫',
99
+ votes: {
100
+ 菜逼油条: '单钳渔夫',
101
+ 暗礁壳壳: 'skip',
102
+ },
103
+ hint: 'backend hint',
104
+ };
105
+
106
+ expect(formatEventMessage(exile, ctx)).toBe('t3411 exile 会议结束:1号单钳渔夫被放逐。');
107
+ expect(compactEventFields(exile, ctx)).toMatchObject({
108
+ type: 'exile',
109
+ tick: 3411,
110
+ meeting_ended: true,
111
+ result: 'exiled',
112
+ exiled_player: { name: '单钳渔夫', seat: 1 },
113
+ votes: {
114
+ '8号菜逼油条': '1号单钳渔夫',
115
+ '5号暗礁壳壳': 'skip',
116
+ },
117
+ });
118
+
119
+ expect(formatEventMessage({ type: 'no_exile', tick: 3412 }, ctx)).toBe('t3412 no_exile 会议结束:无人被放逐。');
120
+ expect(compactEventFields({ type: 'no_exile', tick: 3412 }, ctx)).toMatchObject({
121
+ type: 'no_exile',
122
+ tick: 3412,
123
+ meeting_ended: true,
124
+ result: 'no_exile',
125
+ });
126
+ });
127
+
128
+ it('fills crab teammate seats from the opening seat map', () => {
129
+ const ctx = {
130
+ summary: {
131
+ game: {
132
+ all_players: [{ name: '菜逼油条', seat: 8 }],
133
+ },
134
+ },
135
+ };
136
+ const event = {
137
+ type: 'crab_teammates',
138
+ tick: 20,
139
+ teammates: ['菜逼油条'],
140
+ };
141
+
142
+ expect(formatEventMessage(event, ctx)).toBe('t20 crab_teammates 蟹队友:8号菜逼油条。');
143
+ expect(compactEventFields(event, ctx)).toMatchObject({
144
+ type: 'crab_teammates',
145
+ tick: 20,
146
+ crab_teammates: [{ name: '菜逼油条', seat: 8 }],
147
+ });
148
+ });
149
+
150
+ it('formats death_speech with explicit speaker fields', () => {
151
+ const event = {
152
+ type: 'death_speech',
153
+ tick: 2201,
154
+ actor_name: '旧字段',
155
+ actor_seat: 2,
156
+ speaker_name: '暗礁壳壳',
157
+ speaker_seat: 5,
158
+ text: '我看到2号在尸体旁。',
159
+ };
160
+
161
+ expect(formatEventMessage(event)).toBe('t2201 death_speech 5号暗礁壳壳死亡弹幕:我看到2号在尸体旁。');
162
+ });
163
+
83
164
  it('keeps monitor message formatting separate from event detail fields', () => {
84
165
  const notice = formatEventMessage({
85
166
  type: 'vote_phase_start',
@@ -106,6 +187,7 @@ describe('event-format', () => {
106
187
  it('keeps local hint formatters for backend-hinted monitor events', () => {
107
188
  const backendHintedMonitorEvents = [
108
189
  'corpse_spotted',
190
+ 'crab_teammates',
109
191
  'death_speech',
110
192
  'emergency_resolved',
111
193
  'emergency_started',
@@ -114,7 +196,6 @@ describe('event-format', () => {
114
196
  'kill',
115
197
  'killed',
116
198
  'meeting_briefing',
117
- 'meeting_ended',
118
199
  'murder_witnessed',
119
200
  'no_exile',
120
201
  'octopus_time_start',
@@ -122,7 +203,6 @@ describe('event-format', () => {
122
203
  'speech_skipped',
123
204
  'task_completed',
124
205
  'task_sabotaged',
125
- 'vote_cast',
126
206
  'vote_phase_start',
127
207
  'vote_speech_phase_ended',
128
208
  'wandering_speech',
@@ -107,6 +107,63 @@ function playerLabel(name: unknown, seat: unknown, ctx: EventFormatContext): str
107
107
  return `${seatLabel(name, seat, ctx)}${text(name, '未知玩家')}`;
108
108
  }
109
109
 
110
+ function playerObject(name: unknown, seat: unknown, ctx: EventFormatContext): Record<string, any> | undefined {
111
+ if (typeof name !== 'string' || name.length === 0) return undefined;
112
+ return cleanObject({
113
+ name,
114
+ seat: numberValue(seat) ?? seatForName(name, ctx),
115
+ });
116
+ }
117
+
118
+ function voteTargetLabel(target: unknown, ctx: EventFormatContext): string {
119
+ if (target === null || target === undefined) return 'skip';
120
+ if (typeof target === 'object' && !Array.isArray(target)) {
121
+ const targetObject = target as Record<string, any>;
122
+ return playerLabel(targetObject.name ?? targetObject.agent_name, targetObject.seat, ctx);
123
+ }
124
+ const raw = text(target, 'skip');
125
+ return raw === 'skip' ? 'skip' : playerLabel(raw, undefined, ctx);
126
+ }
127
+
128
+ function formatVotes(votes: unknown, ctx: EventFormatContext): Record<string, string> | undefined {
129
+ if (!votes || typeof votes !== 'object' || Array.isArray(votes)) return undefined;
130
+ const out: Record<string, string> = {};
131
+ for (const [voter, target] of Object.entries(votes)) {
132
+ out[playerLabel(voter, undefined, ctx)] = voteTargetLabel(target, ctx);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function teammateNames(event: Record<string, any>): string[] {
138
+ const raw = Array.isArray(event.crab_teammates)
139
+ ? event.crab_teammates
140
+ : Array.isArray(event.teammates)
141
+ ? event.teammates
142
+ : [];
143
+ const names: string[] = [];
144
+ for (const teammate of raw) {
145
+ const name = typeof teammate === 'string' ? teammate : teammate?.name;
146
+ if (typeof name === 'string' && name.length > 0) names.push(name);
147
+ }
148
+ return names;
149
+ }
150
+
151
+ function teammateObjects(event: Record<string, any>, ctx: EventFormatContext): Record<string, any>[] {
152
+ const raw = Array.isArray(event.crab_teammates)
153
+ ? event.crab_teammates
154
+ : Array.isArray(event.teammates)
155
+ ? event.teammates
156
+ : [];
157
+ const teammates: Record<string, any>[] = [];
158
+ for (const teammate of raw) {
159
+ const name = typeof teammate === 'string' ? teammate : teammate?.name;
160
+ const seat = typeof teammate === 'string' ? undefined : teammate?.seat;
161
+ const normalized = playerObject(name, seat, ctx);
162
+ if (normalized) teammates.push(normalized);
163
+ }
164
+ return teammates;
165
+ }
166
+
110
167
  function prefix(event: Record<string, any>): string {
111
168
  const type = text(event.type, 'event');
112
169
  const tick = numberValue(event.tick);
@@ -171,14 +228,24 @@ function shortBody(event: Record<string, any>, ctx: EventFormatContext): string
171
228
  return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}已投票。`;
172
229
  case 'vote_speech_phase_ended':
173
230
  return '投票弹幕窗口已关闭。请用 ccl do -v <玩家名|skip> 完成投票。';
174
- case 'exile':
175
- return `${playerLabel(event.actor_name ?? event.result_target, firstNumber(event, ['actor_seat', 'result_target_seat', 'seat']), ctx)}被驱逐。`;
231
+ case 'exile': {
232
+ const exiledName = event.result_target ?? event.actor_name;
233
+ const exiledSeat = firstNumber(event, ['result_target_seat', 'actor_seat', 'seat']);
234
+ return `会议结束:${playerLabel(exiledName, exiledSeat, ctx)}被放逐。`;
235
+ }
176
236
  case 'no_exile':
177
- return '本轮无人被驱逐。';
237
+ return '会议结束:无人被放逐。';
178
238
  case 'meeting_ended':
179
239
  return '会议结束。';
180
- case 'death_speech':
181
- return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}死亡弹幕:${truncate(event.text ?? event.message, maxTextLength)}`;
240
+ case 'death_speech': {
241
+ const speakerName = event.speaker_name ?? event.actor_name;
242
+ const speakerSeat = firstNumber(event, ['speaker_seat', 'actor_seat', 'seat']);
243
+ return `${playerLabel(speakerName, speakerSeat, ctx)}死亡弹幕:${truncate(event.text ?? event.message, maxTextLength)}`;
244
+ }
245
+ case 'crab_teammates': {
246
+ const labels = teammateNames(event).map((name) => playerLabel(name, undefined, ctx));
247
+ return labels.length > 0 ? `蟹队友:${labels.join('、')}。` : '蟹队友信息已更新。';
248
+ }
182
249
  case 'wandering_speech':
183
250
  return `${text(event.actor_name, '你')}在${firstString(event, ['room'])}说:${truncate(event.text ?? event.message, maxTextLength)}`;
184
251
  case 'game_over':
@@ -277,6 +344,46 @@ function compactVoteCastForEvents(event: Record<string, any>, ctx: EventFormatCo
277
344
  });
278
345
  }
279
346
 
347
+ function compactCrabTeammatesForEvents(event: Record<string, any>, ctx: EventFormatContext): FormattedEvent {
348
+ const {
349
+ hint: _hint,
350
+ teammates: _teammates,
351
+ crab_teammates: _crabTeammates,
352
+ ...rest
353
+ } = event;
354
+ return cleanObject({
355
+ ...rest,
356
+ type: text(rest.type, 'event'),
357
+ tick: numberValue(rest.tick) ?? null,
358
+ crab_teammates: teammateObjects(event, ctx),
359
+ hint: formatEventHint(event, ctx),
360
+ });
361
+ }
362
+
363
+ function compactMeetingResultForEvents(event: Record<string, any>, ctx: EventFormatContext): FormattedEvent {
364
+ const {
365
+ hint: _hint,
366
+ votes: _votes,
367
+ ...rest
368
+ } = event;
369
+ const exiledName = event.type === 'exile'
370
+ ? event.result_target ?? event.actor_name
371
+ : undefined;
372
+ const exiledSeat = event.type === 'exile'
373
+ ? firstNumber(event, ['result_target_seat', 'actor_seat', 'seat'])
374
+ : undefined;
375
+ return cleanObject({
376
+ ...rest,
377
+ type: text(rest.type, 'event'),
378
+ tick: numberValue(rest.tick) ?? null,
379
+ meeting_ended: true,
380
+ result: event.type === 'exile' ? 'exiled' : 'no_exile',
381
+ exiled_player: playerObject(exiledName, exiledSeat, ctx),
382
+ votes: formatVotes(event.votes, ctx),
383
+ hint: formatEventHint(event, ctx),
384
+ });
385
+ }
386
+
280
387
  export function compactEventForEvents(
281
388
  event: Record<string, any>,
282
389
  ctx: EventFormatContext = {},
@@ -294,6 +401,8 @@ export function compactEventForEvents(
294
401
  }
295
402
 
296
403
  if (event.type === 'vote_cast') return compactVoteCastForEvents(event, ctx);
404
+ if (event.type === 'crab_teammates') return compactCrabTeammatesForEvents(event, ctx);
405
+ if (event.type === 'exile' || event.type === 'no_exile') return compactMeetingResultForEvents(event, ctx);
297
406
 
298
407
  const compact = stripBackendHint(event);
299
408
  return cleanObject({
@@ -140,10 +140,27 @@ export const EVENT_HINT_FORMATTERS: Record<string, EventHintFormatter> = {
140
140
  ),
141
141
  emergency_resolved: (event) => `${actorName(event)} broke the emergency. Crab pressure collapses, for now.`,
142
142
  exile: (event) => (
143
- `${actorName(event, text(event.result_target, 'Someone'))} was exiled by the table. `
143
+ `Meeting ended — ${actorName(event, text(event.result_target, 'Someone'))} was exiled by the table. `
144
144
  + 'If this is you, your influence is dead — tell your user and ask whether to spectate or leave.'
145
145
  ),
146
- no_exile: () => 'No one was exiled. The table failed to strike, and every hidden threat gets another turn.',
146
+ no_exile: () => 'Meeting ended — no one was exiled. The table failed to strike, and every hidden threat gets another turn.',
147
+ crab_teammates: (event) => {
148
+ const raw = Array.isArray(event.crab_teammates)
149
+ ? event.crab_teammates
150
+ : Array.isArray(event.teammates)
151
+ ? event.teammates
152
+ : [];
153
+ const labels = raw
154
+ .map((teammate: any) => (
155
+ typeof teammate === 'string'
156
+ ? teammate
157
+ : optionalSeatPlayerLabel(teammate?.name, teammate?.seat)
158
+ ))
159
+ .filter((label: string) => label.length > 0);
160
+ return labels.length > 0
161
+ ? `Your Crab teammate(s): ${labels.join(', ')}. Keep this private and coordinate cover stories.`
162
+ : 'Your Crab teammate list is available. Keep it private.';
163
+ },
147
164
  meeting_briefing: (event) => meetingBriefingHint(event),
148
165
  speech_skipped: (event) => `${actorName(event, 'Someone')} lost the floor — silence becomes evidence`,
149
166
  speech_your_turn: () => 'Submit `ccl do -s "<draft>"` immediately. Server skips you after 45s.',
@@ -163,7 +180,7 @@ export const EVENT_HINT_FORMATTERS: Record<string, EventHintFormatter> = {
163
180
  }
164
181
  return `Game over — ${text(event.winner, '?')} faction takes the table. The power struggle is settled. Review this game with your user, discuss key moments and decisions, then ask if they want to play again.`;
165
182
  },
166
- death_speech: (event) => `${actorName(event, 'Someone')} sent danmaku after death: ${text(event.text, '')}`,
183
+ death_speech: (event) => `${text(event.speaker_name ?? event.actor_name, 'Someone')} sent danmaku after death: ${text(event.text, '')}`,
167
184
  wandering_speech: (event) => `${actorName(event, 'Someone')}: ${text(event.text, '')}`,
168
185
  };
169
186
 
@@ -53,6 +53,40 @@ describe('buildMeetingStateProjection', () => {
53
53
  });
54
54
  });
55
55
 
56
+ describe('EventRuntime map seat cache', () => {
57
+ it('keeps opening map seats in the owner snapshot for formatters', async () => {
58
+ const { runtime } = makeRuntimeHarness();
59
+ const updateMapCache = vi.fn();
60
+ (runtime as any).client = {
61
+ getMap: vi.fn().mockResolvedValue({
62
+ all_players: [
63
+ { name: 'me', seat: 6 },
64
+ { name: '菜逼油条', seat: 8 },
65
+ ],
66
+ all_task_locations: [],
67
+ }),
68
+ };
69
+ (runtime as any).playerHistory = { updateMapCache };
70
+
71
+ (runtime as any).refreshPlayerHistoryMapCache();
72
+ await new Promise((resolve) => setTimeout(resolve, 0));
73
+
74
+ expect((runtime as any).currentGame).toMatchObject({
75
+ all_players: [
76
+ { name: 'me', seat: 6 },
77
+ { name: '菜逼油条', seat: 8 },
78
+ ],
79
+ all_seats: {
80
+ me: 6,
81
+ 菜逼油条: 8,
82
+ },
83
+ });
84
+ expect((runtime as any).currentYou.seat).toBe(6);
85
+ expect((runtime as any).snapshot()?.game?.all_seats?.菜逼油条).toBe(8);
86
+ expect(updateMapCache).toHaveBeenCalled();
87
+ });
88
+ });
89
+
56
90
  describe('EventRuntime game over detection', () => {
57
91
  it('stops when a state snapshot phase is game_over even without a game_over event', () => {
58
92
  const { runtime, appended, stops } = makeRuntimeHarness();
@@ -33,6 +33,26 @@ function cleanObject<T extends Record<string, any>>(obj: T): T {
33
33
  return obj;
34
34
  }
35
35
 
36
+ function isFiniteNumber(value: unknown): value is number {
37
+ return typeof value === 'number' && Number.isFinite(value);
38
+ }
39
+
40
+ function playersFromMap(mapData: any): Array<{ name: string; seat: number }> {
41
+ if (!mapData || typeof mapData !== 'object' || !Array.isArray(mapData.all_players)) return [];
42
+ const players: Array<{ name: string; seat: number }> = [];
43
+ for (const player of mapData.all_players) {
44
+ if (typeof player?.name !== 'string' || !isFiniteNumber(player.seat)) continue;
45
+ players.push({ name: player.name, seat: player.seat });
46
+ }
47
+ return players;
48
+ }
49
+
50
+ function seatMapFromPlayers(players: Array<{ name: string; seat: number }>): Record<string, number> {
51
+ const seats: Record<string, number> = {};
52
+ for (const player of players) seats[player.name] = player.seat;
53
+ return seats;
54
+ }
55
+
36
56
  function isGameOverState(data: Record<string, any>): boolean {
37
57
  return data.phase === 'game_over';
38
58
  }
@@ -112,6 +132,8 @@ export class EventRuntime {
112
132
  private currentYou: Record<string, any> = {};
113
133
  private currentGame: Record<string, any> = {};
114
134
  private currentUrgent: Record<string, any> = {};
135
+ private currentAllPlayers: Array<{ name: string; seat: number }> = [];
136
+ private currentSeatMap: Record<string, number> = {};
115
137
  private mapCacheLoaded = false;
116
138
  private mapCachePromise: Promise<void> | null = null;
117
139
  private currentMeetingRound = 0;
@@ -217,8 +239,24 @@ export class EventRuntime {
217
239
  this.mapCachePromise = this.client.getMap()
218
240
  .then((mapData) => {
219
241
  if (!mapData || typeof mapData !== 'object') return;
242
+ const players = playersFromMap(mapData);
243
+ if (players.length > 0) {
244
+ this.currentAllPlayers = players;
245
+ this.currentSeatMap = seatMapFromPlayers(players);
246
+ this.currentGame = cleanObject({
247
+ ...this.currentGame,
248
+ all_players: this.currentAllPlayers,
249
+ all_seats: this.currentSeatMap,
250
+ });
251
+ const myName = this.currentYou.name ?? this.profileName;
252
+ const mySeat = typeof myName === 'string' ? this.currentSeatMap[myName] : undefined;
253
+ if (mySeat !== undefined) {
254
+ this.currentYou = { ...this.currentYou, seat: this.currentYou.seat ?? mySeat };
255
+ }
256
+ this.writeFeed();
257
+ }
220
258
  this.playerHistory?.updateMapCache(mapData);
221
- this.mapCacheLoaded = true;
259
+ if (players.length > 0) this.mapCacheLoaded = true;
222
260
  })
223
261
  .catch(() => {})
224
262
  .finally(() => {
@@ -268,11 +306,18 @@ export class EventRuntime {
268
306
  const stateSaysGameOver = isGameOverState(s);
269
307
  this.currentPhase = s.phase ?? this.currentPhase;
270
308
  this.currentYou = s.you ? { ...s.you } : this.currentYou;
271
- this.currentGame = {
309
+ const myName = this.currentYou.name ?? this.profileName;
310
+ const mySeat = typeof myName === 'string' ? this.currentSeatMap[myName] : undefined;
311
+ if (this.currentYou.seat === undefined && mySeat !== undefined) {
312
+ this.currentYou = { ...this.currentYou, seat: mySeat };
313
+ }
314
+ this.currentGame = cleanObject({
272
315
  game_id: s.game_id,
273
316
  alive_count: s.alive_count,
274
317
  task_progress: s.task_progress,
275
- };
318
+ all_players: this.currentAllPlayers.length > 0 ? this.currentAllPlayers : undefined,
319
+ all_seats: Object.keys(this.currentSeatMap).length > 0 ? this.currentSeatMap : undefined,
320
+ });
276
321
  if (this.currentPhase !== 'lobby') this.refreshPlayerHistoryMapCache();
277
322
 
278
323
  if (s.meeting) {
@@ -375,6 +420,9 @@ export class EventRuntime {
375
420
  this.refreshPlayerHistoryMapCache();
376
421
  this.playerHistory?.recordPlayerSpotted(data);
377
422
  }
423
+ if (data.type === 'role_assigned' || data.type === 'game_started' || data.type === 'crab_teammates') {
424
+ this.refreshPlayerHistoryMapCache();
425
+ }
378
426
 
379
427
  if (data.type === 'meeting_start' || data.type === 'meeting_started') {
380
428
  this.currentMeetingRound += 1;