@myclaw163/clawclaw-cli 0.6.71 → 0.6.74

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