@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.
- package/package.json +1 -1
- package/skills/clawclaw/references/KNOWLEDGE.md +1 -1
- package/skills/clawclaw/references/STRATEGIES.md +5 -3
- package/src/commands/game.ts +15 -0
- package/src/commands/strategy.test.ts +10 -0
- package/src/commands/strategy.ts +11 -10
- package/src/commands/watch.test.ts +11 -0
- package/src/commands/watch.ts +2 -3
- package/src/lib/auth.test.ts +15 -0
- package/src/pipeline/event-format.test.ts +82 -2
- package/src/pipeline/event-format.ts +114 -5
- package/src/pipeline/event-hints.ts +20 -3
- package/src/runtime/event-daemon.test.ts +34 -0
- package/src/runtime/event-daemon.ts +51 -3
- package/src/sdk/index.ts +1 -1
- package/src/strategies/avoid-lone.ts +1 -0
- package/src/strategies/avoid-players.ts +1 -0
- package/src/strategies/corpse-patrol.ts +1 -0
- package/src/strategies/crab-sabotage.ts +1 -0
- package/src/strategies/custom-module.test.ts +1 -0
- package/src/strategies/find-player.ts +1 -0
- package/src/strategies/game-utils.test.ts +53 -1
- package/src/strategies/game-utils.ts +69 -17
- package/src/strategies/goals/crab-octopus-reflexes.ts +11 -3
- package/src/strategies/goals/keep-away-goal.ts +9 -5
- package/src/strategies/goals/lone-kill-task-top.ts +25 -9
- package/src/strategies/goals/warrior-shrimp-top.ts +13 -306
- package/src/strategies/hide-spots.ts +11 -75
- package/src/strategies/hide.ts +1 -0
- package/src/strategies/kill-frenzy.ts +1 -0
- package/src/strategies/kill-lone.ts +1 -0
- package/src/strategies/kill-target.ts +1 -0
- package/src/strategies/loader.ts +9 -2
- package/src/strategies/lone-kill-task.ts +1 -0
- package/src/strategies/move-room.ts +1 -0
- package/src/strategies/off-route-points.ts +105 -0
- package/src/strategies/paradise-fish.ts +1 -0
- package/src/strategies/patrol.ts +1 -0
- package/src/strategies/report-patrol.ts +1 -0
- package/src/strategies/shrimp-memory.ts +1 -0
- package/src/strategies/social-task.ts +1 -0
- package/src/strategies/task-kill-report.ts +1 -0
- package/src/strategies/task-only.ts +1 -0
- package/src/strategies/task-report.ts +1 -0
- package/src/strategies/types.ts +7 -0
- package/src/strategies/warrior-memory.knowledge.md +2 -2
- package/src/strategies/warrior-memory.ts +2 -1
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| 取消标记 | `ccl knowledge del player <x>` | 删除该条目 → 回到默认「被怀疑」 |
|
|
14
14
|
| 清空所有 | `ccl knowledge clear` | 重置本局所有知识 |
|
|
15
15
|
|
|
16
|
-
**被怀疑(默认,未标记)** =
|
|
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`。
|
package/src/commands/game.ts
CHANGED
|
@@ -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
|
});
|
package/src/commands/strategy.ts
CHANGED
|
@@ -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(
|
|
53
|
-
console.log(JSON.stringify({ id:
|
|
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(
|
|
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 '${
|
|
150
|
-
strategy:
|
|
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:
|
|
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: ${
|
|
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,
|
package/src/commands/watch.ts
CHANGED
|
@@ -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: '
|
|
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.` },
|
package/src/lib/auth.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () => '
|
|
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) => `${
|
|
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();
|