@myclaw163/clawclaw-cli 0.6.57 → 0.6.58

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 (195) hide show
  1. package/README.md +440 -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/postinstall.mjs +20 -20
  9. package/scripts/sync-bundled-skill.mjs +244 -244
  10. package/scripts/sync-bundled-skill.test.mjs +152 -152
  11. package/skills/clawclaw/SKILL.md +244 -244
  12. package/skills/clawclaw/references/CHATTERBOX.md +142 -142
  13. package/skills/clawclaw/references/COMMANDS.md +132 -132
  14. package/skills/clawclaw/references/GAME-MECHANICS.md +186 -186
  15. package/skills/clawclaw/references/HUB.md +48 -48
  16. package/skills/clawclaw/references/KNOWLEDGE.md +43 -43
  17. package/skills/clawclaw/references/STRATEGIES.md +57 -57
  18. package/skills/clawclaw/references/STREAM.md +59 -59
  19. package/skills/clawclaw/references/TACTICS.md +65 -65
  20. package/src/assets/clawclaw-ascii-map.txt +40 -40
  21. package/src/cli.ts +110 -110
  22. package/src/commands/_schema.ts +109 -109
  23. package/src/commands/account.ts +209 -209
  24. package/src/commands/config.ts +30 -30
  25. package/src/commands/do.test.ts +37 -37
  26. package/src/commands/do.ts +95 -95
  27. package/src/commands/events.ts +22 -22
  28. package/src/commands/game-map.test.ts +28 -28
  29. package/src/commands/game-start-plan.test.ts +84 -84
  30. package/src/commands/game.ts +1027 -1027
  31. package/src/commands/history-player.test.ts +102 -102
  32. package/src/commands/history.ts +573 -573
  33. package/src/commands/hub.test.ts +96 -96
  34. package/src/commands/hub.ts +234 -234
  35. package/src/commands/knowledge.test.ts +19 -19
  36. package/src/commands/knowledge.ts +168 -168
  37. package/src/commands/load.test.ts +51 -51
  38. package/src/commands/load.ts +13 -13
  39. package/src/commands/meeting-history.test.ts +106 -106
  40. package/src/commands/memory.ts +40 -40
  41. package/src/commands/peek.ts +45 -45
  42. package/src/commands/persona.ts +57 -57
  43. package/src/commands/setup/codex.ts +248 -248
  44. package/src/commands/setup/hermes.test.ts +96 -96
  45. package/src/commands/setup/hermes.ts +76 -76
  46. package/src/commands/setup/index.ts +13 -13
  47. package/src/commands/setup/openclaw.test.ts +114 -114
  48. package/src/commands/setup/openclaw.ts +147 -147
  49. package/src/commands/skill.ts +128 -128
  50. package/src/commands/state.ts +46 -46
  51. package/src/commands/strategy.test.ts +135 -135
  52. package/src/commands/strategy.ts +180 -180
  53. package/src/commands/tts.ts +128 -128
  54. package/src/commands/upgrade.test.ts +82 -82
  55. package/src/commands/upgrade.ts +148 -148
  56. package/src/commands/watch.test.ts +969 -969
  57. package/src/commands/watch.ts +720 -720
  58. package/src/lib/auth.test.ts +59 -59
  59. package/src/lib/auth.ts +186 -186
  60. package/src/lib/command-meta.ts +37 -37
  61. package/src/lib/game-client.ts +391 -391
  62. package/src/lib/host-config-patcher.test.ts +130 -130
  63. package/src/lib/host-config-patcher.ts +151 -151
  64. package/src/lib/http-keepalive.ts +15 -15
  65. package/src/lib/http-transport.test.ts +42 -42
  66. package/src/lib/http-transport.ts +113 -113
  67. package/src/lib/hub-client.test.ts +56 -56
  68. package/src/lib/hub-client.ts +88 -88
  69. package/src/lib/hub-install.test.ts +98 -98
  70. package/src/lib/hub-install.ts +121 -121
  71. package/src/lib/hub-reminder.ts +56 -56
  72. package/src/lib/hub-unzip.test.ts +69 -69
  73. package/src/lib/hub-unzip.ts +62 -62
  74. package/src/lib/init-command.test.ts +75 -75
  75. package/src/lib/init-command.ts +120 -120
  76. package/src/lib/knowledge-store.test.ts +180 -180
  77. package/src/lib/knowledge-store.ts +374 -374
  78. package/src/lib/load-context.test.ts +52 -52
  79. package/src/lib/load-context.ts +52 -52
  80. package/src/lib/match-state.test.ts +134 -134
  81. package/src/lib/match-state.ts +94 -94
  82. package/src/lib/netease-tts.ts +83 -83
  83. package/src/lib/normalize.ts +42 -42
  84. package/src/lib/persona.test.ts +41 -41
  85. package/src/lib/persona.ts +72 -72
  86. package/src/lib/server-registry.ts +152 -152
  87. package/src/lib/skill-version.test.ts +48 -48
  88. package/src/lib/skill-version.ts +19 -19
  89. package/src/lib/strategy-export.test.ts +232 -232
  90. package/src/lib/strategy-export.ts +242 -242
  91. package/src/lib/tts-keys.ts +7 -7
  92. package/src/lib/tts-speech.test.ts +63 -63
  93. package/src/lib/tts-speech.ts +76 -76
  94. package/src/lib/workspace-argv.test.ts +49 -49
  95. package/src/lib/workspace-argv.ts +44 -44
  96. package/src/perception/player-history-store.test.ts +87 -87
  97. package/src/perception/player-history-store.ts +194 -194
  98. package/src/pipeline/event-store.ts +124 -124
  99. package/src/pipeline/pipeline.ts +35 -35
  100. package/src/runtime/auto-upgrade.test.ts +66 -66
  101. package/src/runtime/auto-upgrade.ts +31 -31
  102. package/src/runtime/event-daemon.test.ts +107 -107
  103. package/src/runtime/event-daemon.ts +409 -409
  104. package/src/runtime/owner-control.ts +150 -150
  105. package/src/runtime/raw-ws-log.test.ts +33 -33
  106. package/src/runtime/raw-ws-log.ts +32 -32
  107. package/src/runtime/runtime-logger.ts +107 -107
  108. package/src/runtime/ws-client.test.ts +104 -104
  109. package/src/runtime/ws-client.ts +272 -272
  110. package/src/sdk/action.ts +166 -166
  111. package/src/sdk/index.ts +110 -110
  112. package/src/sdk/types.ts +146 -146
  113. package/src/strategies/avoid-lone.ts +11 -11
  114. package/src/strategies/avoid-players.knowledge.md +20 -20
  115. package/src/strategies/avoid-players.ts +15 -15
  116. package/src/strategies/corpse-patrol.ts +22 -22
  117. package/src/strategies/crab-sabotage.ts +21 -21
  118. package/src/strategies/custom-module.test.ts +269 -269
  119. package/src/strategies/find-player.ts +16 -16
  120. package/src/strategies/game-utils.test.ts +190 -164
  121. package/src/strategies/game-utils.ts +744 -737
  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/goal-manager.ts +41 -41
  133. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  134. package/src/strategies/goals/goal.ts +28 -28
  135. package/src/strategies/goals/keep-away-goal.ts +209 -206
  136. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  137. package/src/strategies/goals/kill-lone-top.ts +160 -160
  138. package/src/strategies/goals/kill-target-goal.ts +59 -59
  139. package/src/strategies/goals/kill-target-top.ts +109 -109
  140. package/src/strategies/goals/leaf-goal.ts +25 -25
  141. package/src/strategies/goals/linger-corpse-goal.ts +79 -79
  142. package/src/strategies/goals/lone-kill-core.ts +82 -82
  143. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  144. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  145. package/src/strategies/goals/lone-kill-task-top.ts +86 -86
  146. package/src/strategies/goals/move-room-goal.ts +60 -60
  147. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  148. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  149. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  150. package/src/strategies/goals/paradise-fish-top.ts +219 -219
  151. package/src/strategies/goals/patrol-top.ts +57 -57
  152. package/src/strategies/goals/report-patrol-top.ts +80 -80
  153. package/src/strategies/goals/safe-task-goal.ts +102 -102
  154. package/src/strategies/goals/social-task-top.ts +161 -161
  155. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  156. package/src/strategies/goals/task-only-top.ts +57 -57
  157. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  158. package/src/strategies/goals/task-report-top.ts +57 -57
  159. package/src/strategies/goals/wander-task-goal.ts +33 -33
  160. package/src/strategies/goals/warrior-shrimp-top.test.ts +86 -86
  161. package/src/strategies/goals/warrior-shrimp-top.ts +248 -248
  162. package/src/strategies/greeting.ts +53 -53
  163. package/src/strategies/kill-frenzy.ts +12 -12
  164. package/src/strategies/kill-lone.knowledge.md +20 -20
  165. package/src/strategies/kill-lone.ts +13 -13
  166. package/src/strategies/kill-target.ts +18 -18
  167. package/src/strategies/loader.test.ts +678 -678
  168. package/src/strategies/loader.ts +172 -172
  169. package/src/strategies/lone-kill-task.ts +21 -21
  170. package/src/strategies/meeting-gate.test.ts +59 -59
  171. package/src/strategies/meeting-gate.ts +23 -23
  172. package/src/strategies/move-room.ts +15 -15
  173. package/src/strategies/new-events-backfill.ts +98 -98
  174. package/src/strategies/paradise-fish.knowledge.md +20 -20
  175. package/src/strategies/paradise-fish.ts +25 -25
  176. package/src/strategies/pathfind/distance-field.ts +150 -150
  177. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  178. package/src/strategies/pathfind/escape-planner.ts +355 -348
  179. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  180. package/src/strategies/patrol.ts +11 -11
  181. package/src/strategies/player-targets.ts +13 -13
  182. package/src/strategies/report-patrol.ts +11 -11
  183. package/src/strategies/shrimp-memory.knowledge.md +20 -20
  184. package/src/strategies/shrimp-memory.ts +25 -25
  185. package/src/strategies/social-task.test.ts +28 -28
  186. package/src/strategies/social-task.ts +49 -49
  187. package/src/strategies/spawn.ts +82 -82
  188. package/src/strategies/speech-module.ts +123 -123
  189. package/src/strategies/strategy-loop.ts +763 -763
  190. package/src/strategies/task-kill-report.ts +17 -17
  191. package/src/strategies/task-only.ts +11 -11
  192. package/src/strategies/task-report.ts +22 -22
  193. package/src/strategies/types.ts +96 -96
  194. package/src/strategies/warrior-memory.knowledge.md +20 -20
  195. package/src/strategies/warrior-memory.ts +16 -16
@@ -1,969 +1,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.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.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
+ });