@myclaw163/clawclaw-cli 0.6.60 → 0.6.63

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