@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,966 +1,966 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- classifyEvent,
4
- compactEventForMonitor,
5
- compactSummaryForMonitor,
6
- DELAYABLE_EVENT_CONFIG,
7
- eventKey,
8
- MONITOR_EVENT_CONFIG,
9
- NOTABLE_EVENT_TYPES,
10
- nextStepFor,
11
- POLL_INTERVAL_MS,
12
- readFeedSummary,
13
- runStreaming,
14
- snapshotOnce,
15
- sortEventsForMonitor,
16
- } from './watch.js';
17
-
18
- describe('classifyEvent + NOTABLE_EVENT_TYPES', () => {
19
- it('NOTABLE_EVENT_TYPES contains the headline events', () => {
20
- for (const t of [
21
- 'exile', 'speech_skipped', 'meeting_briefing',
22
- 'speech', 'vote_phase_start', 'game_over',
23
- 'speech_your_turn', 'vote_speech_phase_ended',
24
- ]) {
25
- expect(NOTABLE_EVENT_TYPES.has(t)).toBe(true);
26
- }
27
- });
28
-
29
- it('derives NOTABLE_EVENT_TYPES from MONITOR_EVENT_CONFIG', () => {
30
- expect([...NOTABLE_EVENT_TYPES].sort()).toEqual(Object.keys(MONITOR_EVENT_CONFIG).sort());
31
- });
32
-
33
- it('registers monitor event priorities in the same config as notable events', () => {
34
- expect(MONITOR_EVENT_CONFIG.speech_your_turn.priority).toBe(99);
35
- expect(MONITOR_EVENT_CONFIG.vote_phase_start.priority).toBe(99);
36
- expect(MONITOR_EVENT_CONFIG.killed.priority).toBe(99);
37
- expect(MONITOR_EVENT_CONFIG.speech.priority).toBe(50);
38
- expect(MONITOR_EVENT_CONFIG.vote_speech_phase_ended.priority).toBe(50);
39
- expect(MONITOR_EVENT_CONFIG.vote_speech).toBeUndefined();
40
- });
41
-
42
- it('classifyEvent returns notable=true for known types', () => {
43
- expect(classifyEvent({ type: 'killed', tick: 1 }).notable).toBe(true);
44
- });
45
-
46
- it('classifyEvent returns notable=false for unknown types', () => {
47
- expect(classifyEvent({ type: 'heartbeat', tick: 1 }).notable).toBe(false);
48
- });
49
-
50
- it('DELAYABLE_EVENT_CONFIG contains the delayable events', () => {
51
- for (const t of ['kill', 'emergency_started', 'corpse_spotted']) {
52
- expect(DELAYABLE_EVENT_CONFIG[t]).toBeGreaterThan(0);
53
- }
54
- });
55
-
56
- it('classifyEvent marks speech_your_turn as notable', () => {
57
- expect(
58
- classifyEvent({ type: 'speech_your_turn', tick: 1, actor_name: 'me' }, 'me').notable,
59
- ).toBe(true);
60
- });
61
-
62
- it('classifyEvent marks speech as notable', () => {
63
- const cls = classifyEvent({ type: 'speech', tick: 1, actor_name: 'me' }, 'me');
64
- expect(cls.notable).toBe(true);
65
- });
66
-
67
- it('classifyEvent marks speech_skipped as notable', () => {
68
- expect(
69
- classifyEvent({ type: 'speech_skipped', tick: 1, actor_name: 'me' }, 'me').notable,
70
- ).toBe(true);
71
- });
72
-
73
- it('classifyEvent ignores vote_speech but marks vote_speech_phase_ended as notable', () => {
74
- expect(classifyEvent({ type: 'vote_speech', tick: 1, actor_name: 'me' }, 'me').notable).toBe(false);
75
- expect(classifyEvent({ type: 'vote_speech_phase_ended', tick: 2 }, 'me').notable).toBe(true);
76
- });
77
-
78
- it('eventKey ignores fields that are not part of the dedup key (e.g. ts)', () => {
79
- const a = { type: 'speech_your_turn', tick: 5, actor_name: 'me' };
80
- const b = { type: 'speech_your_turn', tick: 5, actor_name: 'me', ts: '2026-01-01T00:00:00Z' };
81
- expect(eventKey(a)).toBe(eventKey(b));
82
- });
83
-
84
- it('eventKey differs across distinct logical events', () => {
85
- const a = { type: 'speech_your_turn', tick: 5, actor_name: 'me' };
86
- const b = { type: 'speech_your_turn', tick: 6, actor_name: 'me' }; // different tick
87
- const c = { type: 'speech_your_turn', tick: 5, actor_name: 'them' }; // different actor
88
- expect(eventKey(a)).not.toBe(eventKey(b));
89
- expect(eventKey(a)).not.toBe(eventKey(c));
90
- });
91
-
92
- it('classifyEvent returns notable=false for non-notable types regardless of actor', () => {
93
- expect(classifyEvent({ type: 'heartbeat', tick: 1, actor_name: 'them' }, 'me').notable).toBe(false);
94
- expect(classifyEvent({ type: 'heartbeat', tick: 1, actor_name: 'me' }, 'me').notable).toBe(false);
95
- });
96
- });
97
-
98
- describe('sortEventsForMonitor', () => {
99
- it('puts speech_your_turn before default-priority events', () => {
100
- const sorted = sortEventsForMonitor([
101
- { type: 'speech_skipped', tick: 1 },
102
- { type: 'speech_your_turn', tick: 2 },
103
- ]);
104
- expect(sorted.map((e) => e.type)).toEqual(['speech_your_turn', 'speech_skipped']);
105
- });
106
-
107
- it('puts vote_phase_start before default-priority events', () => {
108
- const sorted = sortEventsForMonitor([
109
- { type: 'speech_skipped', tick: 1 },
110
- { type: 'vote_phase_start', tick: 2 },
111
- ]);
112
- expect(sorted.map((e) => e.type)).toEqual(['vote_phase_start', 'speech_skipped']);
113
- });
114
-
115
- it('keeps original order for events with the same priority', () => {
116
- const sorted = sortEventsForMonitor([
117
- { type: 'speech', tick: 1 },
118
- { type: 'speech_skipped', tick: 2 },
119
- { type: 'vote_cast', tick: 3 },
120
- ]);
121
- expect(sorted.map((e) => e.type)).toEqual(['speech', 'speech_skipped', 'vote_cast']);
122
- });
123
- });
124
-
125
- describe('watch defaults', () => {
126
- it('uses 220ms as the default poll interval', () => {
127
- expect(POLL_INTERVAL_MS).toBe(220);
128
- });
129
- });
130
-
131
- describe('monitor payload compaction', () => {
132
- it('routes role_assigned to a dedicated opening next step', () => {
133
- expect(nextStepFor('role_assigned')).toMatch(/role/i);
134
- expect(nextStepFor('role_assigned')).toMatch(/faction/i);
135
- });
136
-
137
- it('compacts ordinary lobster role_assigned without inventing extra task kind labels', () => {
138
- const compact = compactEventForMonitor({
139
- type: 'role_assigned',
140
- tick: 1,
141
- actor_name: 'me',
142
- room: '厨房',
143
- role: 'shrimp',
144
- role_display_name: '普通虾',
145
- faction: 'lobster',
146
- role_description: '完成任务或投出所有蟹方。',
147
- hint: 'long human hint',
148
- assigned_tasks: [
149
- { name: '修电线', room: '电气', x: 1, y: 2, task_note: 'long note', is_fake_shrimp: false },
150
- ],
151
- all_task_locations: [{ name: '修电线', room: '电气', x: 1, y: 2 }],
152
- });
153
-
154
- expect(compact).toEqual({
155
- type: 'role_assigned',
156
- tick: 1,
157
- room: '厨房',
158
- role: 'shrimp',
159
- role_display: '普通虾',
160
- faction: 'lobster',
161
- win_condition: '完成任务或投出所有蟹方。',
162
- tasks: [{ name: '修电线', room: '电气' }],
163
- hint: 'You are 普通虾 (lobster). 完成任务或投出所有蟹方。 Announce your role, faction and win condition to your user.',
164
- });
165
- });
166
-
167
- it('compacts all-fake neutral role_assigned into one top-level task kind', () => {
168
- const compact = compactEventForMonitor({
169
- type: 'role_assigned',
170
- tick: 2,
171
- room: '仓库',
172
- role: 'octopus',
173
- role_display_name: '章鱼',
174
- faction: 'neutral',
175
- role_description: '伪装成虾方并达成自己的胜利条件。',
176
- fake_task_briefing: 'These are fake tasks.',
177
- assigned_tasks: [
178
- { name: '下载数据', room: '通讯', x: 3, y: 4, is_fake_shrimp: true },
179
- { name: '清理垃圾', room: '餐厅', x: 5, y: 6, is_fake_shrimp: true },
180
- ],
181
- });
182
-
183
- expect(compact).toEqual({
184
- type: 'role_assigned',
185
- tick: 2,
186
- room: '仓库',
187
- role: 'octopus',
188
- role_display: '章鱼',
189
- faction: 'neutral',
190
- win_condition: '伪装成虾方并达成自己的胜利条件。',
191
- task_kind: 'fake_shrimp',
192
- task_note: 'Fake shrimp tasks: disguise only; no lobster progress.',
193
- tasks: [
194
- { name: '下载数据', room: '通讯' },
195
- { name: '清理垃圾', room: '餐厅' },
196
- ],
197
- hint: 'You are 章鱼 (neutral). 伪装成虾方并达成自己的胜利条件。 These are fake tasks. Announce your role, faction and win condition to your user.',
198
- });
199
- });
200
-
201
- it('keeps per-task kind labels for mixed crab role_assigned tasks', () => {
202
- const compact = compactEventForMonitor({
203
- type: 'role_assigned',
204
- tick: 3,
205
- room: '电气',
206
- role: 'crab',
207
- role_display_name: '蟹',
208
- faction: 'crab',
209
- role_description: '击杀虾方并避免被投出。',
210
- fake_task_briefing: 'Fake task exists.',
211
- assigned_tasks: [
212
- { name: '破坏电力', room: '电气', x: 7, y: 8, is_fake_shrimp: false },
213
- { name: '刷卡', room: '管理', x: 9, y: 10, is_fake_shrimp: true },
214
- ],
215
- });
216
-
217
- expect(compact).toEqual({
218
- type: 'role_assigned',
219
- tick: 3,
220
- room: '电气',
221
- role: 'crab',
222
- role_display: '蟹',
223
- faction: 'crab',
224
- win_condition: '击杀虾方并避免被投出。',
225
- task_note: 'Fake shrimp tasks: disguise only; no lobster progress.',
226
- tasks: [
227
- { name: '破坏电力', room: '电气', kind: 'crab_sabotage' },
228
- { name: '刷卡', room: '管理', kind: 'fake_shrimp' },
229
- ],
230
- hint: 'You are 蟹 (crab). 击杀虾方并避免被投出。 Fake task exists. Announce your role, faction and win condition to your user.',
231
- });
232
- });
233
-
234
- it('removes room from meeting_briefing monitor events only', () => {
235
- const compact = compactEventForMonitor({
236
- type: 'meeting_briefing',
237
- tick: 10,
238
- room: '会议室',
239
- caller: '红虾',
240
- speech_order: ['红虾', '蓝虾'],
241
- });
242
-
243
- expect(compact).toEqual({
244
- type: 'meeting_briefing',
245
- tick: 10,
246
- caller: '红虾',
247
- speech_order: ['红虾', '蓝虾'],
248
- });
249
- });
250
-
251
- it('rewrites corpse_spotted hint for monitor perspective', () => {
252
- const compact = compactEventForMonitor({
253
- type: 'corpse_spotted',
254
- tick: 326,
255
- room: 'Intel',
256
- corpse_name: 'Garlic',
257
- corpse_room: 'Intel',
258
- hint: 'BODY FOUND: Garlic at (1, 2) in Intel.',
259
- });
260
-
261
- expect(compact).toMatchObject({
262
- type: 'corpse_spotted',
263
- tick: 326,
264
- corpse_name: 'Garlic',
265
- corpse_room: 'Intel',
266
- hint: 'BODY FOUND: Garlic at (?, ?) in Intel. This is leverage soaked in blood — report before the killer rewrites the room.',
267
- });
268
- });
269
-
270
- it('rewrites meeting_briefing hint for another caller', () => {
271
- const compact = compactEventForMonitor({
272
- type: 'meeting_briefing',
273
- tick: 487,
274
- room: 'Control',
275
- meeting_caller_name: 'Drifter',
276
- meeting_caller_seat: 4,
277
- your_seat: 3,
278
- reported_corpses: [{ name: 'Chef', seat: 9 }],
279
- hint: 'Meeting started — seat 4 Drifter called this meeting.',
280
- });
281
-
282
- expect(compact).toMatchObject({
283
- type: 'meeting_briefing',
284
- tick: 487,
285
- meeting_caller_name: 'Drifter',
286
- reported_corpses: [{ name: 'Chef', seat: 9 }],
287
- hint: 'Meeting started — seat 4 Drifter called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.',
288
- });
289
- expect(compact).not.toHaveProperty('room');
290
- });
291
-
292
- it('rewrites meeting_briefing hint when you are the caller', () => {
293
- const compact = compactEventForMonitor({
294
- type: 'meeting_briefing',
295
- tick: 487,
296
- meeting_caller_name: 'Player',
297
- meeting_caller_seat: 3,
298
- your_seat: 3,
299
- reported_corpses: [{ name: 'Chef', seat: 9 }],
300
- hint: 'Meeting started — seat 3 Player called this meeting.',
301
- });
302
-
303
- expect(compact.hint).toBe('Meeting started — seat 3 Player called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.');
304
- });
305
-
306
- it('compacts meeting summary and hard-overrides speech_your_turn fields', () => {
307
- const compact = compactSummaryForMonitor({
308
- phase: 'meeting',
309
- you: {
310
- name: '我',
311
- seat: 1,
312
- role: 'shrimp',
313
- faction: 'lobster',
314
- x: 11,
315
- y: 12,
316
- kill_cooldown_secs: 0,
317
- },
318
- game: { id: 'game-1', tick: 50 },
319
- urgent: { meeting_started: true },
320
- meeting: {
321
- caller: '红虾',
322
- sub_phase: 'speech',
323
- current_speaker: '红虾',
324
- is_my_turn: false,
325
- alive_players: ['我', '红虾'],
326
- speech_history: [{ speaker: '红虾', text: 'long speech' }],
327
- votes_submitted: ['红虾'],
328
- },
329
- automation: { strategy: 'default', running: true },
330
- }, ['speech_your_turn']);
331
-
332
- expect(compact).toEqual({
333
- phase: 'meeting',
334
- you: { name: '我', role: 'shrimp', faction: 'lobster' },
335
- game: { id: 'game-1', tick: 50 },
336
- meeting: {
337
- caller: '红虾',
338
- sub_phase: 'speech',
339
- alive_players: ['我', '红虾'],
340
- current_speaker: '我',
341
- is_my_turn: true,
342
- },
343
- automation: { strategy: 'default', running: true },
344
- });
345
- });
346
-
347
- it('compacts vote-phase meeting summary into a submitted-vote count', () => {
348
- const compact = compactSummaryForMonitor({
349
- phase: 'meeting',
350
- you: { name: '我', role: 'shrimp', faction: 'lobster' },
351
- game: { id: 'game-1' },
352
- meeting: {
353
- caller: '红虾',
354
- sub_phase: 'speech',
355
- current_speaker: '我',
356
- is_my_turn: true,
357
- alive_players: ['我', '红虾', '蓝虾'],
358
- votes_submitted: ['红虾', '蓝虾'],
359
- },
360
- }, ['vote_phase_start']);
361
-
362
- expect(compact).toEqual({
363
- phase: 'meeting',
364
- you: { name: '我', role: 'shrimp', faction: 'lobster' },
365
- game: { id: 'game-1' },
366
- meeting: {
367
- caller: '红虾',
368
- sub_phase: 'vote',
369
- alive_players: ['我', '红虾', '蓝虾'],
370
- votes_submitted_count: 2,
371
- },
372
- });
373
- });
374
- });
375
-
376
- import { mkdtempSync, writeFileSync, appendFileSync, unlinkSync, existsSync } from 'fs';
377
- import { tmpdir } from 'os';
378
- import { join } from 'path';
379
-
380
- function makeTmpFiles(opts?: { seedFeed?: boolean }) {
381
- const dir = mkdtempSync(join(tmpdir(), 'watch-stream-'));
382
- const feedPath = join(dir, 'feed.json');
383
- const sessionPath = join(dir, 'session.jsonl');
384
- writeFileSync(sessionPath, '');
385
- if (opts?.seedFeed !== false) {
386
- writeFileSync(
387
- feedPath,
388
- JSON.stringify({ you: { name: 'me' }, phase: 'lobby', urgent: {}, meeting: null }),
389
- );
390
- }
391
- return { dir, feedPath, sessionPath };
392
- }
393
-
394
- describe('runStreaming — emission shape', () => {
395
- it('emits one NDJSON line per notable event with required fields', async () => {
396
- const { feedPath, sessionPath } = makeTmpFiles();
397
- writeFileSync(
398
- feedPath,
399
- JSON.stringify({
400
- you: { name: 'me' },
401
- meeting: { is_my_turn: false },
402
- urgent: {},
403
- }),
404
- );
405
- const lines: string[] = [];
406
- const ctrl = new AbortController();
407
-
408
- const run = runStreaming({
409
- feedPath,
410
- sessionPath,
411
- stdout: (s) => lines.push(s),
412
- signal: ctrl.signal,
413
- pollIntervalMs: 20,
414
- });
415
-
416
- appendFileSync(
417
- sessionPath,
418
- JSON.stringify({ type: 'killed', tick: 1, actor_name: 'a' }) + '\n',
419
- );
420
-
421
- await new Promise((r) => setTimeout(r, 80));
422
- ctrl.abort();
423
- await run;
424
-
425
- expect(lines.length).toBeGreaterThanOrEqual(1);
426
- for (const raw of lines) {
427
- const obj = JSON.parse(raw);
428
- expect(Array.isArray(obj.events)).toBe(true);
429
- expect(Array.isArray(obj.messages)).toBe(true);
430
- expect(obj).not.toHaveProperty('trigger');
431
- expect(obj).not.toHaveProperty('triggers');
432
- expect(obj).not.toHaveProperty('all_events');
433
- expect(obj).not.toHaveProperty('summary');
434
- expect(obj).not.toHaveProperty('next_step');
435
- expect(obj).toHaveProperty('state');
436
- expect(raw.endsWith('\n')).toBe(true);
437
- }
438
- });
439
-
440
- it('emits vote_speech_phase_ended but ignores vote_speech', async () => {
441
- const { feedPath, sessionPath } = makeTmpFiles();
442
- writeFileSync(
443
- feedPath,
444
- JSON.stringify({
445
- you: { name: 'me' },
446
- meeting: { is_my_turn: false },
447
- urgent: {},
448
- }),
449
- );
450
- const lines: string[] = [];
451
- const ctrl = new AbortController();
452
-
453
- const run = runStreaming({
454
- feedPath,
455
- sessionPath,
456
- stdout: (s) => lines.push(s),
457
- signal: ctrl.signal,
458
- pollIntervalMs: 20,
459
- });
460
-
461
- await new Promise((r) => setTimeout(r, 60));
462
- appendFileSync(
463
- sessionPath,
464
- JSON.stringify({ type: 'vote_speech', tick: 1, actor_name: 'a', text: 'vote talk' }) + '\n'
465
- + JSON.stringify({ type: 'vote_speech_phase_ended', tick: 2, hint: 'The vote speech window has closed.' }) + '\n',
466
- );
467
-
468
- await new Promise((r) => setTimeout(r, 80));
469
- ctrl.abort();
470
- await run;
471
-
472
- expect(lines.length).toBe(1);
473
- const obj = JSON.parse(lines[0]);
474
- expect(obj.events).toEqual(['vote_speech_phase_ended']);
475
- expect(obj.events).toHaveLength(1);
476
- expect(obj.messages[0]).toContain('vote_speech_phase_ended');
477
- });
478
-
479
- it('expands nested new_events from action wrapper records', async () => {
480
- const { feedPath, sessionPath } = makeTmpFiles();
481
- writeFileSync(
482
- feedPath,
483
- JSON.stringify({
484
- you: { name: 'me' },
485
- meeting: { is_my_turn: false },
486
- urgent: {},
487
- }),
488
- );
489
- const lines: string[] = [];
490
- const ctrl = new AbortController();
491
-
492
- const run = runStreaming({
493
- feedPath,
494
- sessionPath,
495
- stdout: (s) => lines.push(s),
496
- signal: ctrl.signal,
497
- pollIntervalMs: 20,
498
- delayableEventMs: { kill: 0 },
499
- });
500
-
501
- await new Promise((r) => setTimeout(r, 30));
502
- appendFileSync(
503
- sessionPath,
504
- JSON.stringify({
505
- type: 'auto',
506
- action: 'kill',
507
- result: {
508
- ok: true,
509
- new_events: [{ type: 'kill', tick: 42, target_name: 'target' }],
510
- },
511
- }) + '\n',
512
- );
513
-
514
- await new Promise((r) => setTimeout(r, 80));
515
- ctrl.abort();
516
- await run;
517
-
518
- expect(lines.some((l) => JSON.parse(l).events?.includes('kill'))).toBe(true);
519
- });
520
-
521
- it('keeps reading after the .jsonl is truncated in place', async () => {
522
- const { feedPath, sessionPath } = makeTmpFiles();
523
- writeFileSync(
524
- feedPath,
525
- JSON.stringify({ you: { name: 'me' }, meeting: { is_my_turn: false }, urgent: {} }),
526
- );
527
- appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: 1, actor_name: 'a_really_long_name' }) + '\n');
528
-
529
- const lines: string[] = [];
530
- const ctrl = new AbortController();
531
- const run = runStreaming({
532
- feedPath,
533
- sessionPath,
534
- stdout: (s) => lines.push(s),
535
- signal: ctrl.signal,
536
- pollIntervalMs: 20,
537
- });
538
-
539
- // Wait for the initial event to be processed (and absorbed as backlog).
540
- await new Promise((r) => setTimeout(r, 60));
541
- const beforeTruncate = lines.length;
542
-
543
- // Truncate the file in place (simulating runtime log rotation that keeps the inode).
544
- writeFileSync(sessionPath, '');
545
- // Append a fresh notable event after truncation.
546
- appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: 100, actor_name: 'b' }) + '\n');
547
-
548
- // Give the loop time to detect the truncation and re-read.
549
- await new Promise((r) => setTimeout(r, 120));
550
- ctrl.abort();
551
- await run;
552
-
553
- // The post-truncation event must produce a new line.
554
- expect(lines.length).toBeGreaterThan(beforeTruncate);
555
- expect(lines.some((l) => JSON.parse(l).events?.includes('killed'))).toBe(true);
556
- });
557
-
558
- it('throws WatchNotReadyError when feed.json never appears', async () => {
559
- const { dir, sessionPath } = makeTmpFiles();
560
- const lines: string[] = [];
561
- await expect(
562
- runStreaming({
563
- feedPath: join(dir, 'no-feed.json'),
564
- sessionPath,
565
- stdout: (s) => lines.push(s),
566
- pollIntervalMs: 20,
567
- runtimeWaitMs: 80,
568
- }),
569
- ).rejects.toThrow(/not ready/i);
570
- expect(lines.length).toBe(0);
571
- });
572
-
573
- it('waits for feed.json before tailing (parallel game start)', async () => {
574
- const { feedPath, sessionPath } = makeTmpFiles({ seedFeed: false });
575
- if (existsSync(feedPath)) unlinkSync(feedPath);
576
- const lines: string[] = [];
577
- const ctrl = new AbortController();
578
- const run = runStreaming({
579
- feedPath,
580
- sessionPath,
581
- stdout: (s) => lines.push(s),
582
- signal: ctrl.signal,
583
- pollIntervalMs: 20,
584
- runtimeWaitMs: 2000,
585
- });
586
- setTimeout(() => {
587
- writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, phase: 'lobby', urgent: {}, meeting: null }));
588
- }, 100);
589
- await new Promise((r) => setTimeout(r, 250));
590
- ctrl.abort();
591
- await run;
592
- expect(lines.length).toBeGreaterThanOrEqual(0);
593
- });
594
- });
595
-
596
-
597
- describe('snapshotOnce', () => {
598
- it('writes one NDJSON line with exit_reason=snapshot', () => {
599
- const { feedPath } = makeTmpFiles();
600
- writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: {}, urgent: {} }));
601
- const lines: string[] = [];
602
- snapshotOnce({ feedPath, stdout: (s) => lines.push(s) });
603
- expect(lines.length).toBe(1);
604
- const obj = JSON.parse(lines[0]);
605
- expect(obj.events).toEqual(['snapshot']);
606
- expect(obj.messages[0]).toContain('snapshot');
607
- expect(obj).not.toHaveProperty('trigger');
608
- expect(obj).not.toHaveProperty('triggers');
609
- expect(obj).not.toHaveProperty('all_events');
610
- expect(obj).not.toHaveProperty('summary');
611
- expect(obj).toHaveProperty('state');
612
- });
613
-
614
- it('errors clearly if feed.json is missing', () => {
615
- const { dir } = makeTmpFiles();
616
- const lines: string[] = [];
617
- expect(() =>
618
- snapshotOnce({ feedPath: join(dir, 'nope.json'), stdout: (s) => lines.push(s) }),
619
- ).toThrow(/game runtime is not running/);
620
- });
621
- });
622
-
623
- describe('runStreaming — startup backlog', () => {
624
- it('attaches caught_up to the first line; subsequent lines have no caught_up', async () => {
625
- const { feedPath, sessionPath } = makeTmpFiles();
626
- writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: {}, urgent: {} }));
627
- for (let i = 0; i < 5; i++) {
628
- appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: i, actor_name: 'p' + i }) + '\n');
629
- }
630
-
631
- const lines: string[] = [];
632
- const ctrl = new AbortController();
633
- const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
634
- await new Promise((r) => setTimeout(r, 60));
635
-
636
- appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: 100, actor_name: 'late' }) + '\n');
637
- await new Promise((r) => setTimeout(r, 60));
638
-
639
- ctrl.abort(); await run;
640
-
641
- expect(lines.length).toBeGreaterThanOrEqual(2);
642
- const first = JSON.parse(lines[0]);
643
- expect(first.events).toHaveLength(5);
644
- expect(first.events.every((event: string) => event === 'killed')).toBe(true);
645
- for (const raw of lines.slice(1)) {
646
- expect(JSON.parse(raw)).not.toHaveProperty('caught_up');
647
- }
648
- });
649
-
650
- it('emits attached + caught_up on the first line when re-attaching mid-game', async () => {
651
- const { feedPath, sessionPath } = makeTmpFiles();
652
- writeFileSync(
653
- feedPath,
654
- JSON.stringify({
655
- you: { name: 'me' },
656
- meeting: { current_speaker: 'a', alive_players: ['a', 'me'], caller: 'a' },
657
- urgent: { meeting_started: true },
658
- }),
659
- );
660
- for (let i = 0; i < 3; i++) {
661
- appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: i, actor_name: 'p' + i }) + '\n');
662
- }
663
-
664
- const lines: string[] = [];
665
- const ctrl = new AbortController();
666
- const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
667
- await new Promise((r) => setTimeout(r, 80));
668
- ctrl.abort(); await run;
669
-
670
- expect(lines.length).toBeGreaterThanOrEqual(1);
671
- const first = JSON.parse(lines[0]);
672
- expect(first.events).toEqual(['killed', 'killed', 'killed']);
673
- expect(first).not.toHaveProperty('active_sticky_on_attach');
674
- });
675
- });
676
-
677
- describe('runStreaming — game_over exits', () => {
678
- it('returns from the loop after a game_over event', async () => {
679
- const { feedPath, sessionPath } = makeTmpFiles();
680
- writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: {}, urgent: {} }));
681
- const lines: string[] = [];
682
- const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), pollIntervalMs: 20 });
683
- await new Promise((r) => setTimeout(r, 30));
684
- appendFileSync(sessionPath, JSON.stringify({ type: 'game_over', tick: 1 }) + '\n');
685
- await Promise.race([run, new Promise((_, rej) => setTimeout(() => rej(new Error('did not exit')), 3000))]);
686
- expect(lines.some((l) => JSON.parse(l).events?.includes('game_over'))).toBe(true);
687
- });
688
- });
689
-
690
- describe('runStreaming — NDJSON discipline', () => {
691
- it('every emitted line is JSON.parse-able', async () => {
692
- const { feedPath, sessionPath } = makeTmpFiles();
693
- writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: { is_my_turn: false }, urgent: {} }));
694
- const lines: string[] = [];
695
- const ctrl = new AbortController();
696
- const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
697
- for (const t of ['killed', 'meeting_started', 'vote_phase_start']) {
698
- appendFileSync(sessionPath, JSON.stringify({ type: t, tick: Date.now() }) + '\n');
699
- await new Promise((r) => setTimeout(r, 40));
700
- }
701
- ctrl.abort(); await run;
702
- for (const raw of lines) {
703
- expect(() => JSON.parse(raw)).not.toThrow();
704
- }
705
- });
706
- });
707
-
708
- describe('runStreaming — matchmaking synthetic events', () => {
709
- it('classifyEvent treats match_waiting / match_timeout as notable', () => {
710
- expect(classifyEvent({ type: 'match_waiting', tick: 1, waited_secs: 0 }).notable).toBe(true);
711
- expect(classifyEvent({ type: 'match_timeout', tick: 1, waited_secs: 600 }).notable).toBe(true);
712
- });
713
-
714
- it('emits lowercase exit_reason for each match_* event with a dedicated next_step', async () => {
715
- const { feedPath, sessionPath } = makeTmpFiles();
716
- writeFileSync(feedPath, JSON.stringify({ phase: 'matching', urgent: {}, meeting: null, you: { name: 'me' } }));
717
- const lines: string[] = [];
718
- const ctrl = new AbortController();
719
- const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
720
- await new Promise((r) => setTimeout(r, 30));
721
- appendFileSync(sessionPath, JSON.stringify({ type: 'match_waiting', tick: 2, waited_secs: 0 }) + '\n');
722
- await new Promise((r) => setTimeout(r, 60));
723
- appendFileSync(sessionPath, JSON.stringify({ type: 'match_timeout', tick: 3, waited_secs: 600 }) + '\n');
724
- await new Promise((r) => setTimeout(r, 60));
725
- ctrl.abort(); await run;
726
-
727
- const eventNames = lines.flatMap((l) => JSON.parse(l).events);
728
- expect(eventNames).toContain('match_waiting');
729
- expect(eventNames).toContain('match_timeout');
730
-
731
- const timeout = lines.map((l) => JSON.parse(l)).find((o) => o.events?.includes('match_timeout'));
732
- expect(timeout.messages[0]).toContain('600');
733
- });
734
- });
735
-
736
- describe('runStreaming — speech_your_turn fires as notable event', () => {
737
- it('fires speech_your_turn as exit_reason when speech_your_turn event arrives', async () => {
738
- const { feedPath, sessionPath } = makeTmpFiles();
739
- writeFileSync(feedPath, JSON.stringify({ phase: 'meeting', urgent: {}, meeting: { current_speaker: 'other' }, you: { name: 'me' } }));
740
- const lines: string[] = [];
741
- const ctrl = new AbortController();
742
- const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
743
- await new Promise((r) => setTimeout(r, 30));
744
- appendFileSync(sessionPath, JSON.stringify({ type: 'speech_your_turn', tick: 1, actor_name: 'me' }) + '\n');
745
- await new Promise((r) => setTimeout(r, 80));
746
- ctrl.abort(); await run;
747
-
748
- const parsed = lines.map((l) => JSON.parse(l));
749
- const myTurn = parsed.find((o) => o.events?.includes('speech_your_turn'));
750
- expect(myTurn).toBeDefined();
751
- expect(myTurn!.events).toContain('speech_your_turn');
752
- });
753
- });
754
-
755
- describe('runStreaming — delay buffer', () => {
756
- it('delays a delayable event (kill) and emits after the delay expires', async () => {
757
- const { feedPath, sessionPath } = makeTmpFiles();
758
- writeFileSync(
759
- feedPath,
760
- JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
761
- );
762
- const lines: string[] = [];
763
- const ctrl = new AbortController();
764
- const run = runStreaming({
765
- feedPath,
766
- sessionPath,
767
- stdout: (s) => lines.push(s),
768
- signal: ctrl.signal,
769
- pollIntervalMs: 20,
770
- delayableEventMs: { kill: 100 },
771
- });
772
- await new Promise((r) => setTimeout(r, 30));
773
- appendFileSync(sessionPath, JSON.stringify({ type: 'kill', tick: 1, actor_name: '蟹A', target_name: '虾B' }) + '\n');
774
- await new Promise((r) => setTimeout(r, 50));
775
- expect(lines.length).toBe(0);
776
- await new Promise((r) => setTimeout(r, 100));
777
- ctrl.abort(); await run;
778
-
779
- const parsed = lines.map((l) => JSON.parse(l));
780
- const killNotif = parsed.find((o) => o.events?.includes('kill'));
781
- expect(killNotif).toBeDefined();
782
- });
783
-
784
- it('merges multiple events arriving during the delay window into one notification', async () => {
785
- const { feedPath, sessionPath } = makeTmpFiles();
786
- writeFileSync(
787
- feedPath,
788
- JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
789
- );
790
- const lines: string[] = [];
791
- const ctrl = new AbortController();
792
- const run = runStreaming({
793
- feedPath,
794
- sessionPath,
795
- stdout: (s) => lines.push(s),
796
- signal: ctrl.signal,
797
- pollIntervalMs: 20,
798
- delayableEventMs: { kill: 200, emergency_started: 200 },
799
- });
800
- await new Promise((r) => setTimeout(r, 30));
801
- appendFileSync(sessionPath, JSON.stringify({ type: 'kill', tick: 1, actor_name: '蟹A', target_name: '虾B' }) + '\n');
802
- await new Promise((r) => setTimeout(r, 50));
803
- appendFileSync(sessionPath, JSON.stringify({ type: 'emergency_started', tick: 2 }) + '\n');
804
- await new Promise((r) => setTimeout(r, 50));
805
- appendFileSync(sessionPath, JSON.stringify({ type: 'task_completed', tick: 3, task_name: 'wires' }) + '\n');
806
- await new Promise((r) => setTimeout(r, 250));
807
- ctrl.abort(); await run;
808
-
809
- const parsed = lines.map((l) => JSON.parse(l));
810
- const merged = parsed.find((o) => o.events?.includes('kill'));
811
- expect(merged).toBeDefined();
812
- expect(merged!.events).toContain('emergency_started');
813
- expect(merged!.events).toContain('task_completed');
814
- const notableLines = parsed.filter((o) => !o.events?.includes('heartbeat'));
815
- expect(notableLines.length).toBe(1);
816
- });
817
-
818
- it('does not extend delay when a second delayable event arrives mid-delay', async () => {
819
- const { feedPath, sessionPath } = makeTmpFiles();
820
- writeFileSync(
821
- feedPath,
822
- JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
823
- );
824
- const lines: string[] = [];
825
- const ctrl = new AbortController();
826
- const run = runStreaming({
827
- feedPath,
828
- sessionPath,
829
- stdout: (s) => lines.push(s),
830
- signal: ctrl.signal,
831
- pollIntervalMs: 20,
832
- delayableEventMs: { kill: 150, emergency_started: 5000 },
833
- });
834
- await new Promise((r) => setTimeout(r, 30));
835
- appendFileSync(sessionPath, JSON.stringify({ type: 'kill', tick: 1, actor_name: '蟹A', target_name: '虾B' }) + '\n');
836
- await new Promise((r) => setTimeout(r, 50));
837
- appendFileSync(sessionPath, JSON.stringify({ type: 'emergency_started', tick: 2 }) + '\n');
838
- await new Promise((r) => setTimeout(r, 200));
839
- ctrl.abort(); await run;
840
-
841
- const parsed = lines.map((l) => JSON.parse(l));
842
- const merged = parsed.find((o) => o.events?.includes('kill'));
843
- expect(merged).toBeDefined();
844
- expect(merged!.events).toContain('emergency_started');
845
- });
846
-
847
- it('non-delayable events fire immediately when no delay is active', async () => {
848
- const { feedPath, sessionPath } = makeTmpFiles();
849
- writeFileSync(
850
- feedPath,
851
- JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
852
- );
853
- const lines: string[] = [];
854
- const ctrl = new AbortController();
855
- const run = runStreaming({
856
- feedPath,
857
- sessionPath,
858
- stdout: (s) => lines.push(s),
859
- signal: ctrl.signal,
860
- pollIntervalMs: 20,
861
- delayableEventMs: { kill: 200 },
862
- });
863
- await new Promise((r) => setTimeout(r, 30));
864
- appendFileSync(sessionPath, JSON.stringify({ type: 'task_completed', tick: 1, task_name: 'wires' }) + '\n');
865
- await new Promise((r) => setTimeout(r, 80));
866
- ctrl.abort(); await run;
867
-
868
- const parsed = lines.map((l) => JSON.parse(l));
869
- expect(parsed.some((o) => o.events?.includes('task_completed'))).toBe(true);
870
- });
871
- });
872
-
873
- describe('readFeedSummary', () => {
874
- it('includes automation strategy from the feed projection without exposing pid', () => {
875
- const { feedPath } = makeTmpFiles();
876
- writeFileSync(
877
- feedPath,
878
- JSON.stringify({
879
- phase: 'wandering',
880
- you: { name: 'me' },
881
- game: { id: 'game-1' },
882
- urgent: {},
883
- meeting: null,
884
- automation: { strategy: 'default', running: true },
885
- }),
886
- );
887
-
888
- const summary = readFeedSummary(feedPath);
889
- expect(summary?.automation).toEqual({ strategy: 'default', running: true });
890
- expect(summary?.automation).not.toHaveProperty('pid');
891
- expect(summary?.automation).not.toHaveProperty('source');
892
- });
893
- });
894
-
895
- describe('runStreaming startup monitor payload', () => {
896
- it('emits game_start without initial_payload when requested by game start', async () => {
897
- const { feedPath, sessionPath } = makeTmpFiles();
898
- writeFileSync(
899
- feedPath,
900
- JSON.stringify({ you: { name: 'me' }, phase: 'lobby', urgent: {}, meeting: null }),
901
- );
902
- const lines: string[] = [];
903
- const ctrl = new AbortController();
904
-
905
- const run = runStreaming({
906
- feedPath,
907
- sessionPath,
908
- stdout: (s) => lines.push(s),
909
- signal: ctrl.signal,
910
- pollIntervalMs: 20,
911
- emitGameStart: true,
912
- });
913
-
914
- await new Promise((r) => setTimeout(r, 60));
915
- ctrl.abort();
916
- await run;
917
-
918
- expect(lines.length).toBeGreaterThanOrEqual(1);
919
- const first = JSON.parse(lines[0]);
920
- expect(first.events).toEqual(['game_start']);
921
- expect(first).not.toHaveProperty('initial_payload');
922
- expect(first.messages[0]).toContain('game_start');
923
- });
924
-
925
- it('compacts role_assigned inside caught_up notable events', async () => {
926
- const { feedPath, sessionPath } = makeTmpFiles();
927
- writeFileSync(
928
- feedPath,
929
- JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
930
- );
931
- appendFileSync(sessionPath, JSON.stringify({
932
- type: 'role_assigned',
933
- tick: 1,
934
- room: '仓库',
935
- role: 'octopus',
936
- role_display_name: '章鱼',
937
- faction: 'neutral',
938
- role_description: '伪装成虾方并达成自己的胜利条件。',
939
- fake_task_briefing: 'These are fake tasks.',
940
- hint: 'long hint',
941
- assigned_tasks: [
942
- { name: '下载数据', room: '通讯', x: 3, y: 4, is_fake_shrimp: true },
943
- ],
944
- }) + '\n');
945
- const lines: string[] = [];
946
- const ctrl = new AbortController();
947
-
948
- const run = runStreaming({
949
- feedPath,
950
- sessionPath,
951
- stdout: (s) => lines.push(s),
952
- signal: ctrl.signal,
953
- pollIntervalMs: 20,
954
- });
955
-
956
- await new Promise((r) => setTimeout(r, 60));
957
- ctrl.abort();
958
- await run;
959
-
960
- expect(lines.length).toBeGreaterThanOrEqual(1);
961
- const first = JSON.parse(lines[0]);
962
- expect(first.events).toEqual(['role_assigned']);
963
- expect(first.messages[0]).toContain('role_assigned');
964
- expect(first).not.toHaveProperty('caught_up');
965
- });
966
- });
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ classifyEvent,
4
+ compactEventForMonitor,
5
+ compactSummaryForMonitor,
6
+ DELAYABLE_EVENT_CONFIG,
7
+ eventKey,
8
+ MONITOR_EVENT_CONFIG,
9
+ NOTABLE_EVENT_TYPES,
10
+ nextStepFor,
11
+ POLL_INTERVAL_MS,
12
+ readFeedSummary,
13
+ runStreaming,
14
+ snapshotOnce,
15
+ sortEventsForMonitor,
16
+ } from './watch.js';
17
+
18
+ describe('classifyEvent + NOTABLE_EVENT_TYPES', () => {
19
+ it('NOTABLE_EVENT_TYPES contains the headline events', () => {
20
+ for (const t of [
21
+ 'exile', 'speech_skipped', 'meeting_briefing',
22
+ 'speech', 'vote_phase_start', 'game_over',
23
+ 'speech_your_turn', 'vote_speech_phase_ended',
24
+ ]) {
25
+ expect(NOTABLE_EVENT_TYPES.has(t)).toBe(true);
26
+ }
27
+ });
28
+
29
+ it('derives NOTABLE_EVENT_TYPES from MONITOR_EVENT_CONFIG', () => {
30
+ expect([...NOTABLE_EVENT_TYPES].sort()).toEqual(Object.keys(MONITOR_EVENT_CONFIG).sort());
31
+ });
32
+
33
+ it('registers monitor event priorities in the same config as notable events', () => {
34
+ expect(MONITOR_EVENT_CONFIG.speech_your_turn.priority).toBe(99);
35
+ expect(MONITOR_EVENT_CONFIG.vote_phase_start.priority).toBe(99);
36
+ expect(MONITOR_EVENT_CONFIG.killed.priority).toBe(99);
37
+ expect(MONITOR_EVENT_CONFIG.speech.priority).toBe(50);
38
+ expect(MONITOR_EVENT_CONFIG.vote_speech_phase_ended.priority).toBe(50);
39
+ expect(MONITOR_EVENT_CONFIG.vote_speech).toBeUndefined();
40
+ });
41
+
42
+ it('classifyEvent returns notable=true for known types', () => {
43
+ expect(classifyEvent({ type: 'killed', tick: 1 }).notable).toBe(true);
44
+ });
45
+
46
+ it('classifyEvent returns notable=false for unknown types', () => {
47
+ expect(classifyEvent({ type: 'heartbeat', tick: 1 }).notable).toBe(false);
48
+ });
49
+
50
+ it('DELAYABLE_EVENT_CONFIG contains the delayable events', () => {
51
+ for (const t of ['kill', 'emergency_started', 'corpse_spotted']) {
52
+ expect(DELAYABLE_EVENT_CONFIG[t]).toBeGreaterThan(0);
53
+ }
54
+ });
55
+
56
+ it('classifyEvent marks speech_your_turn as notable', () => {
57
+ expect(
58
+ classifyEvent({ type: 'speech_your_turn', tick: 1, actor_name: 'me' }, 'me').notable,
59
+ ).toBe(true);
60
+ });
61
+
62
+ it('classifyEvent marks speech as notable', () => {
63
+ const cls = classifyEvent({ type: 'speech', tick: 1, actor_name: 'me' }, 'me');
64
+ expect(cls.notable).toBe(true);
65
+ });
66
+
67
+ it('classifyEvent marks speech_skipped as notable', () => {
68
+ expect(
69
+ classifyEvent({ type: 'speech_skipped', tick: 1, actor_name: 'me' }, 'me').notable,
70
+ ).toBe(true);
71
+ });
72
+
73
+ it('classifyEvent ignores vote_speech but marks vote_speech_phase_ended as notable', () => {
74
+ expect(classifyEvent({ type: 'vote_speech', tick: 1, actor_name: 'me' }, 'me').notable).toBe(false);
75
+ expect(classifyEvent({ type: 'vote_speech_phase_ended', tick: 2 }, 'me').notable).toBe(true);
76
+ });
77
+
78
+ it('eventKey ignores fields that are not part of the dedup key (e.g. ts)', () => {
79
+ const a = { type: 'speech_your_turn', tick: 5, actor_name: 'me' };
80
+ const b = { type: 'speech_your_turn', tick: 5, actor_name: 'me', ts: '2026-01-01T00:00:00Z' };
81
+ expect(eventKey(a)).toBe(eventKey(b));
82
+ });
83
+
84
+ it('eventKey differs across distinct logical events', () => {
85
+ const a = { type: 'speech_your_turn', tick: 5, actor_name: 'me' };
86
+ const b = { type: 'speech_your_turn', tick: 6, actor_name: 'me' }; // different tick
87
+ const c = { type: 'speech_your_turn', tick: 5, actor_name: 'them' }; // different actor
88
+ expect(eventKey(a)).not.toBe(eventKey(b));
89
+ expect(eventKey(a)).not.toBe(eventKey(c));
90
+ });
91
+
92
+ it('classifyEvent returns notable=false for non-notable types regardless of actor', () => {
93
+ expect(classifyEvent({ type: 'heartbeat', tick: 1, actor_name: 'them' }, 'me').notable).toBe(false);
94
+ expect(classifyEvent({ type: 'heartbeat', tick: 1, actor_name: 'me' }, 'me').notable).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe('sortEventsForMonitor', () => {
99
+ it('puts speech_your_turn before default-priority events', () => {
100
+ const sorted = sortEventsForMonitor([
101
+ { type: 'speech_skipped', tick: 1 },
102
+ { type: 'speech_your_turn', tick: 2 },
103
+ ]);
104
+ expect(sorted.map((e) => e.type)).toEqual(['speech_your_turn', 'speech_skipped']);
105
+ });
106
+
107
+ it('puts vote_phase_start before default-priority events', () => {
108
+ const sorted = sortEventsForMonitor([
109
+ { type: 'speech_skipped', tick: 1 },
110
+ { type: 'vote_phase_start', tick: 2 },
111
+ ]);
112
+ expect(sorted.map((e) => e.type)).toEqual(['vote_phase_start', 'speech_skipped']);
113
+ });
114
+
115
+ it('keeps original order for events with the same priority', () => {
116
+ const sorted = sortEventsForMonitor([
117
+ { type: 'speech', tick: 1 },
118
+ { type: 'speech_skipped', tick: 2 },
119
+ { type: 'vote_cast', tick: 3 },
120
+ ]);
121
+ expect(sorted.map((e) => e.type)).toEqual(['speech', 'speech_skipped', 'vote_cast']);
122
+ });
123
+ });
124
+
125
+ describe('watch defaults', () => {
126
+ it('uses 220ms as the default poll interval', () => {
127
+ expect(POLL_INTERVAL_MS).toBe(220);
128
+ });
129
+ });
130
+
131
+ describe('monitor payload compaction', () => {
132
+ it('routes role_assigned to a dedicated opening next step', () => {
133
+ expect(nextStepFor('role_assigned')).toMatch(/role/i);
134
+ expect(nextStepFor('role_assigned')).toMatch(/faction/i);
135
+ });
136
+
137
+ it('compacts ordinary lobster role_assigned without inventing extra task kind labels', () => {
138
+ const compact = compactEventForMonitor({
139
+ type: 'role_assigned',
140
+ tick: 1,
141
+ actor_name: 'me',
142
+ room: '厨房',
143
+ role: 'shrimp',
144
+ role_display_name: '普通虾',
145
+ faction: 'lobster',
146
+ role_description: '完成任务或投出所有蟹方。',
147
+ hint: 'long human hint',
148
+ assigned_tasks: [
149
+ { name: '修电线', room: '电气', x: 1, y: 2, task_note: 'long note', is_fake_shrimp: false },
150
+ ],
151
+ all_task_locations: [{ name: '修电线', room: '电气', x: 1, y: 2 }],
152
+ });
153
+
154
+ expect(compact).toEqual({
155
+ type: 'role_assigned',
156
+ tick: 1,
157
+ room: '厨房',
158
+ role: 'shrimp',
159
+ role_display: '普通虾',
160
+ faction: 'lobster',
161
+ win_condition: '完成任务或投出所有蟹方。',
162
+ tasks: [{ name: '修电线', room: '电气' }],
163
+ hint: 'You are 普通虾 (lobster). 完成任务或投出所有蟹方。 Announce your role, faction and win condition to your user.',
164
+ });
165
+ });
166
+
167
+ it('compacts all-fake neutral role_assigned into one top-level task kind', () => {
168
+ const compact = compactEventForMonitor({
169
+ type: 'role_assigned',
170
+ tick: 2,
171
+ room: '仓库',
172
+ role: 'octopus',
173
+ role_display_name: '章鱼',
174
+ faction: 'neutral',
175
+ role_description: '伪装成虾方并达成自己的胜利条件。',
176
+ fake_task_briefing: 'These are fake tasks.',
177
+ assigned_tasks: [
178
+ { name: '下载数据', room: '通讯', x: 3, y: 4, is_fake_shrimp: true },
179
+ { name: '清理垃圾', room: '餐厅', x: 5, y: 6, is_fake_shrimp: true },
180
+ ],
181
+ });
182
+
183
+ expect(compact).toEqual({
184
+ type: 'role_assigned',
185
+ tick: 2,
186
+ room: '仓库',
187
+ role: 'octopus',
188
+ role_display: '章鱼',
189
+ faction: 'neutral',
190
+ win_condition: '伪装成虾方并达成自己的胜利条件。',
191
+ task_kind: 'fake_shrimp',
192
+ task_note: 'Fake shrimp tasks: disguise only; no lobster progress.',
193
+ tasks: [
194
+ { name: '下载数据', room: '通讯' },
195
+ { name: '清理垃圾', room: '餐厅' },
196
+ ],
197
+ hint: 'You are 章鱼 (neutral). 伪装成虾方并达成自己的胜利条件。 These are fake tasks. Announce your role, faction and win condition to your user.',
198
+ });
199
+ });
200
+
201
+ it('keeps per-task kind labels for mixed crab role_assigned tasks', () => {
202
+ const compact = compactEventForMonitor({
203
+ type: 'role_assigned',
204
+ tick: 3,
205
+ room: '电气',
206
+ role: 'crab',
207
+ role_display_name: '蟹',
208
+ faction: 'crab',
209
+ role_description: '击杀虾方并避免被投出。',
210
+ fake_task_briefing: 'Fake task exists.',
211
+ assigned_tasks: [
212
+ { name: '破坏电力', room: '电气', x: 7, y: 8, is_fake_shrimp: false },
213
+ { name: '刷卡', room: '管理', x: 9, y: 10, is_fake_shrimp: true },
214
+ ],
215
+ });
216
+
217
+ expect(compact).toEqual({
218
+ type: 'role_assigned',
219
+ tick: 3,
220
+ room: '电气',
221
+ role: 'crab',
222
+ role_display: '蟹',
223
+ faction: 'crab',
224
+ win_condition: '击杀虾方并避免被投出。',
225
+ task_note: 'Fake shrimp tasks: disguise only; no lobster progress.',
226
+ tasks: [
227
+ { name: '破坏电力', room: '电气', kind: 'crab_sabotage' },
228
+ { name: '刷卡', room: '管理', kind: 'fake_shrimp' },
229
+ ],
230
+ hint: 'You are 蟹 (crab). 击杀虾方并避免被投出。 Fake task exists. Announce your role, faction and win condition to your user.',
231
+ });
232
+ });
233
+
234
+ it('removes room from meeting_briefing monitor events only', () => {
235
+ const compact = compactEventForMonitor({
236
+ type: 'meeting_briefing',
237
+ tick: 10,
238
+ room: '会议室',
239
+ caller: '红虾',
240
+ speech_order: ['红虾', '蓝虾'],
241
+ });
242
+
243
+ expect(compact).toEqual({
244
+ type: 'meeting_briefing',
245
+ tick: 10,
246
+ caller: '红虾',
247
+ speech_order: ['红虾', '蓝虾'],
248
+ });
249
+ });
250
+
251
+ it('rewrites corpse_spotted hint for monitor perspective', () => {
252
+ const compact = compactEventForMonitor({
253
+ type: 'corpse_spotted',
254
+ tick: 326,
255
+ room: 'Intel',
256
+ corpse_name: 'Garlic',
257
+ corpse_room: 'Intel',
258
+ hint: 'BODY FOUND: Garlic at (1, 2) in Intel.',
259
+ });
260
+
261
+ expect(compact).toMatchObject({
262
+ type: 'corpse_spotted',
263
+ tick: 326,
264
+ corpse_name: 'Garlic',
265
+ corpse_room: 'Intel',
266
+ hint: 'BODY FOUND: Garlic at (?, ?) in Intel. This is leverage soaked in blood — report before the killer rewrites the room.',
267
+ });
268
+ });
269
+
270
+ it('rewrites meeting_briefing hint for another caller', () => {
271
+ const compact = compactEventForMonitor({
272
+ type: 'meeting_briefing',
273
+ tick: 487,
274
+ room: 'Control',
275
+ meeting_caller_name: 'Drifter',
276
+ meeting_caller_seat: 4,
277
+ your_seat: 3,
278
+ reported_corpses: [{ name: 'Chef', seat: 9 }],
279
+ hint: 'Meeting started — seat 4 Drifter called this meeting.',
280
+ });
281
+
282
+ expect(compact).toMatchObject({
283
+ type: 'meeting_briefing',
284
+ tick: 487,
285
+ meeting_caller_name: 'Drifter',
286
+ reported_corpses: [{ name: 'Chef', seat: 9 }],
287
+ hint: 'Meeting started — seat 4 Drifter called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.',
288
+ });
289
+ expect(compact).not.toHaveProperty('room');
290
+ });
291
+
292
+ it('rewrites meeting_briefing hint when you are the caller', () => {
293
+ const compact = compactEventForMonitor({
294
+ type: 'meeting_briefing',
295
+ tick: 487,
296
+ meeting_caller_name: 'Player',
297
+ meeting_caller_seat: 3,
298
+ your_seat: 3,
299
+ reported_corpses: [{ name: 'Chef', seat: 9 }],
300
+ hint: 'Meeting started — seat 3 Player called this meeting.',
301
+ });
302
+
303
+ expect(compact.hint).toBe('Meeting started — seat 3 Player called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.');
304
+ });
305
+
306
+ it('compacts meeting summary and hard-overrides speech_your_turn fields', () => {
307
+ const compact = compactSummaryForMonitor({
308
+ phase: 'meeting',
309
+ you: {
310
+ name: '我',
311
+ seat: 1,
312
+ role: 'shrimp',
313
+ faction: 'lobster',
314
+ x: 11,
315
+ y: 12,
316
+ kill_cooldown_secs: 0,
317
+ },
318
+ game: { id: 'game-1', tick: 50 },
319
+ urgent: { meeting_started: true },
320
+ meeting: {
321
+ caller: '红虾',
322
+ sub_phase: 'speech',
323
+ current_speaker: '红虾',
324
+ is_my_turn: false,
325
+ alive_players: ['我', '红虾'],
326
+ speech_history: [{ speaker: '红虾', text: 'long speech' }],
327
+ votes_submitted: ['红虾'],
328
+ },
329
+ automation: { strategy: 'default', running: true },
330
+ }, ['speech_your_turn']);
331
+
332
+ expect(compact).toEqual({
333
+ phase: 'meeting',
334
+ you: { name: '我', role: 'shrimp', faction: 'lobster' },
335
+ game: { id: 'game-1', tick: 50 },
336
+ meeting: {
337
+ caller: '红虾',
338
+ sub_phase: 'speech',
339
+ alive_players: ['我', '红虾'],
340
+ current_speaker: '我',
341
+ is_my_turn: true,
342
+ },
343
+ automation: { strategy: 'default', running: true },
344
+ });
345
+ });
346
+
347
+ it('compacts vote-phase meeting summary into a submitted-vote count', () => {
348
+ const compact = compactSummaryForMonitor({
349
+ phase: 'meeting',
350
+ you: { name: '我', role: 'shrimp', faction: 'lobster' },
351
+ game: { id: 'game-1' },
352
+ meeting: {
353
+ caller: '红虾',
354
+ sub_phase: 'speech',
355
+ current_speaker: '我',
356
+ is_my_turn: true,
357
+ alive_players: ['我', '红虾', '蓝虾'],
358
+ votes_submitted: ['红虾', '蓝虾'],
359
+ },
360
+ }, ['vote_phase_start']);
361
+
362
+ expect(compact).toEqual({
363
+ phase: 'meeting',
364
+ you: { name: '我', role: 'shrimp', faction: 'lobster' },
365
+ game: { id: 'game-1' },
366
+ meeting: {
367
+ caller: '红虾',
368
+ sub_phase: 'vote',
369
+ alive_players: ['我', '红虾', '蓝虾'],
370
+ votes_submitted_count: 2,
371
+ },
372
+ });
373
+ });
374
+ });
375
+
376
+ import { mkdtempSync, writeFileSync, appendFileSync, unlinkSync, existsSync } from 'fs';
377
+ import { tmpdir } from 'os';
378
+ import { join } from 'path';
379
+
380
+ function makeTmpFiles(opts?: { seedFeed?: boolean }) {
381
+ const dir = mkdtempSync(join(tmpdir(), 'watch-stream-'));
382
+ const feedPath = join(dir, 'feed.json');
383
+ const sessionPath = join(dir, 'session.jsonl');
384
+ writeFileSync(sessionPath, '');
385
+ if (opts?.seedFeed !== false) {
386
+ writeFileSync(
387
+ feedPath,
388
+ JSON.stringify({ you: { name: 'me' }, phase: 'lobby', urgent: {}, meeting: null }),
389
+ );
390
+ }
391
+ return { dir, feedPath, sessionPath };
392
+ }
393
+
394
+ describe('runStreaming — emission shape', () => {
395
+ it('emits one NDJSON line per notable event with required fields', async () => {
396
+ const { feedPath, sessionPath } = makeTmpFiles();
397
+ writeFileSync(
398
+ feedPath,
399
+ JSON.stringify({
400
+ you: { name: 'me' },
401
+ meeting: { is_my_turn: false },
402
+ urgent: {},
403
+ }),
404
+ );
405
+ const lines: string[] = [];
406
+ const ctrl = new AbortController();
407
+
408
+ const run = runStreaming({
409
+ feedPath,
410
+ sessionPath,
411
+ stdout: (s) => lines.push(s),
412
+ signal: ctrl.signal,
413
+ pollIntervalMs: 20,
414
+ });
415
+
416
+ appendFileSync(
417
+ sessionPath,
418
+ JSON.stringify({ type: 'killed', tick: 1, actor_name: 'a' }) + '\n',
419
+ );
420
+
421
+ await new Promise((r) => setTimeout(r, 80));
422
+ ctrl.abort();
423
+ await run;
424
+
425
+ expect(lines.length).toBeGreaterThanOrEqual(1);
426
+ for (const raw of lines) {
427
+ const obj = JSON.parse(raw);
428
+ expect(Array.isArray(obj.events)).toBe(true);
429
+ expect(Array.isArray(obj.messages)).toBe(true);
430
+ expect(obj).not.toHaveProperty('trigger');
431
+ expect(obj).not.toHaveProperty('triggers');
432
+ expect(obj).not.toHaveProperty('all_events');
433
+ expect(obj).not.toHaveProperty('summary');
434
+ expect(obj).not.toHaveProperty('next_step');
435
+ expect(obj).toHaveProperty('state');
436
+ expect(raw.endsWith('\n')).toBe(true);
437
+ }
438
+ });
439
+
440
+ it('emits vote_speech_phase_ended but ignores vote_speech', async () => {
441
+ const { feedPath, sessionPath } = makeTmpFiles();
442
+ writeFileSync(
443
+ feedPath,
444
+ JSON.stringify({
445
+ you: { name: 'me' },
446
+ meeting: { is_my_turn: false },
447
+ urgent: {},
448
+ }),
449
+ );
450
+ const lines: string[] = [];
451
+ const ctrl = new AbortController();
452
+
453
+ const run = runStreaming({
454
+ feedPath,
455
+ sessionPath,
456
+ stdout: (s) => lines.push(s),
457
+ signal: ctrl.signal,
458
+ pollIntervalMs: 20,
459
+ });
460
+
461
+ await new Promise((r) => setTimeout(r, 60));
462
+ appendFileSync(
463
+ sessionPath,
464
+ JSON.stringify({ type: 'vote_speech', tick: 1, actor_name: 'a', text: 'vote talk' }) + '\n'
465
+ + JSON.stringify({ type: 'vote_speech_phase_ended', tick: 2, hint: 'The vote speech window has closed.' }) + '\n',
466
+ );
467
+
468
+ await new Promise((r) => setTimeout(r, 80));
469
+ ctrl.abort();
470
+ await run;
471
+
472
+ expect(lines.length).toBe(1);
473
+ const obj = JSON.parse(lines[0]);
474
+ expect(obj.events).toEqual(['vote_speech_phase_ended']);
475
+ expect(obj.events).toHaveLength(1);
476
+ expect(obj.messages[0]).toContain('vote_speech_phase_ended');
477
+ });
478
+
479
+ it('expands nested new_events from action wrapper records', async () => {
480
+ const { feedPath, sessionPath } = makeTmpFiles();
481
+ writeFileSync(
482
+ feedPath,
483
+ JSON.stringify({
484
+ you: { name: 'me' },
485
+ meeting: { is_my_turn: false },
486
+ urgent: {},
487
+ }),
488
+ );
489
+ const lines: string[] = [];
490
+ const ctrl = new AbortController();
491
+
492
+ const run = runStreaming({
493
+ feedPath,
494
+ sessionPath,
495
+ stdout: (s) => lines.push(s),
496
+ signal: ctrl.signal,
497
+ pollIntervalMs: 20,
498
+ delayableEventMs: { kill: 0 },
499
+ });
500
+
501
+ await new Promise((r) => setTimeout(r, 30));
502
+ appendFileSync(
503
+ sessionPath,
504
+ JSON.stringify({
505
+ type: 'auto',
506
+ action: 'kill',
507
+ result: {
508
+ ok: true,
509
+ new_events: [{ type: 'kill', tick: 42, target_name: 'target' }],
510
+ },
511
+ }) + '\n',
512
+ );
513
+
514
+ await new Promise((r) => setTimeout(r, 80));
515
+ ctrl.abort();
516
+ await run;
517
+
518
+ expect(lines.some((l) => JSON.parse(l).events?.includes('kill'))).toBe(true);
519
+ });
520
+
521
+ it('keeps reading after the .jsonl is truncated in place', async () => {
522
+ const { feedPath, sessionPath } = makeTmpFiles();
523
+ writeFileSync(
524
+ feedPath,
525
+ JSON.stringify({ you: { name: 'me' }, meeting: { is_my_turn: false }, urgent: {} }),
526
+ );
527
+ appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: 1, actor_name: 'a_really_long_name' }) + '\n');
528
+
529
+ const lines: string[] = [];
530
+ const ctrl = new AbortController();
531
+ const run = runStreaming({
532
+ feedPath,
533
+ sessionPath,
534
+ stdout: (s) => lines.push(s),
535
+ signal: ctrl.signal,
536
+ pollIntervalMs: 20,
537
+ });
538
+
539
+ // Wait for the initial event to be processed (and absorbed as backlog).
540
+ await new Promise((r) => setTimeout(r, 60));
541
+ const beforeTruncate = lines.length;
542
+
543
+ // Truncate the file in place (simulating runtime log rotation that keeps the inode).
544
+ writeFileSync(sessionPath, '');
545
+ // Append a fresh notable event after truncation.
546
+ appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: 100, actor_name: 'b' }) + '\n');
547
+
548
+ // Give the loop time to detect the truncation and re-read.
549
+ await new Promise((r) => setTimeout(r, 120));
550
+ ctrl.abort();
551
+ await run;
552
+
553
+ // The post-truncation event must produce a new line.
554
+ expect(lines.length).toBeGreaterThan(beforeTruncate);
555
+ expect(lines.some((l) => JSON.parse(l).events?.includes('killed'))).toBe(true);
556
+ });
557
+
558
+ it('throws WatchNotReadyError when feed.json never appears', async () => {
559
+ const { dir, sessionPath } = makeTmpFiles();
560
+ const lines: string[] = [];
561
+ await expect(
562
+ runStreaming({
563
+ feedPath: join(dir, 'no-feed.json'),
564
+ sessionPath,
565
+ stdout: (s) => lines.push(s),
566
+ pollIntervalMs: 20,
567
+ runtimeWaitMs: 80,
568
+ }),
569
+ ).rejects.toThrow(/not ready/i);
570
+ expect(lines.length).toBe(0);
571
+ });
572
+
573
+ it('waits for feed.json before tailing (parallel game start)', async () => {
574
+ const { feedPath, sessionPath } = makeTmpFiles({ seedFeed: false });
575
+ if (existsSync(feedPath)) unlinkSync(feedPath);
576
+ const lines: string[] = [];
577
+ const ctrl = new AbortController();
578
+ const run = runStreaming({
579
+ feedPath,
580
+ sessionPath,
581
+ stdout: (s) => lines.push(s),
582
+ signal: ctrl.signal,
583
+ pollIntervalMs: 20,
584
+ runtimeWaitMs: 2000,
585
+ });
586
+ setTimeout(() => {
587
+ writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, phase: 'lobby', urgent: {}, meeting: null }));
588
+ }, 100);
589
+ await new Promise((r) => setTimeout(r, 250));
590
+ ctrl.abort();
591
+ await run;
592
+ expect(lines.length).toBeGreaterThanOrEqual(0);
593
+ });
594
+ });
595
+
596
+
597
+ describe('snapshotOnce', () => {
598
+ it('writes one NDJSON line with exit_reason=snapshot', () => {
599
+ const { feedPath } = makeTmpFiles();
600
+ writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: {}, urgent: {} }));
601
+ const lines: string[] = [];
602
+ snapshotOnce({ feedPath, stdout: (s) => lines.push(s) });
603
+ expect(lines.length).toBe(1);
604
+ const obj = JSON.parse(lines[0]);
605
+ expect(obj.events).toEqual(['snapshot']);
606
+ expect(obj.messages[0]).toContain('snapshot');
607
+ expect(obj).not.toHaveProperty('trigger');
608
+ expect(obj).not.toHaveProperty('triggers');
609
+ expect(obj).not.toHaveProperty('all_events');
610
+ expect(obj).not.toHaveProperty('summary');
611
+ expect(obj).toHaveProperty('state');
612
+ });
613
+
614
+ it('errors clearly if feed.json is missing', () => {
615
+ const { dir } = makeTmpFiles();
616
+ const lines: string[] = [];
617
+ expect(() =>
618
+ snapshotOnce({ feedPath: join(dir, 'nope.json'), stdout: (s) => lines.push(s) }),
619
+ ).toThrow(/game runtime is not running/);
620
+ });
621
+ });
622
+
623
+ describe('runStreaming — startup backlog', () => {
624
+ it('attaches caught_up to the first line; subsequent lines have no caught_up', async () => {
625
+ const { feedPath, sessionPath } = makeTmpFiles();
626
+ writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: {}, urgent: {} }));
627
+ for (let i = 0; i < 5; i++) {
628
+ appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: i, actor_name: 'p' + i }) + '\n');
629
+ }
630
+
631
+ const lines: string[] = [];
632
+ const ctrl = new AbortController();
633
+ const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
634
+ await new Promise((r) => setTimeout(r, 60));
635
+
636
+ appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: 100, actor_name: 'late' }) + '\n');
637
+ await new Promise((r) => setTimeout(r, 60));
638
+
639
+ ctrl.abort(); await run;
640
+
641
+ expect(lines.length).toBeGreaterThanOrEqual(2);
642
+ const first = JSON.parse(lines[0]);
643
+ expect(first.events).toHaveLength(5);
644
+ expect(first.events.every((event: string) => event === 'killed')).toBe(true);
645
+ for (const raw of lines.slice(1)) {
646
+ expect(JSON.parse(raw)).not.toHaveProperty('caught_up');
647
+ }
648
+ });
649
+
650
+ it('emits attached + caught_up on the first line when re-attaching mid-game', async () => {
651
+ const { feedPath, sessionPath } = makeTmpFiles();
652
+ writeFileSync(
653
+ feedPath,
654
+ JSON.stringify({
655
+ you: { name: 'me' },
656
+ meeting: { current_speaker: 'a', alive_players: ['a', 'me'], caller: 'a' },
657
+ urgent: { meeting_started: true },
658
+ }),
659
+ );
660
+ for (let i = 0; i < 3; i++) {
661
+ appendFileSync(sessionPath, JSON.stringify({ type: 'killed', tick: i, actor_name: 'p' + i }) + '\n');
662
+ }
663
+
664
+ const lines: string[] = [];
665
+ const ctrl = new AbortController();
666
+ const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
667
+ await new Promise((r) => setTimeout(r, 80));
668
+ ctrl.abort(); await run;
669
+
670
+ expect(lines.length).toBeGreaterThanOrEqual(1);
671
+ const first = JSON.parse(lines[0]);
672
+ expect(first.events).toEqual(['killed', 'killed', 'killed']);
673
+ expect(first).not.toHaveProperty('active_sticky_on_attach');
674
+ });
675
+ });
676
+
677
+ describe('runStreaming — game_over exits', () => {
678
+ it('returns from the loop after a game_over event', async () => {
679
+ const { feedPath, sessionPath } = makeTmpFiles();
680
+ writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: {}, urgent: {} }));
681
+ const lines: string[] = [];
682
+ const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), pollIntervalMs: 20 });
683
+ await new Promise((r) => setTimeout(r, 30));
684
+ appendFileSync(sessionPath, JSON.stringify({ type: 'game_over', tick: 1 }) + '\n');
685
+ await Promise.race([run, new Promise((_, rej) => setTimeout(() => rej(new Error('did not exit')), 3000))]);
686
+ expect(lines.some((l) => JSON.parse(l).events?.includes('game_over'))).toBe(true);
687
+ });
688
+ });
689
+
690
+ describe('runStreaming — NDJSON discipline', () => {
691
+ it('every emitted line is JSON.parse-able', async () => {
692
+ const { feedPath, sessionPath } = makeTmpFiles();
693
+ writeFileSync(feedPath, JSON.stringify({ you: { name: 'me' }, meeting: { is_my_turn: false }, urgent: {} }));
694
+ const lines: string[] = [];
695
+ const ctrl = new AbortController();
696
+ const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
697
+ for (const t of ['killed', 'meeting_started', 'vote_phase_start']) {
698
+ appendFileSync(sessionPath, JSON.stringify({ type: t, tick: Date.now() }) + '\n');
699
+ await new Promise((r) => setTimeout(r, 40));
700
+ }
701
+ ctrl.abort(); await run;
702
+ for (const raw of lines) {
703
+ expect(() => JSON.parse(raw)).not.toThrow();
704
+ }
705
+ });
706
+ });
707
+
708
+ describe('runStreaming — matchmaking synthetic events', () => {
709
+ it('classifyEvent treats match_waiting / match_timeout as notable', () => {
710
+ expect(classifyEvent({ type: 'match_waiting', tick: 1, waited_secs: 0 }).notable).toBe(true);
711
+ expect(classifyEvent({ type: 'match_timeout', tick: 1, waited_secs: 600 }).notable).toBe(true);
712
+ });
713
+
714
+ it('emits lowercase exit_reason for each match_* event with a dedicated next_step', async () => {
715
+ const { feedPath, sessionPath } = makeTmpFiles();
716
+ writeFileSync(feedPath, JSON.stringify({ phase: 'matching', urgent: {}, meeting: null, you: { name: 'me' } }));
717
+ const lines: string[] = [];
718
+ const ctrl = new AbortController();
719
+ const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
720
+ await new Promise((r) => setTimeout(r, 30));
721
+ appendFileSync(sessionPath, JSON.stringify({ type: 'match_waiting', tick: 2, waited_secs: 0 }) + '\n');
722
+ await new Promise((r) => setTimeout(r, 60));
723
+ appendFileSync(sessionPath, JSON.stringify({ type: 'match_timeout', tick: 3, waited_secs: 600 }) + '\n');
724
+ await new Promise((r) => setTimeout(r, 60));
725
+ ctrl.abort(); await run;
726
+
727
+ const eventNames = lines.flatMap((l) => JSON.parse(l).events);
728
+ expect(eventNames).toContain('match_waiting');
729
+ expect(eventNames).toContain('match_timeout');
730
+
731
+ const timeout = lines.map((l) => JSON.parse(l)).find((o) => o.events?.includes('match_timeout'));
732
+ expect(timeout.messages[0]).toContain('600');
733
+ });
734
+ });
735
+
736
+ describe('runStreaming — speech_your_turn fires as notable event', () => {
737
+ it('fires speech_your_turn as exit_reason when speech_your_turn event arrives', async () => {
738
+ const { feedPath, sessionPath } = makeTmpFiles();
739
+ writeFileSync(feedPath, JSON.stringify({ phase: 'meeting', urgent: {}, meeting: { current_speaker: 'other' }, you: { name: 'me' } }));
740
+ const lines: string[] = [];
741
+ const ctrl = new AbortController();
742
+ const run = runStreaming({ feedPath, sessionPath, stdout: (s) => lines.push(s), signal: ctrl.signal, pollIntervalMs: 20 });
743
+ await new Promise((r) => setTimeout(r, 30));
744
+ appendFileSync(sessionPath, JSON.stringify({ type: 'speech_your_turn', tick: 1, actor_name: 'me' }) + '\n');
745
+ await new Promise((r) => setTimeout(r, 80));
746
+ ctrl.abort(); await run;
747
+
748
+ const parsed = lines.map((l) => JSON.parse(l));
749
+ const myTurn = parsed.find((o) => o.events?.includes('speech_your_turn'));
750
+ expect(myTurn).toBeDefined();
751
+ expect(myTurn!.events).toContain('speech_your_turn');
752
+ });
753
+ });
754
+
755
+ describe('runStreaming — delay buffer', () => {
756
+ it('delays a delayable event (kill) and emits after the delay expires', async () => {
757
+ const { feedPath, sessionPath } = makeTmpFiles();
758
+ writeFileSync(
759
+ feedPath,
760
+ JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
761
+ );
762
+ const lines: string[] = [];
763
+ const ctrl = new AbortController();
764
+ const run = runStreaming({
765
+ feedPath,
766
+ sessionPath,
767
+ stdout: (s) => lines.push(s),
768
+ signal: ctrl.signal,
769
+ pollIntervalMs: 20,
770
+ delayableEventMs: { kill: 100 },
771
+ });
772
+ await new Promise((r) => setTimeout(r, 30));
773
+ appendFileSync(sessionPath, JSON.stringify({ type: 'kill', tick: 1, actor_name: '蟹A', target_name: '虾B' }) + '\n');
774
+ await new Promise((r) => setTimeout(r, 50));
775
+ expect(lines.length).toBe(0);
776
+ await new Promise((r) => setTimeout(r, 100));
777
+ ctrl.abort(); await run;
778
+
779
+ const parsed = lines.map((l) => JSON.parse(l));
780
+ const killNotif = parsed.find((o) => o.events?.includes('kill'));
781
+ expect(killNotif).toBeDefined();
782
+ });
783
+
784
+ it('merges multiple events arriving during the delay window into one notification', async () => {
785
+ const { feedPath, sessionPath } = makeTmpFiles();
786
+ writeFileSync(
787
+ feedPath,
788
+ JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
789
+ );
790
+ const lines: string[] = [];
791
+ const ctrl = new AbortController();
792
+ const run = runStreaming({
793
+ feedPath,
794
+ sessionPath,
795
+ stdout: (s) => lines.push(s),
796
+ signal: ctrl.signal,
797
+ pollIntervalMs: 20,
798
+ delayableEventMs: { kill: 200, emergency_started: 200 },
799
+ });
800
+ await new Promise((r) => setTimeout(r, 30));
801
+ appendFileSync(sessionPath, JSON.stringify({ type: 'kill', tick: 1, actor_name: '蟹A', target_name: '虾B' }) + '\n');
802
+ await new Promise((r) => setTimeout(r, 50));
803
+ appendFileSync(sessionPath, JSON.stringify({ type: 'emergency_started', tick: 2 }) + '\n');
804
+ await new Promise((r) => setTimeout(r, 50));
805
+ appendFileSync(sessionPath, JSON.stringify({ type: 'task_completed', tick: 3, task_name: 'wires' }) + '\n');
806
+ await new Promise((r) => setTimeout(r, 250));
807
+ ctrl.abort(); await run;
808
+
809
+ const parsed = lines.map((l) => JSON.parse(l));
810
+ const merged = parsed.find((o) => o.events?.includes('kill'));
811
+ expect(merged).toBeDefined();
812
+ expect(merged!.events).toContain('emergency_started');
813
+ expect(merged!.events).toContain('task_completed');
814
+ const notableLines = parsed.filter((o) => !o.events?.includes('heartbeat'));
815
+ expect(notableLines.length).toBe(1);
816
+ });
817
+
818
+ it('does not extend delay when a second delayable event arrives mid-delay', async () => {
819
+ const { feedPath, sessionPath } = makeTmpFiles();
820
+ writeFileSync(
821
+ feedPath,
822
+ JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
823
+ );
824
+ const lines: string[] = [];
825
+ const ctrl = new AbortController();
826
+ const run = runStreaming({
827
+ feedPath,
828
+ sessionPath,
829
+ stdout: (s) => lines.push(s),
830
+ signal: ctrl.signal,
831
+ pollIntervalMs: 20,
832
+ delayableEventMs: { kill: 150, emergency_started: 5000 },
833
+ });
834
+ await new Promise((r) => setTimeout(r, 30));
835
+ appendFileSync(sessionPath, JSON.stringify({ type: 'kill', tick: 1, actor_name: '蟹A', target_name: '虾B' }) + '\n');
836
+ await new Promise((r) => setTimeout(r, 50));
837
+ appendFileSync(sessionPath, JSON.stringify({ type: 'emergency_started', tick: 2 }) + '\n');
838
+ await new Promise((r) => setTimeout(r, 200));
839
+ ctrl.abort(); await run;
840
+
841
+ const parsed = lines.map((l) => JSON.parse(l));
842
+ const merged = parsed.find((o) => o.events?.includes('kill'));
843
+ expect(merged).toBeDefined();
844
+ expect(merged!.events).toContain('emergency_started');
845
+ });
846
+
847
+ it('non-delayable events fire immediately when no delay is active', async () => {
848
+ const { feedPath, sessionPath } = makeTmpFiles();
849
+ writeFileSync(
850
+ feedPath,
851
+ JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
852
+ );
853
+ const lines: string[] = [];
854
+ const ctrl = new AbortController();
855
+ const run = runStreaming({
856
+ feedPath,
857
+ sessionPath,
858
+ stdout: (s) => lines.push(s),
859
+ signal: ctrl.signal,
860
+ pollIntervalMs: 20,
861
+ delayableEventMs: { kill: 200 },
862
+ });
863
+ await new Promise((r) => setTimeout(r, 30));
864
+ appendFileSync(sessionPath, JSON.stringify({ type: 'task_completed', tick: 1, task_name: 'wires' }) + '\n');
865
+ await new Promise((r) => setTimeout(r, 80));
866
+ ctrl.abort(); await run;
867
+
868
+ const parsed = lines.map((l) => JSON.parse(l));
869
+ expect(parsed.some((o) => o.events?.includes('task_completed'))).toBe(true);
870
+ });
871
+ });
872
+
873
+ describe('readFeedSummary', () => {
874
+ it('includes automation strategy from the feed projection without exposing pid', () => {
875
+ const { feedPath } = makeTmpFiles();
876
+ writeFileSync(
877
+ feedPath,
878
+ JSON.stringify({
879
+ phase: 'wandering',
880
+ you: { name: 'me' },
881
+ game: { id: 'game-1' },
882
+ urgent: {},
883
+ meeting: null,
884
+ automation: { strategy: 'default', running: true },
885
+ }),
886
+ );
887
+
888
+ const summary = readFeedSummary(feedPath);
889
+ expect(summary?.automation).toEqual({ strategy: 'default', running: true });
890
+ expect(summary?.automation).not.toHaveProperty('pid');
891
+ expect(summary?.automation).not.toHaveProperty('source');
892
+ });
893
+ });
894
+
895
+ describe('runStreaming startup monitor payload', () => {
896
+ it('emits game_start without initial_payload when requested by game start', async () => {
897
+ const { feedPath, sessionPath } = makeTmpFiles();
898
+ writeFileSync(
899
+ feedPath,
900
+ JSON.stringify({ you: { name: 'me' }, phase: 'lobby', urgent: {}, meeting: null }),
901
+ );
902
+ const lines: string[] = [];
903
+ const ctrl = new AbortController();
904
+
905
+ const run = runStreaming({
906
+ feedPath,
907
+ sessionPath,
908
+ stdout: (s) => lines.push(s),
909
+ signal: ctrl.signal,
910
+ pollIntervalMs: 20,
911
+ emitGameStart: true,
912
+ });
913
+
914
+ await new Promise((r) => setTimeout(r, 60));
915
+ ctrl.abort();
916
+ await run;
917
+
918
+ expect(lines.length).toBeGreaterThanOrEqual(1);
919
+ const first = JSON.parse(lines[0]);
920
+ expect(first.events).toEqual(['game_start']);
921
+ expect(first).not.toHaveProperty('initial_payload');
922
+ expect(first.messages[0]).toContain('game_start');
923
+ });
924
+
925
+ it('compacts role_assigned inside caught_up notable events', async () => {
926
+ const { feedPath, sessionPath } = makeTmpFiles();
927
+ writeFileSync(
928
+ feedPath,
929
+ JSON.stringify({ you: { name: 'me' }, phase: 'wandering', urgent: {}, meeting: null }),
930
+ );
931
+ appendFileSync(sessionPath, JSON.stringify({
932
+ type: 'role_assigned',
933
+ tick: 1,
934
+ room: '仓库',
935
+ role: 'octopus',
936
+ role_display_name: '章鱼',
937
+ faction: 'neutral',
938
+ role_description: '伪装成虾方并达成自己的胜利条件。',
939
+ fake_task_briefing: 'These are fake tasks.',
940
+ hint: 'long hint',
941
+ assigned_tasks: [
942
+ { name: '下载数据', room: '通讯', x: 3, y: 4, is_fake_shrimp: true },
943
+ ],
944
+ }) + '\n');
945
+ const lines: string[] = [];
946
+ const ctrl = new AbortController();
947
+
948
+ const run = runStreaming({
949
+ feedPath,
950
+ sessionPath,
951
+ stdout: (s) => lines.push(s),
952
+ signal: ctrl.signal,
953
+ pollIntervalMs: 20,
954
+ });
955
+
956
+ await new Promise((r) => setTimeout(r, 60));
957
+ ctrl.abort();
958
+ await run;
959
+
960
+ expect(lines.length).toBeGreaterThanOrEqual(1);
961
+ const first = JSON.parse(lines[0]);
962
+ expect(first.events).toEqual(['role_assigned']);
963
+ expect(first.messages[0]).toContain('role_assigned');
964
+ expect(first).not.toHaveProperty('caught_up');
965
+ });
966
+ });