@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myclaw163/clawclaw-cli",
3
- "version": "0.6.68",
3
+ "version": "0.6.69",
4
4
  "type": "module",
5
5
  "description": "ClawClaw social deduction game CLI",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  | 取消标记 | `ccl knowledge del player <x>` | 删除该条目 → 回到默认「被怀疑」 |
14
14
  | 清空所有 | `ccl knowledge clear` | 重置本局所有知识 |
15
15
 
16
- **被怀疑(默认,未标记)** = 记忆策略提高警惕:保持距离观察,不会仅凭怀疑主动出刀;带刀虾只有在被同一个被怀疑者持续贴身追击、退无可退时才会自卫先手(无需先标记)。要让某人升级为「见到就追杀/必躲」,把它标 `hostile`。
16
+ **被怀疑(默认,未标记)** = 记忆策略提高警惕:保持距离观察,不会仅凭怀疑主动出刀;带刀虾也只回避,绝不因为贴身追击或退无可退而自卫先手。要让某人升级为「见到就追杀/必躲」,把它标 `hostile`。
17
17
 
18
18
  ## 工作原理
19
19
 
@@ -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,
@@ -65,6 +65,7 @@ export const MONITOR_EVENT_CONFIG: Record<string, MonitorEventConfig> = {
65
65
  vote_speech_phase_ended: { priority: 50 },
66
66
  death_speech: { priority: 50 },
67
67
  wandering_speech: { priority: 50 },
68
+ crab_teammates: { priority: 50 },
68
69
 
69
70
  meeting_briefing: { priority: 50 },
70
71
  speech: { priority: 50 },
@@ -72,8 +73,6 @@ export const MONITOR_EVENT_CONFIG: Record<string, MonitorEventConfig> = {
72
73
  speech_your_turn: { priority: 99 },
73
74
 
74
75
  vote_phase_start: { priority: 99 },
75
- vote_cast: { priority: 50 },
76
- meeting_ended: { priority: 50 },
77
76
 
78
77
  game_over: { priority: 50 },
79
78
  role_assigned: { priority: 50 },
@@ -149,7 +148,7 @@ interface RouteRule {
149
148
  const ROUTING_RULES: RouteRule[] = [
150
149
  { reason: 'speech_your_turn', match: (t) => t.includes('speech_your_turn'), nextStep: 'It is YOUR turn to speak. Submit `ccl do -s "<draft>"` immediately. Server skips you after 45s.' },
151
150
  { reason: 'role_assigned', match: (t) => t.includes('role_assigned'), nextStep: 'Tell user your role, faction, win condition, and first plan.' },
152
- { reason: 'vote_cast', match: (t) => t.includes('vote_cast'), nextStep: 'A player just cast their vote. Read events[] to track who voted. No `ccl` action needed cast your own vote immediately when vote phase starts.' },
151
+ { reason: 'crab_teammates', match: (t) => t.includes('crab_teammates'), nextStep: 'Crab teammate list is available. Keep teammate identities private, coordinate cover stories, and avoid voting or killing into them.' },
153
152
  { reason: 'match_start', match: (t) => t.includes('match_start'), nextStep: 'Matchmaking queue entered. The game runtime is live and the stream is attached. Chat with the user while waiting for allocation.' },
154
153
  { reason: 'match_waiting', match: (t) => t.includes('match_waiting'), nextStep: 'Still in queue (see `events[].waited_secs`). Keep chatting with the user; no tactical action required.' },
155
154
  { reason: 'match_timeout', match: (t) => t.includes('match_timeout'), nextStep: `Cumulative wait reached ${Math.round(DEFAULT_MATCH_TIMEOUT_MS / 60_000)} min (see \`events[].waited_secs\`). The stream will exit — discuss with the user: launch a fresh \`ccl game start\` to retry, or call it a session.` },
@@ -39,6 +39,21 @@ describe('AuthStore TTS config', () => {
39
39
  expect(raw.profiles['lobster-1'].tts.defaultVoice).toBe('male-qn-qingse');
40
40
  });
41
41
 
42
+ it('clears cached game server URL when set to undefined', () => {
43
+ const store = new AuthStore(authFile);
44
+ store.addProfile({
45
+ agentName: 'lobster-1',
46
+ apiKey: 'claw_1',
47
+ serverUrl: 'https://example.com',
48
+ gameServerUrl: 'https://example.com/gs/old-game-server',
49
+ });
50
+
51
+ store.updateGameServerUrl(undefined);
52
+
53
+ const raw = JSON.parse(readFileSync(authFile, 'utf8'));
54
+ expect(raw.profiles['lobster-1'].gameServerUrl).toBeUndefined();
55
+ });
56
+
42
57
  it('migrates legacy neteaseTtsKey to each profile tts keys', () => {
43
58
  writeFileSync(authFile, JSON.stringify({
44
59
  activeProfile: 'lobster-1',
@@ -80,6 +80,87 @@ describe('event-format', () => {
80
80
  expect(JSON.stringify(formatted)).not.toContain('单钳渔夫');
81
81
  });
82
82
 
83
+ it('formats meeting result events as meeting-ended notices with seat-aware votes', () => {
84
+ const ctx = {
85
+ summary: {
86
+ game: {
87
+ all_seats: {
88
+ 菜逼油条: 8,
89
+ 单钳渔夫: 1,
90
+ 暗礁壳壳: 5,
91
+ },
92
+ },
93
+ },
94
+ };
95
+ const exile = {
96
+ type: 'exile',
97
+ tick: 3411,
98
+ actor_name: '单钳渔夫',
99
+ votes: {
100
+ 菜逼油条: '单钳渔夫',
101
+ 暗礁壳壳: 'skip',
102
+ },
103
+ hint: 'backend hint',
104
+ };
105
+
106
+ expect(formatEventMessage(exile, ctx)).toBe('t3411 exile 会议结束:1号单钳渔夫被放逐。');
107
+ expect(compactEventFields(exile, ctx)).toMatchObject({
108
+ type: 'exile',
109
+ tick: 3411,
110
+ meeting_ended: true,
111
+ result: 'exiled',
112
+ exiled_player: { name: '单钳渔夫', seat: 1 },
113
+ votes: {
114
+ '8号菜逼油条': '1号单钳渔夫',
115
+ '5号暗礁壳壳': 'skip',
116
+ },
117
+ });
118
+
119
+ expect(formatEventMessage({ type: 'no_exile', tick: 3412 }, ctx)).toBe('t3412 no_exile 会议结束:无人被放逐。');
120
+ expect(compactEventFields({ type: 'no_exile', tick: 3412 }, ctx)).toMatchObject({
121
+ type: 'no_exile',
122
+ tick: 3412,
123
+ meeting_ended: true,
124
+ result: 'no_exile',
125
+ });
126
+ });
127
+
128
+ it('fills crab teammate seats from the opening seat map', () => {
129
+ const ctx = {
130
+ summary: {
131
+ game: {
132
+ all_players: [{ name: '菜逼油条', seat: 8 }],
133
+ },
134
+ },
135
+ };
136
+ const event = {
137
+ type: 'crab_teammates',
138
+ tick: 20,
139
+ teammates: ['菜逼油条'],
140
+ };
141
+
142
+ expect(formatEventMessage(event, ctx)).toBe('t20 crab_teammates 蟹队友:8号菜逼油条。');
143
+ expect(compactEventFields(event, ctx)).toMatchObject({
144
+ type: 'crab_teammates',
145
+ tick: 20,
146
+ crab_teammates: [{ name: '菜逼油条', seat: 8 }],
147
+ });
148
+ });
149
+
150
+ it('formats death_speech with explicit speaker fields', () => {
151
+ const event = {
152
+ type: 'death_speech',
153
+ tick: 2201,
154
+ actor_name: '旧字段',
155
+ actor_seat: 2,
156
+ speaker_name: '暗礁壳壳',
157
+ speaker_seat: 5,
158
+ text: '我看到2号在尸体旁。',
159
+ };
160
+
161
+ expect(formatEventMessage(event)).toBe('t2201 death_speech 5号暗礁壳壳死亡弹幕:我看到2号在尸体旁。');
162
+ });
163
+
83
164
  it('keeps monitor message formatting separate from event detail fields', () => {
84
165
  const notice = formatEventMessage({
85
166
  type: 'vote_phase_start',
@@ -106,6 +187,7 @@ describe('event-format', () => {
106
187
  it('keeps local hint formatters for backend-hinted monitor events', () => {
107
188
  const backendHintedMonitorEvents = [
108
189
  'corpse_spotted',
190
+ 'crab_teammates',
109
191
  'death_speech',
110
192
  'emergency_resolved',
111
193
  'emergency_started',
@@ -114,7 +196,6 @@ describe('event-format', () => {
114
196
  'kill',
115
197
  'killed',
116
198
  'meeting_briefing',
117
- 'meeting_ended',
118
199
  'murder_witnessed',
119
200
  'no_exile',
120
201
  'octopus_time_start',
@@ -122,7 +203,6 @@ describe('event-format', () => {
122
203
  'speech_skipped',
123
204
  'task_completed',
124
205
  'task_sabotaged',
125
- 'vote_cast',
126
206
  'vote_phase_start',
127
207
  'vote_speech_phase_ended',
128
208
  'wandering_speech',
@@ -107,6 +107,63 @@ function playerLabel(name: unknown, seat: unknown, ctx: EventFormatContext): str
107
107
  return `${seatLabel(name, seat, ctx)}${text(name, '未知玩家')}`;
108
108
  }
109
109
 
110
+ function playerObject(name: unknown, seat: unknown, ctx: EventFormatContext): Record<string, any> | undefined {
111
+ if (typeof name !== 'string' || name.length === 0) return undefined;
112
+ return cleanObject({
113
+ name,
114
+ seat: numberValue(seat) ?? seatForName(name, ctx),
115
+ });
116
+ }
117
+
118
+ function voteTargetLabel(target: unknown, ctx: EventFormatContext): string {
119
+ if (target === null || target === undefined) return 'skip';
120
+ if (typeof target === 'object' && !Array.isArray(target)) {
121
+ const targetObject = target as Record<string, any>;
122
+ return playerLabel(targetObject.name ?? targetObject.agent_name, targetObject.seat, ctx);
123
+ }
124
+ const raw = text(target, 'skip');
125
+ return raw === 'skip' ? 'skip' : playerLabel(raw, undefined, ctx);
126
+ }
127
+
128
+ function formatVotes(votes: unknown, ctx: EventFormatContext): Record<string, string> | undefined {
129
+ if (!votes || typeof votes !== 'object' || Array.isArray(votes)) return undefined;
130
+ const out: Record<string, string> = {};
131
+ for (const [voter, target] of Object.entries(votes)) {
132
+ out[playerLabel(voter, undefined, ctx)] = voteTargetLabel(target, ctx);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function teammateNames(event: Record<string, any>): string[] {
138
+ const raw = Array.isArray(event.crab_teammates)
139
+ ? event.crab_teammates
140
+ : Array.isArray(event.teammates)
141
+ ? event.teammates
142
+ : [];
143
+ const names: string[] = [];
144
+ for (const teammate of raw) {
145
+ const name = typeof teammate === 'string' ? teammate : teammate?.name;
146
+ if (typeof name === 'string' && name.length > 0) names.push(name);
147
+ }
148
+ return names;
149
+ }
150
+
151
+ function teammateObjects(event: Record<string, any>, ctx: EventFormatContext): Record<string, any>[] {
152
+ const raw = Array.isArray(event.crab_teammates)
153
+ ? event.crab_teammates
154
+ : Array.isArray(event.teammates)
155
+ ? event.teammates
156
+ : [];
157
+ const teammates: Record<string, any>[] = [];
158
+ for (const teammate of raw) {
159
+ const name = typeof teammate === 'string' ? teammate : teammate?.name;
160
+ const seat = typeof teammate === 'string' ? undefined : teammate?.seat;
161
+ const normalized = playerObject(name, seat, ctx);
162
+ if (normalized) teammates.push(normalized);
163
+ }
164
+ return teammates;
165
+ }
166
+
110
167
  function prefix(event: Record<string, any>): string {
111
168
  const type = text(event.type, 'event');
112
169
  const tick = numberValue(event.tick);
@@ -171,14 +228,24 @@ function shortBody(event: Record<string, any>, ctx: EventFormatContext): string
171
228
  return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}已投票。`;
172
229
  case 'vote_speech_phase_ended':
173
230
  return '投票弹幕窗口已关闭。请用 ccl do -v <玩家名|skip> 完成投票。';
174
- case 'exile':
175
- return `${playerLabel(event.actor_name ?? event.result_target, firstNumber(event, ['actor_seat', 'result_target_seat', 'seat']), ctx)}被驱逐。`;
231
+ case 'exile': {
232
+ const exiledName = event.result_target ?? event.actor_name;
233
+ const exiledSeat = firstNumber(event, ['result_target_seat', 'actor_seat', 'seat']);
234
+ return `会议结束:${playerLabel(exiledName, exiledSeat, ctx)}被放逐。`;
235
+ }
176
236
  case 'no_exile':
177
- return '本轮无人被驱逐。';
237
+ return '会议结束:无人被放逐。';
178
238
  case 'meeting_ended':
179
239
  return '会议结束。';
180
- case 'death_speech':
181
- return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}死亡弹幕:${truncate(event.text ?? event.message, maxTextLength)}`;
240
+ case 'death_speech': {
241
+ const speakerName = event.speaker_name ?? event.actor_name;
242
+ const speakerSeat = firstNumber(event, ['speaker_seat', 'actor_seat', 'seat']);
243
+ return `${playerLabel(speakerName, speakerSeat, ctx)}死亡弹幕:${truncate(event.text ?? event.message, maxTextLength)}`;
244
+ }
245
+ case 'crab_teammates': {
246
+ const labels = teammateNames(event).map((name) => playerLabel(name, undefined, ctx));
247
+ return labels.length > 0 ? `蟹队友:${labels.join('、')}。` : '蟹队友信息已更新。';
248
+ }
182
249
  case 'wandering_speech':
183
250
  return `${text(event.actor_name, '你')}在${firstString(event, ['room'])}说:${truncate(event.text ?? event.message, maxTextLength)}`;
184
251
  case 'game_over':
@@ -277,6 +344,46 @@ function compactVoteCastForEvents(event: Record<string, any>, ctx: EventFormatCo
277
344
  });
278
345
  }
279
346
 
347
+ function compactCrabTeammatesForEvents(event: Record<string, any>, ctx: EventFormatContext): FormattedEvent {
348
+ const {
349
+ hint: _hint,
350
+ teammates: _teammates,
351
+ crab_teammates: _crabTeammates,
352
+ ...rest
353
+ } = event;
354
+ return cleanObject({
355
+ ...rest,
356
+ type: text(rest.type, 'event'),
357
+ tick: numberValue(rest.tick) ?? null,
358
+ crab_teammates: teammateObjects(event, ctx),
359
+ hint: formatEventHint(event, ctx),
360
+ });
361
+ }
362
+
363
+ function compactMeetingResultForEvents(event: Record<string, any>, ctx: EventFormatContext): FormattedEvent {
364
+ const {
365
+ hint: _hint,
366
+ votes: _votes,
367
+ ...rest
368
+ } = event;
369
+ const exiledName = event.type === 'exile'
370
+ ? event.result_target ?? event.actor_name
371
+ : undefined;
372
+ const exiledSeat = event.type === 'exile'
373
+ ? firstNumber(event, ['result_target_seat', 'actor_seat', 'seat'])
374
+ : undefined;
375
+ return cleanObject({
376
+ ...rest,
377
+ type: text(rest.type, 'event'),
378
+ tick: numberValue(rest.tick) ?? null,
379
+ meeting_ended: true,
380
+ result: event.type === 'exile' ? 'exiled' : 'no_exile',
381
+ exiled_player: playerObject(exiledName, exiledSeat, ctx),
382
+ votes: formatVotes(event.votes, ctx),
383
+ hint: formatEventHint(event, ctx),
384
+ });
385
+ }
386
+
280
387
  export function compactEventForEvents(
281
388
  event: Record<string, any>,
282
389
  ctx: EventFormatContext = {},
@@ -294,6 +401,8 @@ export function compactEventForEvents(
294
401
  }
295
402
 
296
403
  if (event.type === 'vote_cast') return compactVoteCastForEvents(event, ctx);
404
+ if (event.type === 'crab_teammates') return compactCrabTeammatesForEvents(event, ctx);
405
+ if (event.type === 'exile' || event.type === 'no_exile') return compactMeetingResultForEvents(event, ctx);
297
406
 
298
407
  const compact = stripBackendHint(event);
299
408
  return cleanObject({
@@ -140,10 +140,27 @@ export const EVENT_HINT_FORMATTERS: Record<string, EventHintFormatter> = {
140
140
  ),
141
141
  emergency_resolved: (event) => `${actorName(event)} broke the emergency. Crab pressure collapses, for now.`,
142
142
  exile: (event) => (
143
- `${actorName(event, text(event.result_target, 'Someone'))} was exiled by the table. `
143
+ `Meeting ended — ${actorName(event, text(event.result_target, 'Someone'))} was exiled by the table. `
144
144
  + 'If this is you, your influence is dead — tell your user and ask whether to spectate or leave.'
145
145
  ),
146
- no_exile: () => 'No one was exiled. The table failed to strike, and every hidden threat gets another turn.',
146
+ no_exile: () => 'Meeting ended — no one was exiled. The table failed to strike, and every hidden threat gets another turn.',
147
+ crab_teammates: (event) => {
148
+ const raw = Array.isArray(event.crab_teammates)
149
+ ? event.crab_teammates
150
+ : Array.isArray(event.teammates)
151
+ ? event.teammates
152
+ : [];
153
+ const labels = raw
154
+ .map((teammate: any) => (
155
+ typeof teammate === 'string'
156
+ ? teammate
157
+ : optionalSeatPlayerLabel(teammate?.name, teammate?.seat)
158
+ ))
159
+ .filter((label: string) => label.length > 0);
160
+ return labels.length > 0
161
+ ? `Your Crab teammate(s): ${labels.join(', ')}. Keep this private and coordinate cover stories.`
162
+ : 'Your Crab teammate list is available. Keep it private.';
163
+ },
147
164
  meeting_briefing: (event) => meetingBriefingHint(event),
148
165
  speech_skipped: (event) => `${actorName(event, 'Someone')} lost the floor — silence becomes evidence`,
149
166
  speech_your_turn: () => 'Submit `ccl do -s "<draft>"` immediately. Server skips you after 45s.',
@@ -163,7 +180,7 @@ export const EVENT_HINT_FORMATTERS: Record<string, EventHintFormatter> = {
163
180
  }
164
181
  return `Game over — ${text(event.winner, '?')} faction takes the table. The power struggle is settled. Review this game with your user, discuss key moments and decisions, then ask if they want to play again.`;
165
182
  },
166
- death_speech: (event) => `${actorName(event, 'Someone')} sent danmaku after death: ${text(event.text, '')}`,
183
+ death_speech: (event) => `${text(event.speaker_name ?? event.actor_name, 'Someone')} sent danmaku after death: ${text(event.text, '')}`,
167
184
  wandering_speech: (event) => `${actorName(event, 'Someone')}: ${text(event.text, '')}`,
168
185
  };
169
186
 
@@ -53,6 +53,40 @@ describe('buildMeetingStateProjection', () => {
53
53
  });
54
54
  });
55
55
 
56
+ describe('EventRuntime map seat cache', () => {
57
+ it('keeps opening map seats in the owner snapshot for formatters', async () => {
58
+ const { runtime } = makeRuntimeHarness();
59
+ const updateMapCache = vi.fn();
60
+ (runtime as any).client = {
61
+ getMap: vi.fn().mockResolvedValue({
62
+ all_players: [
63
+ { name: 'me', seat: 6 },
64
+ { name: '菜逼油条', seat: 8 },
65
+ ],
66
+ all_task_locations: [],
67
+ }),
68
+ };
69
+ (runtime as any).playerHistory = { updateMapCache };
70
+
71
+ (runtime as any).refreshPlayerHistoryMapCache();
72
+ await new Promise((resolve) => setTimeout(resolve, 0));
73
+
74
+ expect((runtime as any).currentGame).toMatchObject({
75
+ all_players: [
76
+ { name: 'me', seat: 6 },
77
+ { name: '菜逼油条', seat: 8 },
78
+ ],
79
+ all_seats: {
80
+ me: 6,
81
+ 菜逼油条: 8,
82
+ },
83
+ });
84
+ expect((runtime as any).currentYou.seat).toBe(6);
85
+ expect((runtime as any).snapshot()?.game?.all_seats?.菜逼油条).toBe(8);
86
+ expect(updateMapCache).toHaveBeenCalled();
87
+ });
88
+ });
89
+
56
90
  describe('EventRuntime game over detection', () => {
57
91
  it('stops when a state snapshot phase is game_over even without a game_over event', () => {
58
92
  const { runtime, appended, stops } = makeRuntimeHarness();
@@ -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.currentGame = {
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 { CorpseMemory, firstAvailableTask, nearestSafeTask, PatrolState, safePatrolStep } from './game-utils.js';
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 会因起点采样紧贴威胁把整条「背向威胁」的路线判危,