@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.
- package/bin/clawclaw-cli.mjs +3 -3
- package/package.json +1 -1
- package/scripts/sync-bundled-skill.mjs +1 -1
- package/skills/clawclaw/references/COMMANDS.md +4 -4
- package/skills/clawclaw/references/KNOWLEDGE.md +14 -12
- package/src/commands/config.ts +30 -30
- package/src/commands/game.ts +15 -0
- package/src/commands/knowledge.test.ts +4 -10
- package/src/commands/knowledge.ts +10 -39
- package/src/commands/setup/hermes.test.ts +96 -96
- package/src/commands/setup/hermes.ts +76 -76
- package/src/commands/setup/index.ts +13 -13
- package/src/commands/setup/openclaw.test.ts +114 -114
- package/src/commands/setup/openclaw.ts +147 -147
- 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/lib/host-config-patcher.test.ts +130 -130
- package/src/lib/host-config-patcher.ts +151 -151
- package/src/lib/hub-reminder.ts +19 -19
- package/src/lib/knowledge-store.test.ts +28 -38
- package/src/lib/knowledge-store.ts +52 -57
- 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 +2 -3
- package/src/sdk/types.ts +2 -0
- package/src/strategies/avoid-players.knowledge.md +7 -8
- package/src/strategies/avoid-players.ts +1 -1
- package/src/strategies/corpse-patrol.ts +1 -1
- package/src/strategies/game-utils.test.ts +53 -1
- package/src/strategies/game-utils.ts +92 -28
- package/src/strategies/goals/avoid-players-top.ts +3 -3
- package/src/strategies/goals/corpse-patrol-top.ts +23 -1
- package/src/strategies/goals/crab-octopus-reflexes.ts +11 -3
- package/src/strategies/goals/keep-away-goal.ts +9 -5
- package/src/strategies/goals/leaf-goal.ts +2 -0
- package/src/strategies/goals/lone-kill-task-top.ts +58 -11
- package/src/strategies/goals/normal-shrimp-top.ts +11 -11
- package/src/strategies/goals/paradise-fish-top.ts +32 -15
- package/src/strategies/goals/warrior-shrimp-top.test.ts +4 -3
- package/src/strategies/goals/warrior-shrimp-top.ts +62 -295
- package/src/strategies/hide-spots.ts +11 -75
- package/src/strategies/kill-lone.knowledge.md +6 -9
- package/src/strategies/lone-kill-task.ts +1 -1
- package/src/strategies/off-route-points.ts +105 -0
- package/src/strategies/paradise-fish.knowledge.md +7 -8
- package/src/strategies/paradise-fish.ts +1 -1
- package/src/strategies/shrimp-memory.knowledge.md +7 -8
- package/src/strategies/shrimp-memory.ts +1 -1
- package/src/strategies/warrior-memory.knowledge.md +9 -10
- 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
|
-
* - 价值中立:只存推断 +
|
|
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>; // 玩家策略主标记:
|
|
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
|
-
|
|
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
|
|
69
|
-
hasTag(ref: KnowledgeRef, tag: string
|
|
70
|
-
/** 按 type / tag / factKey
|
|
71
|
-
query(q: { type?: string; tag?: string; factKey?: string
|
|
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
|
|
73
|
+
selectorsWithTag(type: string, tag: string): string[];
|
|
74
74
|
// —— player 便捷语法糖 ——
|
|
75
75
|
/** 最近一次 role 事实。身份事实与策略 mark 完全解耦。 */
|
|
76
76
|
roleOf(p: PlayerRef): string | undefined;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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] = {
|
|
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
|
|
306
|
-
|
|
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
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
354
|
-
.filter((s) => s.type === type && tagActive(s, tag
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -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.
|
|
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;
|