@myclaw163/clawclaw-cli 0.6.66 → 0.6.67

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.
Files changed (199) hide show
  1. package/README.md +427 -427
  2. package/bin/clawclaw-cli.mjs +3 -3
  3. package/package.json +48 -48
  4. package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
  5. package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
  6. package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
  7. package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
  8. package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
  9. package/scripts/find-hide-spots.py +157 -157
  10. package/scripts/postinstall.mjs +20 -20
  11. package/scripts/sync-bundled-skill.mjs +245 -245
  12. package/scripts/sync-bundled-skill.test.mjs +152 -152
  13. package/skills/clawclaw/SKILL.md +244 -245
  14. package/skills/clawclaw/references/CHATTERBOX.md +142 -142
  15. package/skills/clawclaw/references/COMMANDS.md +148 -148
  16. package/skills/clawclaw/references/GAME-MECHANICS.md +188 -188
  17. package/skills/clawclaw/references/HUB.md +48 -48
  18. package/skills/clawclaw/references/KNOWLEDGE.md +43 -43
  19. package/skills/clawclaw/references/STRATEGIES.md +57 -57
  20. package/skills/clawclaw/references/STREAM.md +91 -92
  21. package/skills/clawclaw/references/TACTICS.md +65 -65
  22. package/src/assets/clawclaw-ascii-map.txt +40 -40
  23. package/src/cli.ts +110 -110
  24. package/src/commands/_schema.ts +109 -109
  25. package/src/commands/account.ts +209 -209
  26. package/src/commands/do.test.ts +73 -73
  27. package/src/commands/do.ts +126 -126
  28. package/src/commands/events.test.ts +71 -71
  29. package/src/commands/events.ts +155 -155
  30. package/src/commands/game-map.test.ts +28 -28
  31. package/src/commands/game-start-plan.test.ts +84 -84
  32. package/src/commands/game.ts +1027 -1027
  33. package/src/commands/history-player.test.ts +102 -102
  34. package/src/commands/history.ts +573 -573
  35. package/src/commands/hub.test.ts +96 -96
  36. package/src/commands/hub.ts +234 -234
  37. package/src/commands/knowledge.test.ts +19 -19
  38. package/src/commands/knowledge.ts +168 -168
  39. package/src/commands/load.test.ts +51 -51
  40. package/src/commands/load.ts +13 -13
  41. package/src/commands/meeting-history.test.ts +106 -106
  42. package/src/commands/memory.ts +40 -40
  43. package/src/commands/peek.ts +45 -45
  44. package/src/commands/persona.ts +57 -57
  45. package/src/commands/setup/codex.ts +266 -248
  46. package/src/commands/skill.ts +128 -128
  47. package/src/commands/state.ts +46 -46
  48. package/src/commands/strategy.test.ts +135 -135
  49. package/src/commands/strategy.ts +180 -180
  50. package/src/commands/tts.ts +128 -128
  51. package/src/commands/upgrade.test.ts +82 -82
  52. package/src/commands/upgrade.ts +148 -148
  53. package/src/commands/watch.test.ts +966 -966
  54. package/src/commands/watch.ts +659 -659
  55. package/src/lib/auth.test.ts +59 -59
  56. package/src/lib/auth.ts +186 -186
  57. package/src/lib/command-meta.ts +37 -37
  58. package/src/lib/game-client.ts +391 -391
  59. package/src/lib/http-keepalive.ts +15 -15
  60. package/src/lib/http-transport.test.ts +42 -42
  61. package/src/lib/http-transport.ts +113 -113
  62. package/src/lib/hub-client.test.ts +56 -56
  63. package/src/lib/hub-client.ts +88 -88
  64. package/src/lib/hub-install.test.ts +98 -98
  65. package/src/lib/hub-install.ts +121 -121
  66. package/src/lib/hub-reminder.ts +75 -75
  67. package/src/lib/hub-unzip.test.ts +69 -69
  68. package/src/lib/hub-unzip.ts +62 -62
  69. package/src/lib/init-command.test.ts +75 -75
  70. package/src/lib/init-command.ts +120 -120
  71. package/src/lib/knowledge-store.test.ts +180 -180
  72. package/src/lib/knowledge-store.ts +374 -374
  73. package/src/lib/load-context.test.ts +52 -52
  74. package/src/lib/load-context.ts +52 -52
  75. package/src/lib/match-state.test.ts +134 -134
  76. package/src/lib/match-state.ts +94 -94
  77. package/src/lib/netease-tts.ts +83 -83
  78. package/src/lib/normalize.ts +42 -42
  79. package/src/lib/persona.test.ts +41 -41
  80. package/src/lib/persona.ts +72 -72
  81. package/src/lib/server-registry.ts +152 -152
  82. package/src/lib/skill-version.test.ts +48 -48
  83. package/src/lib/skill-version.ts +19 -19
  84. package/src/lib/strategy-export.test.ts +232 -232
  85. package/src/lib/strategy-export.ts +242 -242
  86. package/src/lib/tts-keys.ts +7 -7
  87. package/src/lib/tts-speech.test.ts +63 -63
  88. package/src/lib/tts-speech.ts +76 -76
  89. package/src/lib/workspace-argv.test.ts +49 -49
  90. package/src/lib/workspace-argv.ts +44 -44
  91. package/src/perception/player-history-store.test.ts +87 -87
  92. package/src/perception/player-history-store.ts +194 -194
  93. package/src/pipeline/event-format.test.ts +135 -135
  94. package/src/pipeline/event-format.ts +376 -376
  95. package/src/pipeline/event-hints.ts +173 -173
  96. package/src/pipeline/event-store.test.ts +28 -28
  97. package/src/pipeline/event-store.ts +193 -193
  98. package/src/pipeline/pipeline.ts +35 -35
  99. package/src/runtime/auto-upgrade.test.ts +66 -66
  100. package/src/runtime/auto-upgrade.ts +31 -31
  101. package/src/runtime/event-daemon.test.ts +107 -107
  102. package/src/runtime/event-daemon.ts +409 -409
  103. package/src/runtime/owner-control.ts +150 -150
  104. package/src/runtime/raw-ws-log.test.ts +33 -33
  105. package/src/runtime/raw-ws-log.ts +32 -32
  106. package/src/runtime/runtime-logger.ts +107 -107
  107. package/src/runtime/ws-client.test.ts +104 -104
  108. package/src/runtime/ws-client.ts +272 -272
  109. package/src/sdk/action.ts +166 -166
  110. package/src/sdk/index.ts +111 -111
  111. package/src/sdk/types.ts +159 -159
  112. package/src/strategies/avoid-lone.ts +11 -11
  113. package/src/strategies/avoid-players.knowledge.md +20 -20
  114. package/src/strategies/avoid-players.ts +15 -15
  115. package/src/strategies/corpse-patrol.ts +22 -22
  116. package/src/strategies/crab-sabotage.ts +21 -21
  117. package/src/strategies/custom-module.test.ts +269 -269
  118. package/src/strategies/find-player.ts +16 -16
  119. package/src/strategies/game-utils.test.ts +190 -190
  120. package/src/strategies/game-utils.ts +782 -782
  121. package/src/strategies/goals/anchor-linger.ts +77 -77
  122. package/src/strategies/goals/avoid-lone-top.ts +168 -168
  123. package/src/strategies/goals/avoid-players-top.test.ts +83 -83
  124. package/src/strategies/goals/avoid-players-top.ts +121 -121
  125. package/src/strategies/goals/conversation-goal.ts +51 -51
  126. package/src/strategies/goals/corpse-patrol-top.ts +91 -91
  127. package/src/strategies/goals/crab-octopus-reflexes.ts +93 -93
  128. package/src/strategies/goals/crab-sabotage-top.ts +197 -197
  129. package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
  130. package/src/strategies/goals/find-player-top.ts +93 -93
  131. package/src/strategies/goals/flee-players-goal.ts +53 -53
  132. package/src/strategies/goals/follow-companion-goal.ts +106 -106
  133. package/src/strategies/goals/goal-manager.ts +41 -41
  134. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  135. package/src/strategies/goals/goal.ts +28 -28
  136. package/src/strategies/goals/hide-top.ts +197 -197
  137. package/src/strategies/goals/keep-away-goal.ts +217 -217
  138. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  139. package/src/strategies/goals/kill-lone-top.ts +160 -160
  140. package/src/strategies/goals/kill-target-goal.ts +59 -59
  141. package/src/strategies/goals/kill-target-top.ts +109 -109
  142. package/src/strategies/goals/leaf-goal.ts +25 -25
  143. package/src/strategies/goals/linger-corpse-goal.ts +35 -35
  144. package/src/strategies/goals/lone-kill-core.ts +82 -82
  145. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  146. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  147. package/src/strategies/goals/lone-kill-task-top.ts +86 -86
  148. package/src/strategies/goals/move-room-goal.ts +60 -60
  149. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  150. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  151. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  152. package/src/strategies/goals/paradise-fish-top.ts +207 -207
  153. package/src/strategies/goals/patrol-top.ts +57 -57
  154. package/src/strategies/goals/report-patrol-top.ts +80 -80
  155. package/src/strategies/goals/safe-task-goal.ts +102 -102
  156. package/src/strategies/goals/social-task-top.ts +161 -161
  157. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  158. package/src/strategies/goals/task-only-top.ts +57 -57
  159. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  160. package/src/strategies/goals/task-report-top.ts +57 -57
  161. package/src/strategies/goals/wander-task-goal.ts +33 -33
  162. package/src/strategies/goals/warrior-shrimp-top.test.ts +86 -86
  163. package/src/strategies/goals/warrior-shrimp-top.ts +500 -500
  164. package/src/strategies/greeting.ts +53 -53
  165. package/src/strategies/hide-spots.ts +123 -123
  166. package/src/strategies/hide.ts +23 -23
  167. package/src/strategies/kill-frenzy.ts +12 -12
  168. package/src/strategies/kill-lone.knowledge.md +20 -20
  169. package/src/strategies/kill-lone.ts +13 -13
  170. package/src/strategies/kill-target.ts +18 -18
  171. package/src/strategies/loader.test.ts +678 -678
  172. package/src/strategies/loader.ts +172 -172
  173. package/src/strategies/lone-kill-task.ts +21 -21
  174. package/src/strategies/meeting-gate.test.ts +59 -59
  175. package/src/strategies/meeting-gate.ts +23 -23
  176. package/src/strategies/move-room.ts +15 -15
  177. package/src/strategies/new-events-backfill.ts +98 -98
  178. package/src/strategies/paradise-fish.knowledge.md +20 -20
  179. package/src/strategies/paradise-fish.ts +25 -25
  180. package/src/strategies/pathfind/distance-field.ts +150 -150
  181. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  182. package/src/strategies/pathfind/escape-planner.ts +355 -355
  183. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  184. package/src/strategies/patrol.ts +11 -11
  185. package/src/strategies/player-targets.ts +13 -13
  186. package/src/strategies/report-patrol.ts +11 -11
  187. package/src/strategies/shrimp-memory.knowledge.md +20 -20
  188. package/src/strategies/shrimp-memory.ts +25 -25
  189. package/src/strategies/social-task.test.ts +28 -28
  190. package/src/strategies/social-task.ts +49 -49
  191. package/src/strategies/spawn.ts +82 -82
  192. package/src/strategies/speech-module.ts +123 -123
  193. package/src/strategies/strategy-loop.ts +771 -771
  194. package/src/strategies/task-kill-report.ts +17 -17
  195. package/src/strategies/task-only.ts +11 -11
  196. package/src/strategies/task-report.ts +22 -22
  197. package/src/strategies/types.ts +102 -102
  198. package/src/strategies/warrior-memory.knowledge.md +22 -22
  199. package/src/strategies/warrior-memory.ts +16 -16
@@ -1,376 +1,376 @@
1
- import { formatEventHint, roleAssignedHint } from './event-hints.js';
2
-
3
- export interface EventFormatContext {
4
- summary?: any | null;
5
- seats?: Record<string, number>;
6
- isFirstVisibleEvent?: boolean;
7
- maxTextLength?: number;
8
- }
9
-
10
- export interface FormattedEvent {
11
- line?: number;
12
- type: string;
13
- tick: number | null;
14
- notice?: string;
15
- hint?: string;
16
- [key: string]: any;
17
- }
18
-
19
- function cleanObject<T extends Record<string, any>>(obj: T): T {
20
- for (const key of Object.keys(obj)) {
21
- if (obj[key] === undefined) delete obj[key];
22
- }
23
- return obj;
24
- }
25
-
26
- function text(value: unknown, fallback = '?'): string {
27
- if (typeof value === 'string' && value.length > 0) return value;
28
- if (typeof value === 'number' && Number.isFinite(value)) return String(value);
29
- return fallback;
30
- }
31
-
32
- function numberValue(value: unknown): number | undefined {
33
- if (typeof value === 'number' && Number.isFinite(value)) return value;
34
- if (typeof value === 'string' && value.trim() !== '') {
35
- const parsed = Number(value);
36
- if (Number.isFinite(parsed)) return parsed;
37
- }
38
- return undefined;
39
- }
40
-
41
- function firstString(event: Record<string, any>, keys: string[], fallback = '?'): string {
42
- for (const key of keys) {
43
- const value = event[key];
44
- if (typeof value === 'string' && value.length > 0) return value;
45
- if (typeof value === 'number' && Number.isFinite(value)) return String(value);
46
- }
47
- return fallback;
48
- }
49
-
50
- function firstNumber(event: Record<string, any>, keys: string[]): number | undefined {
51
- for (const key of keys) {
52
- const value = numberValue(event[key]);
53
- if (value !== undefined) return value;
54
- }
55
- return undefined;
56
- }
57
-
58
- function truncate(value: unknown, maxLength: number): string {
59
- const raw = text(value, '');
60
- if (raw.length <= maxLength) return raw;
61
- return `${raw.slice(0, Math.max(0, maxLength - 1))}…`;
62
- }
63
-
64
- export function seatMapFromSummary(summary: any | null | undefined, extra?: Record<string, number>): Record<string, number> {
65
- const seats: Record<string, number> = { ...(extra ?? {}) };
66
- const remember = (name: unknown, seat: unknown): void => {
67
- if (typeof name !== 'string' || name.length === 0) return;
68
- const parsed = numberValue(seat);
69
- if (parsed !== undefined) seats[name] = parsed;
70
- };
71
-
72
- remember(summary?.you?.name, summary?.you?.seat);
73
-
74
- const collections = [
75
- summary?.visible_players,
76
- summary?.all_players,
77
- summary?.game?.all_players,
78
- summary?.meeting?.alive_players,
79
- ];
80
- for (const collection of collections) {
81
- if (!Array.isArray(collection)) continue;
82
- for (const player of collection) {
83
- if (typeof player === 'string') continue;
84
- remember(player?.name ?? player?.agent_name, player?.seat);
85
- }
86
- }
87
-
88
- const allSeats = summary?.all_seats ?? summary?.game?.all_seats;
89
- if (allSeats && typeof allSeats === 'object' && !Array.isArray(allSeats)) {
90
- for (const [name, seat] of Object.entries(allSeats)) remember(name, seat);
91
- }
92
-
93
- return seats;
94
- }
95
-
96
- function seatForName(name: unknown, ctx: EventFormatContext): number | undefined {
97
- if (typeof name !== 'string' || name.length === 0) return undefined;
98
- return seatMapFromSummary(ctx.summary, ctx.seats)[name];
99
- }
100
-
101
- function seatLabel(name: unknown, seat: unknown, ctx: EventFormatContext): string {
102
- const resolved = numberValue(seat) ?? seatForName(name, ctx);
103
- return resolved === undefined ? '?号' : `${resolved}号`;
104
- }
105
-
106
- function playerLabel(name: unknown, seat: unknown, ctx: EventFormatContext): string {
107
- return `${seatLabel(name, seat, ctx)}${text(name, '未知玩家')}`;
108
- }
109
-
110
- function prefix(event: Record<string, any>): string {
111
- const type = text(event.type, 'event');
112
- const tick = numberValue(event.tick);
113
- return tick === undefined ? `tick=null ${type}` : `t${tick} ${type}`;
114
- }
115
-
116
- function firstReportedCorpse(event: Record<string, any>): Record<string, any> | null {
117
- const corpses = event.reported_corpses;
118
- if (Array.isArray(corpses) && corpses.length > 0 && corpses[0] && typeof corpses[0] === 'object') {
119
- return corpses[0];
120
- }
121
- return null;
122
- }
123
-
124
- function shortBody(event: Record<string, any>, ctx: EventFormatContext): string {
125
- const maxTextLength = ctx.maxTextLength ?? 100;
126
- switch (event.type) {
127
- case 'role_assigned': {
128
- const start = ctx.isFirstVisibleEvent ? '对局开始。' : '';
129
- return `${start}身份已分配:你是${firstString(event, ['role_display_name', 'role_display', 'role'])}(${firstString(event, ['faction'])})。`;
130
- }
131
- case 'kill':
132
- return `你在${firstString(event, ['room'])}击杀了${playerLabel(event.target_name, firstNumber(event, ['target_seat', 'target_player_seat']), ctx)}。`;
133
- case 'killed':
134
- return `你在${firstString(event, ['room'])}被${playerLabel(event.killer_name, firstNumber(event, ['killer_seat', 'killer_player_seat']), ctx)}击杀了,你已死亡。`;
135
- case 'murder_witnessed':
136
- return `你在${firstString(event, ['room'])}目击${playerLabel(event.killer_name, firstNumber(event, ['killer_seat', 'killer_player_seat']), ctx)}击杀${playerLabel(event.target_name, firstNumber(event, ['target_seat', 'target_player_seat']), ctx)}。`;
137
- case 'warrior_shrimp_self_destruct':
138
- return `你误杀了${playerLabel(event.target_name, firstNumber(event, ['target_seat', 'target_player_seat']), ctx)},武士虾自爆出局。`;
139
- case 'task_completed': {
140
- const fakeNote = event.is_fake_shrimp ? '(伪装)' : '';
141
- return `任务完成:${firstString(event, ['task_name'])}${fakeNote}。`;
142
- }
143
- case 'task_sabotaged':
144
- return `破坏任务完成:${firstString(event, ['task_name'])}(${firstString(event, ['room'])})。`;
145
- case 'corpse_spotted':
146
- return `我发现了${playerLabel(event.corpse_name, firstNumber(event, ['corpse_seat', 'corpse_player_seat']), ctx)}的尸体,在${firstString(event, ['corpse_room', 'room'])}。`;
147
- case 'octopus_time_start':
148
- return '章鱼时间开始:剩3人,章鱼存活60秒将获胜,会议已禁用。';
149
- case 'emergency_started':
150
- return `紧急任务:${firstString(event, ['task_name'])}在${firstString(event, ['room'])},${text(event.timeout_secs, '?')}秒内处理。`;
151
- case 'emergency_resolved':
152
- return `紧急任务已解决:${firstString(event, ['task_name'])}。`;
153
- case 'meeting_briefing': {
154
- const callerName = firstString(event, ['meeting_caller_name', 'caller', 'actor_name']);
155
- const caller = playerLabel(callerName, firstNumber(event, ['meeting_caller_seat', 'caller_seat', 'actor_seat']), ctx);
156
- const corpse = firstReportedCorpse(event);
157
- if (!corpse) return `会议开始:${caller}发起会议。`;
158
- const corpseName = text(corpse.name ?? event.corpse_name, '未知玩家');
159
- const corpseSeat = numberValue(corpse.seat) ?? firstNumber(event, ['corpse_seat', 'corpse_player_seat']);
160
- return `会议开始:${caller}报告${playerLabel(corpseName, corpseSeat, ctx)}尸体。`;
161
- }
162
- case 'speech':
163
- return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}会议发言:${truncate(event.text ?? event.message, maxTextLength)}`;
164
- case 'speech_skipped':
165
- return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}发言超时,被跳过。`;
166
- case 'speech_your_turn':
167
- return '轮到你发言。立即用 ccl do -s "..."。';
168
- case 'vote_phase_start':
169
- return '投票阶段开始。前20秒可用 ccl do -s "..." 发弹幕;确认后用 ccl do -v <玩家名|skip> 投票。';
170
- case 'vote_cast':
171
- return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}已投票。`;
172
- case 'vote_speech_phase_ended':
173
- 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)}被驱逐。`;
176
- case 'no_exile':
177
- return '本轮无人被驱逐。';
178
- case 'meeting_ended':
179
- return '会议结束。';
180
- case 'death_speech':
181
- return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}死亡弹幕:${truncate(event.text ?? event.message, maxTextLength)}`;
182
- case 'wandering_speech':
183
- return `${text(event.actor_name, '你')}在${firstString(event, ['room'])}说:${truncate(event.text ?? event.message, maxTextLength)}`;
184
- case 'game_over':
185
- return `游戏结束:${firstString(event, ['winner_name'], '未知玩家')}获胜(${firstString(event, ['winner'])}),原因 ${firstString(event, ['reason'])}。`;
186
- case 'match_waiting':
187
- return `仍在匹配队列,已等待${text(event.waited_secs, '?')}秒。`;
188
- case 'match_timeout':
189
- return `匹配超时,累计等待${text(event.waited_secs, '?')}秒。询问用户是否重试。`;
190
- case 'robot_speak_rule':
191
- return `发言提示:${truncate(event.message ?? event.text, maxTextLength)}`;
192
- case 'strategy_alert':
193
- return truncate(event.message ?? event.text, maxTextLength);
194
- case 'game_start':
195
- return '事件流已连接,等待身份分配。';
196
- case 'heartbeat':
197
- return '事件流正常,暂无新事件。';
198
- case 'snapshot':
199
- return '当前状态快照。';
200
- case 'not_logged_in':
201
- return '未登录。';
202
- case 'not_ready':
203
- return '游戏运行时未就绪。请运行 ccl game start。';
204
- case 'error':
205
- return text(event.message ?? event.error, '发生错误。');
206
- default:
207
- return truncate(event.message ?? event.hint ?? event.type, maxTextLength);
208
- }
209
- }
210
-
211
- export function formatEventMessage(event: Record<string, any>, ctx: EventFormatContext = {}): string {
212
- return `${prefix(event)} ${shortBody(event, ctx)}`;
213
- }
214
-
215
- function taskKindForEvents(task: any, event: Record<string, any>): string | undefined {
216
- if (typeof task?.kind === 'string' && task.kind.length > 0) return task.kind;
217
- if (typeof task?.task_kind === 'string' && task.task_kind.length > 0) return task.task_kind;
218
- if (task?.is_fake_shrimp === true) return 'fake_shrimp';
219
- if (event.faction === 'crab' && task?.is_fake_shrimp === false) return 'crab_sabotage';
220
- return undefined;
221
- }
222
-
223
- function compactRoleAssignedForEvents(event: Record<string, any>): FormattedEvent {
224
- const tasks = Array.isArray(event.assigned_tasks)
225
- ? event.assigned_tasks.map((task: any) => cleanObject({
226
- name: task?.name,
227
- room: task?.room,
228
- kind: taskKindForEvents(task, event),
229
- }))
230
- : undefined;
231
- const taskKinds = Array.isArray(tasks)
232
- ? Array.from(new Set(tasks.map((task: any) => task.kind).filter((kind: any) => typeof kind === 'string')))
233
- : [];
234
- const useTopLevelTaskKind = taskKinds.length === 1;
235
- const compactTasks = Array.isArray(tasks)
236
- ? tasks.map((task: any) => {
237
- if (!useTopLevelTaskKind) return task;
238
- const { kind: _kind, ...rest } = task;
239
- return rest;
240
- })
241
- : undefined;
242
- const hasFakeTask = taskKinds.includes('fake_shrimp') || event.fake_task_briefing;
243
- return cleanObject({
244
- type: text(event.type, 'event'),
245
- tick: numberValue(event.tick) ?? null,
246
- room: event.room,
247
- role: event.role,
248
- role_display: event.role_display_name ?? event.role_display,
249
- faction: event.faction,
250
- win_condition: event.role_description,
251
- task_kind: useTopLevelTaskKind ? taskKinds[0] : undefined,
252
- task_note: hasFakeTask ? 'Fake shrimp tasks: disguise only; no lobster progress.' : undefined,
253
- tasks: compactTasks,
254
- hint: roleAssignedHint(event),
255
- });
256
- }
257
-
258
- function stripBackendHint(event: Record<string, any>): Record<string, any> {
259
- const { hint: _hint, ...rest } = event;
260
- return { ...rest };
261
- }
262
-
263
- function compactVoteCastForEvents(event: Record<string, any>, ctx: EventFormatContext): FormattedEvent {
264
- const {
265
- hint: _hint,
266
- target: _target,
267
- target_name: _targetName,
268
- target_seat: _targetSeat,
269
- target_player_seat: _targetPlayerSeat,
270
- ...rest
271
- } = event;
272
- return cleanObject({
273
- ...rest,
274
- type: text(rest.type, 'event'),
275
- tick: numberValue(rest.tick) ?? null,
276
- hint: formatEventHint(event, ctx),
277
- });
278
- }
279
-
280
- export function compactEventForEvents(
281
- event: Record<string, any>,
282
- ctx: EventFormatContext = {},
283
- ): FormattedEvent {
284
- if (event.type === 'role_assigned') return compactRoleAssignedForEvents(event);
285
-
286
- if (event.type === 'meeting_briefing') {
287
- const { room: _room, hint: _hint, ...rest } = event;
288
- return cleanObject({
289
- ...rest,
290
- type: text(rest.type, 'event'),
291
- tick: numberValue(rest.tick) ?? null,
292
- hint: formatEventHint(event, ctx),
293
- });
294
- }
295
-
296
- if (event.type === 'vote_cast') return compactVoteCastForEvents(event, ctx);
297
-
298
- const compact = stripBackendHint(event);
299
- return cleanObject({
300
- ...compact,
301
- type: text(compact.type, 'event'),
302
- tick: numberValue(compact.tick) ?? null,
303
- hint: formatEventHint(event, ctx),
304
- });
305
- }
306
-
307
- export function compactEventFields(
308
- event: Record<string, any>,
309
- ctx: EventFormatContext = {},
310
- line?: number,
311
- ): FormattedEvent {
312
- return cleanObject({
313
- line,
314
- ...compactEventForEvents(event, ctx),
315
- });
316
- }
317
-
318
- export function compactStateForEvents(summary: any | null | undefined): Record<string, any> | null {
319
- if (!summary || typeof summary !== 'object') return null;
320
- const you = summary.you ?? {};
321
- const meeting = summary.meeting ?? null;
322
- const alivePlayers = Array.isArray(meeting?.alive_players) ? meeting.alive_players : undefined;
323
- const votesSubmitted = Array.isArray(meeting?.votes_submitted) ? meeting.votes_submitted : undefined;
324
- return cleanObject({
325
- phase: summary.phase,
326
- tick: summary.tick,
327
- you: cleanObject({
328
- name: you.name,
329
- seat: you.seat,
330
- role: you.role,
331
- role_display: you.role_display,
332
- faction: you.faction,
333
- alive: you.alive ?? you.is_alive,
334
- room: you.room,
335
- kill_cooldown_secs: you.kill_cooldown_secs,
336
- kills_remaining: you.kills_remaining,
337
- doing_task: you.doing_task,
338
- }),
339
- game: summary.game,
340
- meeting: meeting
341
- ? cleanObject({
342
- caller: meeting.caller,
343
- sub_phase: meeting.sub_phase,
344
- current_speaker: meeting.current_speaker,
345
- is_my_turn: meeting.is_my_turn,
346
- alive_count: alivePlayers?.length,
347
- votes_submitted_count: votesSubmitted?.length,
348
- })
349
- : undefined,
350
- urgent: summary.urgent,
351
- automation: summary.automation,
352
- });
353
- }
354
-
355
- export function shortStateText(summary: any | null | undefined): string {
356
- if (!summary || typeof summary !== 'object') return 'state unavailable';
357
- const phase = text(summary.phase, 'unknown');
358
- const sub = typeof summary.meeting?.sub_phase === 'string' ? `/${summary.meeting.sub_phase}` : '';
359
- const parts = [`${phase}${sub}`];
360
- const you = summary.you ?? {};
361
- if (you.name) {
362
- const alive = you.alive ?? you.is_alive;
363
- const aliveText = typeof alive === 'boolean' ? (alive ? 'alive' : 'dead') : 'unknown';
364
- parts.push(`you=${you.name} ${aliveText}`);
365
- }
366
- const speaker = summary.meeting?.current_speaker;
367
- if (speaker) parts.push(`speaker=${summary.meeting?.is_my_turn ? 'you' : speaker}`);
368
- const alivePlayers = summary.meeting?.alive_players;
369
- const aliveCount = Array.isArray(alivePlayers)
370
- ? alivePlayers.length
371
- : summary.game?.alive_count ?? summary.alive_count;
372
- if (aliveCount !== undefined) parts.push(`alive=${aliveCount}`);
373
- if (summary.urgent?.emergency_active) parts.push('urgent=emergency');
374
- if (summary.urgent?.corpse_nearby) parts.push('urgent=corpse');
375
- return parts.join('; ');
376
- }
1
+ import { formatEventHint, roleAssignedHint } from './event-hints.js';
2
+
3
+ export interface EventFormatContext {
4
+ summary?: any | null;
5
+ seats?: Record<string, number>;
6
+ isFirstVisibleEvent?: boolean;
7
+ maxTextLength?: number;
8
+ }
9
+
10
+ export interface FormattedEvent {
11
+ line?: number;
12
+ type: string;
13
+ tick: number | null;
14
+ notice?: string;
15
+ hint?: string;
16
+ [key: string]: any;
17
+ }
18
+
19
+ function cleanObject<T extends Record<string, any>>(obj: T): T {
20
+ for (const key of Object.keys(obj)) {
21
+ if (obj[key] === undefined) delete obj[key];
22
+ }
23
+ return obj;
24
+ }
25
+
26
+ function text(value: unknown, fallback = '?'): string {
27
+ if (typeof value === 'string' && value.length > 0) return value;
28
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
29
+ return fallback;
30
+ }
31
+
32
+ function numberValue(value: unknown): number | undefined {
33
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
34
+ if (typeof value === 'string' && value.trim() !== '') {
35
+ const parsed = Number(value);
36
+ if (Number.isFinite(parsed)) return parsed;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ function firstString(event: Record<string, any>, keys: string[], fallback = '?'): string {
42
+ for (const key of keys) {
43
+ const value = event[key];
44
+ if (typeof value === 'string' && value.length > 0) return value;
45
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
46
+ }
47
+ return fallback;
48
+ }
49
+
50
+ function firstNumber(event: Record<string, any>, keys: string[]): number | undefined {
51
+ for (const key of keys) {
52
+ const value = numberValue(event[key]);
53
+ if (value !== undefined) return value;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function truncate(value: unknown, maxLength: number): string {
59
+ const raw = text(value, '');
60
+ if (raw.length <= maxLength) return raw;
61
+ return `${raw.slice(0, Math.max(0, maxLength - 1))}…`;
62
+ }
63
+
64
+ export function seatMapFromSummary(summary: any | null | undefined, extra?: Record<string, number>): Record<string, number> {
65
+ const seats: Record<string, number> = { ...(extra ?? {}) };
66
+ const remember = (name: unknown, seat: unknown): void => {
67
+ if (typeof name !== 'string' || name.length === 0) return;
68
+ const parsed = numberValue(seat);
69
+ if (parsed !== undefined) seats[name] = parsed;
70
+ };
71
+
72
+ remember(summary?.you?.name, summary?.you?.seat);
73
+
74
+ const collections = [
75
+ summary?.visible_players,
76
+ summary?.all_players,
77
+ summary?.game?.all_players,
78
+ summary?.meeting?.alive_players,
79
+ ];
80
+ for (const collection of collections) {
81
+ if (!Array.isArray(collection)) continue;
82
+ for (const player of collection) {
83
+ if (typeof player === 'string') continue;
84
+ remember(player?.name ?? player?.agent_name, player?.seat);
85
+ }
86
+ }
87
+
88
+ const allSeats = summary?.all_seats ?? summary?.game?.all_seats;
89
+ if (allSeats && typeof allSeats === 'object' && !Array.isArray(allSeats)) {
90
+ for (const [name, seat] of Object.entries(allSeats)) remember(name, seat);
91
+ }
92
+
93
+ return seats;
94
+ }
95
+
96
+ function seatForName(name: unknown, ctx: EventFormatContext): number | undefined {
97
+ if (typeof name !== 'string' || name.length === 0) return undefined;
98
+ return seatMapFromSummary(ctx.summary, ctx.seats)[name];
99
+ }
100
+
101
+ function seatLabel(name: unknown, seat: unknown, ctx: EventFormatContext): string {
102
+ const resolved = numberValue(seat) ?? seatForName(name, ctx);
103
+ return resolved === undefined ? '?号' : `${resolved}号`;
104
+ }
105
+
106
+ function playerLabel(name: unknown, seat: unknown, ctx: EventFormatContext): string {
107
+ return `${seatLabel(name, seat, ctx)}${text(name, '未知玩家')}`;
108
+ }
109
+
110
+ function prefix(event: Record<string, any>): string {
111
+ const type = text(event.type, 'event');
112
+ const tick = numberValue(event.tick);
113
+ return tick === undefined ? `tick=null ${type}` : `t${tick} ${type}`;
114
+ }
115
+
116
+ function firstReportedCorpse(event: Record<string, any>): Record<string, any> | null {
117
+ const corpses = event.reported_corpses;
118
+ if (Array.isArray(corpses) && corpses.length > 0 && corpses[0] && typeof corpses[0] === 'object') {
119
+ return corpses[0];
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function shortBody(event: Record<string, any>, ctx: EventFormatContext): string {
125
+ const maxTextLength = ctx.maxTextLength ?? 100;
126
+ switch (event.type) {
127
+ case 'role_assigned': {
128
+ const start = ctx.isFirstVisibleEvent ? '对局开始。' : '';
129
+ return `${start}身份已分配:你是${firstString(event, ['role_display_name', 'role_display', 'role'])}(${firstString(event, ['faction'])})。`;
130
+ }
131
+ case 'kill':
132
+ return `你在${firstString(event, ['room'])}击杀了${playerLabel(event.target_name, firstNumber(event, ['target_seat', 'target_player_seat']), ctx)}。`;
133
+ case 'killed':
134
+ return `你在${firstString(event, ['room'])}被${playerLabel(event.killer_name, firstNumber(event, ['killer_seat', 'killer_player_seat']), ctx)}击杀了,你已死亡。`;
135
+ case 'murder_witnessed':
136
+ return `你在${firstString(event, ['room'])}目击${playerLabel(event.killer_name, firstNumber(event, ['killer_seat', 'killer_player_seat']), ctx)}击杀${playerLabel(event.target_name, firstNumber(event, ['target_seat', 'target_player_seat']), ctx)}。`;
137
+ case 'warrior_shrimp_self_destruct':
138
+ return `你误杀了${playerLabel(event.target_name, firstNumber(event, ['target_seat', 'target_player_seat']), ctx)},武士虾自爆出局。`;
139
+ case 'task_completed': {
140
+ const fakeNote = event.is_fake_shrimp ? '(伪装)' : '';
141
+ return `任务完成:${firstString(event, ['task_name'])}${fakeNote}。`;
142
+ }
143
+ case 'task_sabotaged':
144
+ return `破坏任务完成:${firstString(event, ['task_name'])}(${firstString(event, ['room'])})。`;
145
+ case 'corpse_spotted':
146
+ return `我发现了${playerLabel(event.corpse_name, firstNumber(event, ['corpse_seat', 'corpse_player_seat']), ctx)}的尸体,在${firstString(event, ['corpse_room', 'room'])}。`;
147
+ case 'octopus_time_start':
148
+ return '章鱼时间开始:剩3人,章鱼存活60秒将获胜,会议已禁用。';
149
+ case 'emergency_started':
150
+ return `紧急任务:${firstString(event, ['task_name'])}在${firstString(event, ['room'])},${text(event.timeout_secs, '?')}秒内处理。`;
151
+ case 'emergency_resolved':
152
+ return `紧急任务已解决:${firstString(event, ['task_name'])}。`;
153
+ case 'meeting_briefing': {
154
+ const callerName = firstString(event, ['meeting_caller_name', 'caller', 'actor_name']);
155
+ const caller = playerLabel(callerName, firstNumber(event, ['meeting_caller_seat', 'caller_seat', 'actor_seat']), ctx);
156
+ const corpse = firstReportedCorpse(event);
157
+ if (!corpse) return `会议开始:${caller}发起会议。`;
158
+ const corpseName = text(corpse.name ?? event.corpse_name, '未知玩家');
159
+ const corpseSeat = numberValue(corpse.seat) ?? firstNumber(event, ['corpse_seat', 'corpse_player_seat']);
160
+ return `会议开始:${caller}报告${playerLabel(corpseName, corpseSeat, ctx)}尸体。`;
161
+ }
162
+ case 'speech':
163
+ return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}会议发言:${truncate(event.text ?? event.message, maxTextLength)}`;
164
+ case 'speech_skipped':
165
+ return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}发言超时,被跳过。`;
166
+ case 'speech_your_turn':
167
+ return '轮到你发言。立即用 ccl do -s "..."。';
168
+ case 'vote_phase_start':
169
+ return '投票阶段开始。前20秒可用 ccl do -s "..." 发弹幕;确认后用 ccl do -v <玩家名|skip> 投票。';
170
+ case 'vote_cast':
171
+ return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}已投票。`;
172
+ case 'vote_speech_phase_ended':
173
+ 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)}被驱逐。`;
176
+ case 'no_exile':
177
+ return '本轮无人被驱逐。';
178
+ case 'meeting_ended':
179
+ return '会议结束。';
180
+ case 'death_speech':
181
+ return `${playerLabel(event.actor_name, firstNumber(event, ['actor_seat', 'seat']), ctx)}死亡弹幕:${truncate(event.text ?? event.message, maxTextLength)}`;
182
+ case 'wandering_speech':
183
+ return `${text(event.actor_name, '你')}在${firstString(event, ['room'])}说:${truncate(event.text ?? event.message, maxTextLength)}`;
184
+ case 'game_over':
185
+ return `游戏结束:${firstString(event, ['winner_name'], '未知玩家')}获胜(${firstString(event, ['winner'])}),原因 ${firstString(event, ['reason'])}。`;
186
+ case 'match_waiting':
187
+ return `仍在匹配队列,已等待${text(event.waited_secs, '?')}秒。`;
188
+ case 'match_timeout':
189
+ return `匹配超时,累计等待${text(event.waited_secs, '?')}秒。询问用户是否重试。`;
190
+ case 'robot_speak_rule':
191
+ return `发言提示:${truncate(event.message ?? event.text, maxTextLength)}`;
192
+ case 'strategy_alert':
193
+ return truncate(event.message ?? event.text, maxTextLength);
194
+ case 'game_start':
195
+ return '事件流已连接,等待身份分配。';
196
+ case 'heartbeat':
197
+ return '事件流正常,暂无新事件。';
198
+ case 'snapshot':
199
+ return '当前状态快照。';
200
+ case 'not_logged_in':
201
+ return '未登录。';
202
+ case 'not_ready':
203
+ return '游戏运行时未就绪。请运行 ccl game start。';
204
+ case 'error':
205
+ return text(event.message ?? event.error, '发生错误。');
206
+ default:
207
+ return truncate(event.message ?? event.hint ?? event.type, maxTextLength);
208
+ }
209
+ }
210
+
211
+ export function formatEventMessage(event: Record<string, any>, ctx: EventFormatContext = {}): string {
212
+ return `${prefix(event)} ${shortBody(event, ctx)}`;
213
+ }
214
+
215
+ function taskKindForEvents(task: any, event: Record<string, any>): string | undefined {
216
+ if (typeof task?.kind === 'string' && task.kind.length > 0) return task.kind;
217
+ if (typeof task?.task_kind === 'string' && task.task_kind.length > 0) return task.task_kind;
218
+ if (task?.is_fake_shrimp === true) return 'fake_shrimp';
219
+ if (event.faction === 'crab' && task?.is_fake_shrimp === false) return 'crab_sabotage';
220
+ return undefined;
221
+ }
222
+
223
+ function compactRoleAssignedForEvents(event: Record<string, any>): FormattedEvent {
224
+ const tasks = Array.isArray(event.assigned_tasks)
225
+ ? event.assigned_tasks.map((task: any) => cleanObject({
226
+ name: task?.name,
227
+ room: task?.room,
228
+ kind: taskKindForEvents(task, event),
229
+ }))
230
+ : undefined;
231
+ const taskKinds = Array.isArray(tasks)
232
+ ? Array.from(new Set(tasks.map((task: any) => task.kind).filter((kind: any) => typeof kind === 'string')))
233
+ : [];
234
+ const useTopLevelTaskKind = taskKinds.length === 1;
235
+ const compactTasks = Array.isArray(tasks)
236
+ ? tasks.map((task: any) => {
237
+ if (!useTopLevelTaskKind) return task;
238
+ const { kind: _kind, ...rest } = task;
239
+ return rest;
240
+ })
241
+ : undefined;
242
+ const hasFakeTask = taskKinds.includes('fake_shrimp') || event.fake_task_briefing;
243
+ return cleanObject({
244
+ type: text(event.type, 'event'),
245
+ tick: numberValue(event.tick) ?? null,
246
+ room: event.room,
247
+ role: event.role,
248
+ role_display: event.role_display_name ?? event.role_display,
249
+ faction: event.faction,
250
+ win_condition: event.role_description,
251
+ task_kind: useTopLevelTaskKind ? taskKinds[0] : undefined,
252
+ task_note: hasFakeTask ? 'Fake shrimp tasks: disguise only; no lobster progress.' : undefined,
253
+ tasks: compactTasks,
254
+ hint: roleAssignedHint(event),
255
+ });
256
+ }
257
+
258
+ function stripBackendHint(event: Record<string, any>): Record<string, any> {
259
+ const { hint: _hint, ...rest } = event;
260
+ return { ...rest };
261
+ }
262
+
263
+ function compactVoteCastForEvents(event: Record<string, any>, ctx: EventFormatContext): FormattedEvent {
264
+ const {
265
+ hint: _hint,
266
+ target: _target,
267
+ target_name: _targetName,
268
+ target_seat: _targetSeat,
269
+ target_player_seat: _targetPlayerSeat,
270
+ ...rest
271
+ } = event;
272
+ return cleanObject({
273
+ ...rest,
274
+ type: text(rest.type, 'event'),
275
+ tick: numberValue(rest.tick) ?? null,
276
+ hint: formatEventHint(event, ctx),
277
+ });
278
+ }
279
+
280
+ export function compactEventForEvents(
281
+ event: Record<string, any>,
282
+ ctx: EventFormatContext = {},
283
+ ): FormattedEvent {
284
+ if (event.type === 'role_assigned') return compactRoleAssignedForEvents(event);
285
+
286
+ if (event.type === 'meeting_briefing') {
287
+ const { room: _room, hint: _hint, ...rest } = event;
288
+ return cleanObject({
289
+ ...rest,
290
+ type: text(rest.type, 'event'),
291
+ tick: numberValue(rest.tick) ?? null,
292
+ hint: formatEventHint(event, ctx),
293
+ });
294
+ }
295
+
296
+ if (event.type === 'vote_cast') return compactVoteCastForEvents(event, ctx);
297
+
298
+ const compact = stripBackendHint(event);
299
+ return cleanObject({
300
+ ...compact,
301
+ type: text(compact.type, 'event'),
302
+ tick: numberValue(compact.tick) ?? null,
303
+ hint: formatEventHint(event, ctx),
304
+ });
305
+ }
306
+
307
+ export function compactEventFields(
308
+ event: Record<string, any>,
309
+ ctx: EventFormatContext = {},
310
+ line?: number,
311
+ ): FormattedEvent {
312
+ return cleanObject({
313
+ line,
314
+ ...compactEventForEvents(event, ctx),
315
+ });
316
+ }
317
+
318
+ export function compactStateForEvents(summary: any | null | undefined): Record<string, any> | null {
319
+ if (!summary || typeof summary !== 'object') return null;
320
+ const you = summary.you ?? {};
321
+ const meeting = summary.meeting ?? null;
322
+ const alivePlayers = Array.isArray(meeting?.alive_players) ? meeting.alive_players : undefined;
323
+ const votesSubmitted = Array.isArray(meeting?.votes_submitted) ? meeting.votes_submitted : undefined;
324
+ return cleanObject({
325
+ phase: summary.phase,
326
+ tick: summary.tick,
327
+ you: cleanObject({
328
+ name: you.name,
329
+ seat: you.seat,
330
+ role: you.role,
331
+ role_display: you.role_display,
332
+ faction: you.faction,
333
+ alive: you.alive ?? you.is_alive,
334
+ room: you.room,
335
+ kill_cooldown_secs: you.kill_cooldown_secs,
336
+ kills_remaining: you.kills_remaining,
337
+ doing_task: you.doing_task,
338
+ }),
339
+ game: summary.game,
340
+ meeting: meeting
341
+ ? cleanObject({
342
+ caller: meeting.caller,
343
+ sub_phase: meeting.sub_phase,
344
+ current_speaker: meeting.current_speaker,
345
+ is_my_turn: meeting.is_my_turn,
346
+ alive_count: alivePlayers?.length,
347
+ votes_submitted_count: votesSubmitted?.length,
348
+ })
349
+ : undefined,
350
+ urgent: summary.urgent,
351
+ automation: summary.automation,
352
+ });
353
+ }
354
+
355
+ export function shortStateText(summary: any | null | undefined): string {
356
+ if (!summary || typeof summary !== 'object') return 'state unavailable';
357
+ const phase = text(summary.phase, 'unknown');
358
+ const sub = typeof summary.meeting?.sub_phase === 'string' ? `/${summary.meeting.sub_phase}` : '';
359
+ const parts = [`${phase}${sub}`];
360
+ const you = summary.you ?? {};
361
+ if (you.name) {
362
+ const alive = you.alive ?? you.is_alive;
363
+ const aliveText = typeof alive === 'boolean' ? (alive ? 'alive' : 'dead') : 'unknown';
364
+ parts.push(`you=${you.name} ${aliveText}`);
365
+ }
366
+ const speaker = summary.meeting?.current_speaker;
367
+ if (speaker) parts.push(`speaker=${summary.meeting?.is_my_turn ? 'you' : speaker}`);
368
+ const alivePlayers = summary.meeting?.alive_players;
369
+ const aliveCount = Array.isArray(alivePlayers)
370
+ ? alivePlayers.length
371
+ : summary.game?.alive_count ?? summary.alive_count;
372
+ if (aliveCount !== undefined) parts.push(`alive=${aliveCount}`);
373
+ if (summary.urgent?.emergency_active) parts.push('urgent=emergency');
374
+ if (summary.urgent?.corpse_nearby) parts.push('urgent=corpse');
375
+ return parts.join('; ');
376
+ }