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