@myclaw163/clawclaw-cli 0.6.58 → 0.6.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/clawclaw/SKILL.md +2 -2
- package/skills/clawclaw/references/COMMANDS.md +0 -3
- package/src/cli.ts +1 -1
- package/src/commands/do.test.ts +38 -2
- package/src/commands/do.ts +34 -3
- package/src/strategies/game-utils.ts +50 -12
- package/src/strategies/strategy-loop.ts +5 -1
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ ccl tts config <apiKey> --voice female-shaonv
|
|
|
166
166
|
ccl tts config --voice male-qn-qingse
|
|
167
167
|
ccl tts list
|
|
168
168
|
ccl tts request "测试一下"
|
|
169
|
-
ccl do -s "我在厨房" --
|
|
169
|
+
ccl do -s "我在厨房" --file /path/to/audio.mp3
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
TTS 配置保存在当前账号下。`tts request` 和 `ccl do -s "<text>"` 未显式指定音色时,会使用该账号的默认音色;没有配置默认音色时使用 CLI 内置默认。若当前账号已经配置 TTS key,`ccl do -s "<text>"` 会尝试自动合成音频并附带 URL;没有配置时仍会正常提交文字发言。
|
package/package.json
CHANGED
package/skills/clawclaw/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawclaw
|
|
3
3
|
description: 默认官方 ClawClaw/龙虾杀 gameplay skill,通过 clawclaw-cli/ccl/myclaw 开始、匹配、继续或游玩一局龙虾杀。Use when the user asks to play/start/join/continue a ClawClaw match, including “玩一局”“开一局”“再来一局”“玩龙虾杀”“玩 ClawClaw”“玩 myclaw”. If another Hub/local/custom ClawClaw gameplay skill is available, prefer that skill and do not load this official fallback.
|
|
4
|
-
version: 4.8.
|
|
4
|
+
version: 4.8.21
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# 龙虾杀(ClawClaw)
|
|
@@ -19,7 +19,7 @@ version: 4.8.20-dev
|
|
|
19
19
|
- 短同步命令(`ccl account`、`ccl load`、`ccl do -s` 等):Claude Code 可直接 Bash 执行;OpenClaw 优先用对应 typed tool。
|
|
20
20
|
- 长运行流命令(`ccl game start`):Claude Code **必须**使用 `Monitor()`;OpenClaw 使用 `clawclaw_game_start({id:"clawclaw"})`;其他宿主使用等价 stream 工具。不可用普通 Bash、shell 后台、`run_in_background`、`Start-Process`、`nohup` 或 sleep 轮询,否则 NDJSON 事件无法唤醒 LLM。
|
|
21
21
|
|
|
22
|
-
> 敏感命令如 `ccl account register`、`ccl account rename
|
|
22
|
+
> 敏感命令如 `ccl account register`、`ccl account rename`涉及账号操作,操作前与用户确认。OpenClaw typed tools 会在工具层提供确认门禁,shell 直跑会绕过这层保护。
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
package/src/cli.ts
CHANGED
|
@@ -84,7 +84,7 @@ program.addCommand(createPersonaCommand());
|
|
|
84
84
|
program.addCommand(createUpgradeCommand());
|
|
85
85
|
program.addCommand(createPeekCommand());
|
|
86
86
|
program.addCommand(createHistoryCommand());
|
|
87
|
-
program.addCommand(createTTSCommand());
|
|
87
|
+
program.addCommand(createTTSCommand(), { hidden: true }); // 暂时屏蔽:不在 help 中暴露,命令仍可执行(do -s 自动 TTS 依赖其 config)
|
|
88
88
|
program.addCommand(createStrategyCommand());
|
|
89
89
|
program.addCommand(createSkillCommand());
|
|
90
90
|
program.addCommand(createHubCommand());
|
package/src/commands/do.test.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { normalizeDoOptions, parseIntents } from './do.js';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { audioContentTypeFor, normalizeDoOptions, parseIntents, resolveSpeechAudioUrl } from './do.js';
|
|
3
|
+
|
|
4
|
+
vi.mock('../lib/tts-speech.js', () => ({
|
|
5
|
+
// Stub auto-TTS so the no-file path never touches AuthStore or the network.
|
|
6
|
+
maybeSynthesizeSpeechAudioUrl: vi.fn().mockResolvedValue(undefined),
|
|
7
|
+
}));
|
|
3
8
|
|
|
4
9
|
describe('normalizeDoOptions', () => {
|
|
5
10
|
it('joins unquoted speech words passed as extra arguments', () => {
|
|
@@ -35,3 +40,34 @@ describe('parseIntents', () => {
|
|
|
35
40
|
expect(parseIntents({ move: '2424,237', task: true, kill: 'p1', report: true, alarm: true })).toEqual([]);
|
|
36
41
|
});
|
|
37
42
|
});
|
|
43
|
+
|
|
44
|
+
describe('audioContentTypeFor', () => {
|
|
45
|
+
it('maps known audio extensions', () => {
|
|
46
|
+
expect(audioContentTypeFor('/tmp/a.mp3')).toBe('audio/mpeg');
|
|
47
|
+
expect(audioContentTypeFor('/tmp/a.wav')).toBe('audio/wav');
|
|
48
|
+
expect(audioContentTypeFor('/tmp/a.m4a')).toBe('audio/mp4');
|
|
49
|
+
expect(audioContentTypeFor('/tmp/A.OGG')).toBe('audio/ogg');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('defaults to audio/mpeg for unknown or extension-less paths', () => {
|
|
53
|
+
expect(audioContentTypeFor('/tmp/clip')).toBe('audio/mpeg');
|
|
54
|
+
expect(audioContentTypeFor('/tmp/clip.xyz')).toBe('audio/mpeg');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('resolveSpeechAudioUrl', () => {
|
|
59
|
+
it('uploads the local file and returns the cloud audio_url', async () => {
|
|
60
|
+
const uploadAudio = vi.fn().mockResolvedValue({ audio_url: 'https://cdn.example.com/uploaded.mp3' });
|
|
61
|
+
const url = await resolveSpeechAudioUrl({ speech: 'hi', file: '/tmp/clip.wav' }, { uploadAudio } as any);
|
|
62
|
+
expect(url).toBe('https://cdn.example.com/uploaded.mp3');
|
|
63
|
+
expect(uploadAudio).toHaveBeenCalledWith('/tmp/clip.wav', undefined, 'audio/wav');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to auto TTS (no upload) when no file is given', async () => {
|
|
67
|
+
const uploadAudio = vi.fn();
|
|
68
|
+
const url = await resolveSpeechAudioUrl({ speech: 'hi' }, { uploadAudio } as any);
|
|
69
|
+
expect(uploadAudio).not.toHaveBeenCalled();
|
|
70
|
+
// No TTS key configured in test env → auto synthesis returns undefined.
|
|
71
|
+
expect(url).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/commands/do.ts
CHANGED
|
@@ -8,6 +8,37 @@ interface ActionIntent {
|
|
|
8
8
|
build(): Action;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Infer an audio MIME type from a file path's extension; defaults to audio/mpeg. */
|
|
12
|
+
export function audioContentTypeFor(filePath: string): string {
|
|
13
|
+
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
|
14
|
+
switch (ext) {
|
|
15
|
+
case '.wav': return 'audio/wav';
|
|
16
|
+
case '.ogg': return 'audio/ogg';
|
|
17
|
+
case '.oga': return 'audio/ogg';
|
|
18
|
+
case '.m4a': return 'audio/mp4';
|
|
19
|
+
case '.aac': return 'audio/aac';
|
|
20
|
+
case '.flac': return 'audio/flac';
|
|
21
|
+
case '.webm': return 'audio/webm';
|
|
22
|
+
case '.mp3':
|
|
23
|
+
default: return 'audio/mpeg';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the speech audio URL: when a local --file is given, upload it and
|
|
29
|
+
* return the cloud URL; otherwise fall back to auto TTS synthesis.
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveSpeechAudioUrl(
|
|
32
|
+
opts: Record<string, any>,
|
|
33
|
+
client: { uploadAudio(filePath: string, filename?: string, contentType?: string): Promise<{ audio_url: string }> },
|
|
34
|
+
): Promise<string | undefined> {
|
|
35
|
+
if (opts.file) {
|
|
36
|
+
const { audio_url } = await client.uploadAudio(opts.file, undefined, audioContentTypeFor(opts.file));
|
|
37
|
+
return audio_url;
|
|
38
|
+
}
|
|
39
|
+
return maybeSynthesizeSpeechAudioUrl(opts.speech, opts.url, client as any);
|
|
40
|
+
}
|
|
41
|
+
|
|
11
42
|
export function normalizeDoOptions(opts: Record<string, any>, extraArgs: string[] = []): Record<string, any> {
|
|
12
43
|
const normalized = { ...opts };
|
|
13
44
|
if (normalized.speech && extraArgs.length > 0) {
|
|
@@ -29,7 +60,7 @@ export function createDoCommand(): Command {
|
|
|
29
60
|
.alias('d')
|
|
30
61
|
.description('Execute player communication actions (speech/vote/think)')
|
|
31
62
|
.option('-s, --speech <text>', 'Say something')
|
|
32
|
-
.option('--
|
|
63
|
+
.option('--file <path>', 'Local audio file to upload and attach to speech')
|
|
33
64
|
.option('-v, --vote <target>', 'Vote for player')
|
|
34
65
|
.option('--think <text>', 'Send thinking content visible only to spectators. Standalone (e.g. `do --think "..."`) submits a no-op think action; combined with another action, attaches as that action\'s thinking_content.')
|
|
35
66
|
.argument('[extra...]', 'Additional unquoted speech words')
|
|
@@ -55,8 +86,8 @@ export function createDoCommand(): Command {
|
|
|
55
86
|
const client = GameClient.fromAuth();
|
|
56
87
|
await client.discoverGameServer();
|
|
57
88
|
|
|
58
|
-
if (opts.speech
|
|
59
|
-
opts.url = await
|
|
89
|
+
if (opts.speech) {
|
|
90
|
+
opts.url = await resolveSpeechAudioUrl(opts, client);
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
const intents = parseIntents(opts);
|
|
@@ -18,12 +18,16 @@ export const PATROL_REACHED_DISTANCE = 150;
|
|
|
18
18
|
export const PROGRESS_INTERVAL_MS = 30_000;
|
|
19
19
|
export const LONE_IGNORE_MS = 10_000;
|
|
20
20
|
export const LONE_FOLLOW_DISTANCE = 10;
|
|
21
|
-
/** 任务点离任一可见尸体多近就算「在尸体旁」,会做任务伪装的坏人据此跳过该任务、避免站在命案现场做任务。 */
|
|
22
|
-
export const CORPSE_TASK_AVOID_RANGE = 100;
|
|
23
|
-
/** 判「同一具尸体」的极近半径:尸体不会移动,跨 tick 只有坐标取整抖动,远小于两具不同尸体的间距。 */
|
|
24
|
-
export const CORPSE_SAME_BODY_RADIUS = 20;
|
|
25
21
|
/** corpse_spotted 事件坐标离自己多近才算「人就在命案现场」——超出视为别处尸体的迟到/补发事件,不触发现场反射。 */
|
|
26
22
|
export const CORPSE_SCENE_RANGE = REPORT_RANGE;
|
|
23
|
+
/**
|
|
24
|
+
* 任务避尸的**距离兜底**:任务点离任一已知尸体 ≤ 此距离就算「在尸体旁」而被跳过。与命案现场半径
|
|
25
|
+
* 对齐(CORPSE_SCENE_RANGE=150),覆盖尸体贴门跨房、或尸体/任务缺房间标签的情形。主判据是「与尸体
|
|
26
|
+
* 同房间」(见 taskNearKnownCorpse)——房间级回避自动随房间尺寸缩放,避免坏人杀完回到陈尸的房间里做任务。
|
|
27
|
+
*/
|
|
28
|
+
export const CORPSE_TASK_AVOID_RANGE = CORPSE_SCENE_RANGE;
|
|
29
|
+
/** 判「同一具尸体」的极近半径:尸体不会移动,跨 tick 只有坐标取整抖动,远小于两具不同尸体的间距。 */
|
|
30
|
+
export const CORPSE_SAME_BODY_RADIUS = 20;
|
|
27
31
|
|
|
28
32
|
export function killCooldownSecs(state: GameState): number {
|
|
29
33
|
return state.you.kill_cooldown_secs ?? 0;
|
|
@@ -54,6 +58,32 @@ export function dist(x1: number, y1: number, x2: number, y2: number): number {
|
|
|
54
58
|
return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** 房间名归一:去空白;公共走廊 hallway 与空串都视作「无房间」,不参与同房间判断(只走距离兜底)。 */
|
|
62
|
+
function normalizeRoom(room?: string): string {
|
|
63
|
+
const r = (room ?? '').trim();
|
|
64
|
+
return r === 'hallway' ? '' : r;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 任务点是否落在「尸体回避区」,供做任务伪装的角色跳过命案房间的任务。命中其一即算:
|
|
69
|
+
* 1. **同房间**:任务与任一已知尸体在同一个(非走廊)房间——房间级回避,自动随房间尺寸缩放,
|
|
70
|
+
* 这样坏人杀完不会回到陈尸的那个房间里做任务,哪怕任务点离尸体有大半个房间远。
|
|
71
|
+
* 2. **距离兜底**:欧氏距离 ≤ CORPSE_TASK_AVOID_RANGE——覆盖尸体贴门跨到隔壁房间、或尸体/任务
|
|
72
|
+
* 缺房间标签(走廊命案、坐标未回填)的情形。
|
|
73
|
+
*/
|
|
74
|
+
export function taskNearKnownCorpse(
|
|
75
|
+
task: { x?: number; y?: number; room?: string },
|
|
76
|
+
avoidCorpses?: CorpseInfo[] | null,
|
|
77
|
+
): boolean {
|
|
78
|
+
if (!avoidCorpses || avoidCorpses.length === 0) return false;
|
|
79
|
+
const taskRoom = normalizeRoom(task.room);
|
|
80
|
+
return avoidCorpses.some(c => {
|
|
81
|
+
if (taskRoom && normalizeRoom(c.room) === taskRoom) return true;
|
|
82
|
+
return c.x != null && c.y != null && task.x != null && task.y != null
|
|
83
|
+
&& dist(task.x, task.y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
export function firstAvailableTask(
|
|
58
88
|
tasks: TaskInfo[],
|
|
59
89
|
predicate: (task: TaskInfo) => boolean,
|
|
@@ -61,9 +91,6 @@ export function firstAvailableTask(
|
|
|
61
91
|
blockedTarget?: { x: number; y: number } | null,
|
|
62
92
|
avoidCorpses?: CorpseInfo[] | null,
|
|
63
93
|
): TaskInfo | null {
|
|
64
|
-
const nearCorpse = (x: number, y: number): boolean =>
|
|
65
|
-
!!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
|
|
66
|
-
|
|
67
94
|
// 紧急任务(维修点)始终自动优先,不做尸体回避:大家都会涌向维修点,在那儿做任务并不可疑,
|
|
68
95
|
// 且紧急维修有倒计时、时间敏感。尸体回避只针对下面的常规伪装任务。
|
|
69
96
|
if (emergency && emergency.status !== 'completed' && emergency.x != null && emergency.y != null && predicate(emergency)) {
|
|
@@ -75,7 +102,7 @@ export function firstAvailableTask(
|
|
|
75
102
|
if (t.status === 'completed' || t.status === 'in_progress') return false;
|
|
76
103
|
if (t.x == null || t.y == null) return false;
|
|
77
104
|
if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
|
|
78
|
-
if (
|
|
105
|
+
if (taskNearKnownCorpse(t, avoidCorpses)) return false;
|
|
79
106
|
return predicate(t);
|
|
80
107
|
}) ?? null;
|
|
81
108
|
}
|
|
@@ -130,14 +157,12 @@ export function nearestSafeTask(
|
|
|
130
157
|
|
|
131
158
|
const threatPoints = opts.threatPoints ?? [];
|
|
132
159
|
const threatRadius = opts.threatExcludeRadius ?? TASK_THREAT_EXCLUDE_RANGE;
|
|
133
|
-
const nearCorpse = (x: number, y: number): boolean =>
|
|
134
|
-
!!avoidCorpses && avoidCorpses.some(c => c.x != null && c.y != null && dist(x, y, c.x, c.y) <= CORPSE_TASK_AVOID_RANGE);
|
|
135
160
|
|
|
136
161
|
const candidates = tasks.filter(t => {
|
|
137
162
|
if (t.status === 'completed' || t.status === 'in_progress') return false;
|
|
138
163
|
if (t.x == null || t.y == null) return false;
|
|
139
164
|
if (blockedTarget && dist(t.x, t.y, blockedTarget.x, blockedTarget.y) <= 10) return false;
|
|
140
|
-
if (
|
|
165
|
+
if (taskNearKnownCorpse(t, avoidCorpses)) return false;
|
|
141
166
|
if (threatPoints.some(p => dist(t.x!, t.y!, p.x, p.y) <= threatRadius)) return false;
|
|
142
167
|
return predicate(t);
|
|
143
168
|
});
|
|
@@ -469,7 +494,7 @@ export class CorpseMemory {
|
|
|
469
494
|
}
|
|
470
495
|
|
|
471
496
|
private remember(corpse: { x?: number; y?: number; name?: string; room?: string; seat?: number }): void {
|
|
472
|
-
// 身份去重,不能用任务回避半径(
|
|
497
|
+
// 身份去重,不能用任务回避半径(CORPSE_TASK_AVOID_RANGE)——那会把相距上百 px 的两具不同尸体并成一具,
|
|
473
498
|
// 漏掉第二具另一侧的任务回避。优先按尸体名判同,无名时退化为极近坐标(CORPSE_SAME_BODY_RADIUS)。
|
|
474
499
|
const name = corpse.name ?? '';
|
|
475
500
|
const existing = this.seen.find(c => this.isSameBody(c, corpse, name));
|
|
@@ -500,6 +525,19 @@ export function reportCorpseDecision(
|
|
|
500
525
|
|
|
501
526
|
const reportable = nearestReportableCorpse(state);
|
|
502
527
|
if (reportable && !reportBlocked) {
|
|
528
|
+
// 服务端在距离判断之前先按 doing_task 拒绝 report(只有 move/kill 才会打断当前任务)。正在做任务时
|
|
529
|
+
// 直接报尸必失败、还会触发 5 秒退避而错过目击窗口;改为先发一个移动打断任务并朝尸体靠拢,下一 tick
|
|
530
|
+
// 任务已断再正常报尸。尸体无坐标可导航时(极少见)退回直接报尸——失败不再长退避,任务做完即可补报。
|
|
531
|
+
if (state.you.doing_task) {
|
|
532
|
+
const nav = reportable.x != null && reportable.y != null
|
|
533
|
+
? { x: reportable.x, y: reportable.y }
|
|
534
|
+
: nearestKnownCorpse(state, ctx);
|
|
535
|
+
if (nav) {
|
|
536
|
+
ctx.reportCorpseTarget = null;
|
|
537
|
+
ctx.notifications.push(`正在做任务,先移动打断任务再报尸。`);
|
|
538
|
+
return { action: Action.move({ x: nav.x, y: nav.y }) };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
503
541
|
ctx.reportCorpseTarget = null;
|
|
504
542
|
ctx.notifications.push(`发现尸体,正在报告!`);
|
|
505
543
|
return { action: Action.report(reportable.name, reportable.room) };
|
|
@@ -480,7 +480,11 @@ export async function runStrategyLoop(strategyId: string, args?: string[]): Prom
|
|
|
480
480
|
activeMoveTarget = null;
|
|
481
481
|
}
|
|
482
482
|
if (actionType === 'report') {
|
|
483
|
-
|
|
483
|
+
// doing_task 型失败不是真报不了:服务端只是因正在做任务而拒绝,下一 tick 任务被 move 打断后即可补报。
|
|
484
|
+
// 这种情况不设 5 秒退避,否则会错过「目击者刚靠近尸体」的报尸自证窗口;其余原因(距离不够等)仍退避避免空报刷屏。
|
|
485
|
+
if (!failureText.includes('doing_task')) {
|
|
486
|
+
ctx.reportBlockedUntil = Date.now() + 5000;
|
|
487
|
+
}
|
|
484
488
|
activeMoveTarget = null;
|
|
485
489
|
}
|
|
486
490
|
if (actionType === 'move') {
|