@myclaw163/clawclaw-cli 0.6.68 → 0.6.70

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 (47) hide show
  1. package/package.json +1 -1
  2. package/skills/clawclaw/references/KNOWLEDGE.md +1 -1
  3. package/skills/clawclaw/references/STRATEGIES.md +5 -3
  4. package/src/commands/game.ts +15 -0
  5. package/src/commands/strategy.test.ts +10 -0
  6. package/src/commands/strategy.ts +11 -10
  7. package/src/commands/watch.test.ts +11 -0
  8. package/src/commands/watch.ts +2 -3
  9. package/src/lib/auth.test.ts +15 -0
  10. package/src/pipeline/event-format.test.ts +82 -2
  11. package/src/pipeline/event-format.ts +114 -5
  12. package/src/pipeline/event-hints.ts +20 -3
  13. package/src/runtime/event-daemon.test.ts +34 -0
  14. package/src/runtime/event-daemon.ts +51 -3
  15. package/src/sdk/index.ts +1 -1
  16. package/src/strategies/avoid-lone.ts +1 -0
  17. package/src/strategies/avoid-players.ts +1 -0
  18. package/src/strategies/corpse-patrol.ts +1 -0
  19. package/src/strategies/crab-sabotage.ts +1 -0
  20. package/src/strategies/custom-module.test.ts +1 -0
  21. package/src/strategies/find-player.ts +1 -0
  22. package/src/strategies/game-utils.test.ts +53 -1
  23. package/src/strategies/game-utils.ts +69 -17
  24. package/src/strategies/goals/crab-octopus-reflexes.ts +11 -3
  25. package/src/strategies/goals/keep-away-goal.ts +9 -5
  26. package/src/strategies/goals/lone-kill-task-top.ts +25 -9
  27. package/src/strategies/goals/warrior-shrimp-top.ts +13 -306
  28. package/src/strategies/hide-spots.ts +11 -75
  29. package/src/strategies/hide.ts +1 -0
  30. package/src/strategies/kill-frenzy.ts +1 -0
  31. package/src/strategies/kill-lone.ts +1 -0
  32. package/src/strategies/kill-target.ts +1 -0
  33. package/src/strategies/loader.ts +9 -2
  34. package/src/strategies/lone-kill-task.ts +1 -0
  35. package/src/strategies/move-room.ts +1 -0
  36. package/src/strategies/off-route-points.ts +105 -0
  37. package/src/strategies/paradise-fish.ts +1 -0
  38. package/src/strategies/patrol.ts +1 -0
  39. package/src/strategies/report-patrol.ts +1 -0
  40. package/src/strategies/shrimp-memory.ts +1 -0
  41. package/src/strategies/social-task.ts +1 -0
  42. package/src/strategies/task-kill-report.ts +1 -0
  43. package/src/strategies/task-only.ts +1 -0
  44. package/src/strategies/task-report.ts +1 -0
  45. package/src/strategies/types.ts +7 -0
  46. package/src/strategies/warrior-memory.knowledge.md +2 -2
  47. package/src/strategies/warrior-memory.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myclaw163/clawclaw-cli",
3
- "version": "0.6.68",
3
+ "version": "0.6.70",
4
4
  "type": "module",
5
5
  "description": "ClawClaw social deduction game CLI",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  | 取消标记 | `ccl knowledge del player <x>` | 删除该条目 → 回到默认「被怀疑」 |
14
14
  | 清空所有 | `ccl knowledge clear` | 重置本局所有知识 |
15
15
 
16
- **被怀疑(默认,未标记)** = 记忆策略提高警惕:保持距离观察,不会仅凭怀疑主动出刀;带刀虾只有在被同一个被怀疑者持续贴身追击、退无可退时才会自卫先手(无需先标记)。要让某人升级为「见到就追杀/必躲」,把它标 `hostile`。
16
+ **被怀疑(默认,未标记)** = 记忆策略提高警惕:保持距离观察,不会仅凭怀疑主动出刀;带刀虾也只回避,绝不因为贴身追击或退无可退而自卫先手。要让某人升级为「见到就追杀/必躲」,把它标 `hostile`。
17
17
 
18
18
  ## 工作原理
19
19
 
@@ -8,10 +8,12 @@
8
8
  ccl strategy --list
9
9
  ```
10
10
 
11
- `--list` 返回当前实际加载到的策略 `id` 和 `description`,包括内置策略、Hub 策略和本地自定义策略;如果用户用同名策略覆盖官方策略,也以这里显示的结果为准。
11
+ `--list` 返回当前实际加载到的策略 `id`、`name` 和 `description`,包括内置策略、Hub 策略和本地自定义策略;如果用户用同名策略覆盖官方策略,也以这里显示的结果为准。
12
12
 
13
13
  `description` 是策略选择的第一入口。它应说明策略做什么、是否需要参数、参数怎么传,以及是否读取 `ccl knowledge`。
14
14
 
15
+ `name` 是策略的中文代词(如「守尸」「武士虾」),是玩家和你口头沟通时用的称呼。玩家说「切到守尸」时,你据此映射回对应 `id`。`id` 仍是规范选择器,但 `ccl strategy <name>`(中文名)与 `ccl strategy <id>` 等价,两者都能启动。
16
+
15
17
  ## 策略选择流程
16
18
 
17
19
  1. 先确认当前策略:从 `ccl game start` 短通知、`ccl events` 返回的 state / 事件,或最近一次 `ccl strategy <id>` 的启动结果里,看现在正在跑什么策略。
@@ -25,7 +27,7 @@ ccl strategy --info <id>
25
27
  ```
26
28
 
27
29
  6. 如需让当前策略对特定玩家或事实做出反应,使用 `ccl knowledge` 写入判断;如果是整体目标变化,则切换策略。
28
- 7. 启动策略:
30
+ 7. 启动策略(`<id>` 处也可填中文 `name`):
29
31
 
30
32
  ```bash
31
33
  ccl strategy <id> [args...]
@@ -54,4 +56,4 @@ ccl strategy --info <id>
54
56
 
55
57
  ## 能力边界说明
56
58
 
57
- > **策略是有限集合——不要暗示它们无所不能。** `ccl strategy --list` 是当前实际可用策略目录。**当用户要求的玩法超出这些策略能力时,直说——告诉用户当前策略不支持。** 不要拖延、不要假装某个策略能做到、不要沉默忽略请求。然后提供最接近的方案:让现有策略覆盖能做的部分,其余用 `ccl do`(发言/投票/思考)、手动 `ccl strategy` 切换或手动移动来处理——明确指出哪些是策略自动化的、哪些是你手动操作的、哪些确实无法实现。当差距真实存在——请求需要当前策略都不支持的自动化行为时——主动引导用户到 clawclawhub:`ccl hub search` 浏览社区策略,`ccl hub install <type>/<id>` 安装用户选中的。安装后用 `ccl strategy --list` 确认策略可用。如果那里也不适合,建议创建自定义策略:在 `<workspace>/strategies/` 下放 `.ts` / `.js` 文件,导出 `strategy`(id / description / create),从 `clawclaw-cli` 导入辅助函数。主动提出为他们编写——API 和示例见 `ccl strategy -h` 和 `docs/自定义策略.md`。
59
+ > **策略是有限集合——不要暗示它们无所不能。** `ccl strategy --list` 是当前实际可用策略目录。**当用户要求的玩法超出这些策略能力时,直说——告诉用户当前策略不支持。** 不要拖延、不要假装某个策略能做到、不要沉默忽略请求。然后提供最接近的方案:让现有策略覆盖能做的部分,其余用 `ccl do`(发言/投票/思考)、手动 `ccl strategy` 切换或手动移动来处理——明确指出哪些是策略自动化的、哪些是你手动操作的、哪些确实无法实现。当差距真实存在——请求需要当前策略都不支持的自动化行为时——主动引导用户到 clawclawhub:`ccl hub search` 浏览社区策略,`ccl hub install <type>/<id>` 安装用户选中的。安装后用 `ccl strategy --list` 确认策略可用。如果那里也不适合,建议创建自定义策略:在 `<workspace>/strategies/` 下放 `.ts` / `.js` 文件,导出 `strategy`(id / name / description / create),从 `clawclaw-cli` 导入辅助函数。主动提出为他们编写——API 和示例见 `ccl strategy -h` 和 `docs/自定义策略.md`。
@@ -45,6 +45,19 @@ function queueStatus(result: any): string | undefined {
45
45
 
46
46
  const DEFAULT_QUEUE_WAIT_TIMEOUT_SECS = 30;
47
47
 
48
+ function clearCachedGameServerUrl(authStore: AuthStore, profileName?: string): void {
49
+ try {
50
+ authStore.updateGameServerUrl(undefined, profileName);
51
+ } catch {
52
+ // Cache cleanup is best-effort; queue status will discover the current server.
53
+ }
54
+ }
55
+
56
+ function isLeaveGameSuccess(result: any): boolean {
57
+ const data = result?.data ?? result;
58
+ return data?.ok === true && data?.message === 'left_game';
59
+ }
60
+
48
61
  function isPidAlive(pid: number): boolean {
49
62
  if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
50
63
  try { process.kill(pid, 0); return true; } catch { return false; }
@@ -366,6 +379,7 @@ async function runGameStart(opts: { watch: boolean; force?: boolean }): Promise<
366
379
 
367
380
  const stateDir = getProfileStateDir(profile);
368
381
  const feedPath = join(stateDir, 'feed.json');
382
+ clearCachedGameServerUrl(authStore, profile.agentName);
369
383
  const client = GameClient.fromAuth();
370
384
  let eventRuntime: EventRuntime | undefined;
371
385
  let streamAbortController: AbortController | null = null;
@@ -957,6 +971,7 @@ export function createGameCommand(): Command {
957
971
  } catch (err: any) {
958
972
  result = { error: err?.message ?? String(err) };
959
973
  }
974
+ if (isLeaveGameSuccess(result)) clearCachedGameServerUrl(authStore, profile.agentName);
960
975
  const stateDir = getProfileStateDir(profile);
961
976
  endMatch(stateDir);
962
977
  const owner = await stopOwnerWithCommand(stateDir, 'quit');
@@ -109,6 +109,15 @@ describe('strategy command', () => {
109
109
  // knowledge might be null if no .knowledge.md file exists
110
110
  });
111
111
 
112
+ it('should resolve a Chinese name and show its knowledge contract', async () => {
113
+ await run(['--info', '武士虾']);
114
+
115
+ const output = JSON.parse(logs[0]);
116
+ expect(output.id).toBe('warrior-memory');
117
+ expect(output.name).toBe('武士虾');
118
+ expect(output.knowledge).toContain('warrior-memory');
119
+ });
120
+
112
121
  it('should reject non-existent strategies', async () => {
113
122
  await expect(run(['--info', 'non-existent'])).rejects.toThrow('process.exit');
114
123
 
@@ -129,6 +138,7 @@ describe('strategy command', () => {
129
138
  // Check that task-only is in the list
130
139
  const taskOnly = output.strategies.find((s: any) => s.id === 'task-only');
131
140
  expect(taskOnly).toBeDefined();
141
+ expect(taskOnly.name).toBe('纯任务');
132
142
  expect(taskOnly.description).toBeDefined();
133
143
  });
134
144
  });
@@ -31,7 +31,7 @@ export function createStrategyCommand(): Command {
31
31
  Custom strategies:
32
32
  Place .ts or .js files in ${strategiesDir}
33
33
  (or $CLAWCLAW_WORKSPACE_DIR/strategies/)
34
- Each file must export a 'strategy' object with id, description, and create() function.
34
+ Each file must export a 'strategy' object with id, name (中文别名), description, and create() function.
35
35
  Use 'import { ... } from "@myclaw163/clawclaw-cli"' to access Action, GameState, and utilities.
36
36
  See docs/自定义策略.md for full API reference and examples.
37
37
  `;
@@ -49,8 +49,8 @@ Custom strategies:
49
49
  }, null, 2));
50
50
  process.exit(1);
51
51
  }
52
- const knowledge = await getStrategyKnowledgeDoc(opts.info);
53
- console.log(JSON.stringify({ id: opts.info, description: entry.description, knowledge: knowledge ?? null }, null, 2));
52
+ const knowledge = await getStrategyKnowledgeDoc(entry.id);
53
+ console.log(JSON.stringify({ id: entry.id, name: entry.name ?? entry.id, description: entry.description, knowledge: knowledge ?? null }, null, 2));
54
54
  return;
55
55
  }
56
56
 
@@ -87,7 +87,7 @@ Custom strategies:
87
87
  return;
88
88
  }
89
89
  console.log(JSON.stringify({
90
- strategies: entries.map(e => ({ id: e.id, description: e.description })),
90
+ strategies: entries.map(e => ({ id: e.id, name: e.name ?? e.id, description: e.description })),
91
91
  }, null, 2));
92
92
  return;
93
93
  }
@@ -120,7 +120,7 @@ Custom strategies:
120
120
  console.error(JSON.stringify({
121
121
  error: 'unknown_strategy',
122
122
  message: `Unknown strategy '${name}'.`,
123
- available: available.map(e => ({ id: e.id, description: e.description })),
123
+ available: available.map(e => ({ id: e.id, name: e.name ?? e.id, description: e.description })),
124
124
  }, null, 2));
125
125
  process.exit(1);
126
126
  }
@@ -136,7 +136,7 @@ Custom strategies:
136
136
  process.exit(1);
137
137
  }
138
138
 
139
- if (KILL_STRATEGIES.has(name)) {
139
+ if (KILL_STRATEGIES.has(entry.id)) {
140
140
  try {
141
141
  const client = GameClient.fromAuth();
142
142
  await client.discoverGameServer();
@@ -146,8 +146,8 @@ Custom strategies:
146
146
  if (role && !KILL_CAPABLE_ROLES.has(role)) {
147
147
  console.error(JSON.stringify({
148
148
  error: 'role_incompatible',
149
- message: `Strategy '${name}' requires kill ability, but your role '${displayName}' (${role}) cannot kill. Use a non-kill strategy like task-report, patrol, or report-patrol.`,
150
- strategy: name,
149
+ message: `Strategy '${entry.id}' requires kill ability, but your role '${displayName}' (${role}) cannot kill. Use a non-kill strategy like task-report, patrol, or report-patrol.`,
150
+ strategy: entry.id,
151
151
  role,
152
152
  }, null, 2));
153
153
  process.exit(1);
@@ -158,7 +158,7 @@ Custom strategies:
158
158
  const profile = new AuthStore().getActive();
159
159
  const response = profile
160
160
  ? await sendOwnerControlRequest(getProfileStateDir(profile), 'switch_strategy', {
161
- strategy: name,
161
+ strategy: entry.id,
162
162
  args: args.length > 0 ? args : undefined,
163
163
  })
164
164
  : null;
@@ -170,7 +170,8 @@ Custom strategies:
170
170
  process.exit(1);
171
171
  }
172
172
  console.log(JSON.stringify({
173
- message: `Strategy started: ${name}`,
173
+ message: `Strategy started: ${entry.id}`,
174
+ name: entry.name ?? entry.id,
174
175
  description: entry.description,
175
176
  pid: response.pid,
176
177
  }, null, 2));
@@ -21,9 +21,12 @@ describe('classifyEvent + NOTABLE_EVENT_TYPES', () => {
21
21
  'exile', 'speech_skipped', 'meeting_briefing',
22
22
  'speech', 'vote_phase_start', 'game_over',
23
23
  'speech_your_turn', 'vote_speech_phase_ended',
24
+ 'crab_teammates', 'no_exile',
24
25
  ]) {
25
26
  expect(NOTABLE_EVENT_TYPES.has(t)).toBe(true);
26
27
  }
28
+ expect(NOTABLE_EVENT_TYPES.has('vote_cast')).toBe(false);
29
+ expect(NOTABLE_EVENT_TYPES.has('meeting_ended')).toBe(false);
27
30
  });
28
31
 
29
32
  it('derives NOTABLE_EVENT_TYPES from MONITOR_EVENT_CONFIG', () => {
@@ -36,7 +39,10 @@ describe('classifyEvent + NOTABLE_EVENT_TYPES', () => {
36
39
  expect(MONITOR_EVENT_CONFIG.killed.priority).toBe(99);
37
40
  expect(MONITOR_EVENT_CONFIG.speech.priority).toBe(50);
38
41
  expect(MONITOR_EVENT_CONFIG.vote_speech_phase_ended.priority).toBe(50);
42
+ expect(MONITOR_EVENT_CONFIG.crab_teammates.priority).toBe(50);
39
43
  expect(MONITOR_EVENT_CONFIG.vote_speech).toBeUndefined();
44
+ expect(MONITOR_EVENT_CONFIG.vote_cast).toBeUndefined();
45
+ expect(MONITOR_EVENT_CONFIG.meeting_ended).toBeUndefined();
40
46
  });
41
47
 
42
48
  it('classifyEvent returns notable=true for known types', () => {
@@ -64,6 +70,11 @@ describe('classifyEvent + NOTABLE_EVENT_TYPES', () => {
64
70
  expect(cls.notable).toBe(true);
65
71
  });
66
72
 
73
+ it('does not wake the monitor for private vote_cast or meeting_ended', () => {
74
+ expect(classifyEvent({ type: 'vote_cast', tick: 1, actor_name: 'me' }, 'me').notable).toBe(false);
75
+ expect(classifyEvent({ type: 'meeting_ended', tick: 2 }, 'me').notable).toBe(false);
76
+ });
77
+
67
78
  it('classifyEvent marks speech_skipped as notable', () => {
68
79
  expect(
69
80
  classifyEvent({ type: 'speech_skipped', tick: 1, actor_name: 'me' }, 'me').notable,
@@ -65,6 +65,7 @@ export const MONITOR_EVENT_CONFIG: Record<string, MonitorEventConfig> = {
65
65
  vote_speech_phase_ended: { priority: 50 },
66
66
  death_speech: { priority: 50 },
67
67
  wandering_speech: { priority: 50 },
68
+ crab_teammates: { priority: 50 },
68
69
 
69
70
  meeting_briefing: { priority: 50 },
70
71
  speech: { priority: 50 },
@@ -72,8 +73,6 @@ export const MONITOR_EVENT_CONFIG: Record<string, MonitorEventConfig> = {
72
73
  speech_your_turn: { priority: 99 },
73
74
 
74
75
  vote_phase_start: { priority: 99 },
75
- vote_cast: { priority: 50 },
76
- meeting_ended: { priority: 50 },
77
76
 
78
77
  game_over: { priority: 50 },
79
78
  role_assigned: { priority: 50 },
@@ -149,7 +148,7 @@ interface RouteRule {
149
148
  const ROUTING_RULES: RouteRule[] = [
150
149
  { reason: 'speech_your_turn', match: (t) => t.includes('speech_your_turn'), nextStep: 'It is YOUR turn to speak. Submit `ccl do -s "<draft>"` immediately. Server skips you after 45s.' },
151
150
  { reason: 'role_assigned', match: (t) => t.includes('role_assigned'), nextStep: 'Tell user your role, faction, win condition, and first plan.' },
152
- { reason: 'vote_cast', match: (t) => t.includes('vote_cast'), nextStep: 'A player just cast their vote. Read events[] to track who voted. No `ccl` action needed cast your own vote immediately when vote phase starts.' },
151
+ { reason: 'crab_teammates', match: (t) => t.includes('crab_teammates'), nextStep: 'Crab teammate list is available. Keep teammate identities private, coordinate cover stories, and avoid voting or killing into them.' },
153
152
  { reason: 'match_start', match: (t) => t.includes('match_start'), nextStep: 'Matchmaking queue entered. The game runtime is live and the stream is attached. Chat with the user while waiting for allocation.' },
154
153
  { reason: 'match_waiting', match: (t) => t.includes('match_waiting'), nextStep: 'Still in queue (see `events[].waited_secs`). Keep chatting with the user; no tactical action required.' },
155
154
  { reason: 'match_timeout', match: (t) => t.includes('match_timeout'), nextStep: `Cumulative wait reached ${Math.round(DEFAULT_MATCH_TIMEOUT_MS / 60_000)} min (see \`events[].waited_secs\`). The stream will exit — discuss with the user: launch a fresh \`ccl game start\` to retry, or call it a session.` },
@@ -39,6 +39,21 @@ describe('AuthStore TTS config', () => {
39
39
  expect(raw.profiles['lobster-1'].tts.defaultVoice).toBe('male-qn-qingse');
40
40
  });
41
41
 
42
+ it('clears cached game server URL when set to undefined', () => {
43
+ const store = new AuthStore(authFile);
44
+ store.addProfile({
45
+ agentName: 'lobster-1',
46
+ apiKey: 'claw_1',
47
+ serverUrl: 'https://example.com',
48
+ gameServerUrl: 'https://example.com/gs/old-game-server',
49
+ });
50
+
51
+ store.updateGameServerUrl(undefined);
52
+
53
+ const raw = JSON.parse(readFileSync(authFile, 'utf8'));
54
+ expect(raw.profiles['lobster-1'].gameServerUrl).toBeUndefined();
55
+ });
56
+
42
57
  it('migrates legacy neteaseTtsKey to each profile tts keys', () => {
43
58
  writeFileSync(authFile, JSON.stringify({
44
59
  activeProfile: 'lobster-1',
@@ -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();