@myclaw163/clawclaw-cli 0.6.68 → 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/package.json +1 -1
- package/skills/clawclaw/references/KNOWLEDGE.md +1 -1
- package/src/commands/game.ts +15 -0
- 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/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/off-route-points.ts +105 -0
- package/src/strategies/warrior-memory.knowledge.md +2 -2
- package/src/strategies/warrior-memory.ts +1 -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
|
|
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');
|
|
@@ -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();
|
|
@@ -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;
|
package/src/sdk/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export type {
|
|
|
52
52
|
// Game utilities (for user strategies)
|
|
53
53
|
export {
|
|
54
54
|
dist, firstAvailableTask, nearestKnownCorpse, nearestKnownCorpseNav, hasKnownCorpse, corpseAtScene, nearestReportableCorpse,
|
|
55
|
-
emergencyRushDecision, EMERGENCY_RUSH_ALIVE_THRESHOLD,
|
|
55
|
+
emergencyRushDecision, activeEmergencyRushTask, killYieldsToEmergencyRepair, EMERGENCY_RUSH_ALIVE_THRESHOLD,
|
|
56
56
|
safePatrolStep,
|
|
57
57
|
nonTeammatesVisible, matchesTarget, isTargetAlive,
|
|
58
58
|
nearestVisibleTarget, pursueVisibleTarget,
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { GameState, TaskInfo } from '../sdk/types.js';
|
|
3
3
|
import type { StrategyContext } from './types.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
CorpseMemory,
|
|
6
|
+
emergencyRushDecision,
|
|
7
|
+
firstAvailableTask,
|
|
8
|
+
killYieldsToEmergencyRepair,
|
|
9
|
+
nearestSafeTask,
|
|
10
|
+
PatrolState,
|
|
11
|
+
safePatrolStep,
|
|
12
|
+
} from './game-utils.js';
|
|
5
13
|
|
|
6
14
|
function state(overrides: Partial<GameState> = {}): GameState {
|
|
7
15
|
return {
|
|
@@ -163,6 +171,50 @@ describe('safePatrolStep', () => {
|
|
|
163
171
|
});
|
|
164
172
|
});
|
|
165
173
|
|
|
174
|
+
describe('emergency repair kill yielding', () => {
|
|
175
|
+
function emergencyContext(overrides: Partial<StrategyContext> = {}): StrategyContext {
|
|
176
|
+
return context({
|
|
177
|
+
emergency: {
|
|
178
|
+
task_id: 'repair',
|
|
179
|
+
task_name: 'repair',
|
|
180
|
+
room: 'control',
|
|
181
|
+
status: 'emergency',
|
|
182
|
+
x: 100,
|
|
183
|
+
y: 0,
|
|
184
|
+
remaining_secs: 45,
|
|
185
|
+
},
|
|
186
|
+
...overrides,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('yields near the repair task at one player above the rush threshold', () => {
|
|
191
|
+
const repairState = state({ alive_count: 7 });
|
|
192
|
+
const ctx = emergencyContext();
|
|
193
|
+
|
|
194
|
+
expect(killYieldsToEmergencyRepair(
|
|
195
|
+
repairState,
|
|
196
|
+
ctx,
|
|
197
|
+
{ room: 'control', x: 130, y: 0 },
|
|
198
|
+
)).toBe(true);
|
|
199
|
+
expect(emergencyRushDecision(repairState, ctx)).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('does not yield before the one-player buffer', () => {
|
|
203
|
+
expect(killYieldsToEmergencyRepair(
|
|
204
|
+
state({ alive_count: 8 }),
|
|
205
|
+
emergencyContext(),
|
|
206
|
+
{ room: 'control', x: 130, y: 0 },
|
|
207
|
+
)).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('still rushes the repair task at the endgame threshold', () => {
|
|
211
|
+
const decision = emergencyRushDecision(state({ alive_count: 6 }), emergencyContext());
|
|
212
|
+
|
|
213
|
+
expect(decision?.action.type).toBe('move');
|
|
214
|
+
expect(decision?.action.payload).toMatchObject({ target_x: 100, target_y: 0 });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
166
218
|
describe('nearestSafeTask', () => {
|
|
167
219
|
// 威胁贴在起点旁(视野内 ~52px),唯一安全任务远在反方向、离威胁 556px(> 端点
|
|
168
220
|
// 排除半径 500)。修复前 pathNearAny 会因起点采样紧贴威胁把整条「背向威胁」的路线判危,
|