@myclaw163/clawclaw-cli 0.6.55 → 0.6.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +440 -440
  2. package/bin/clawclaw-cli.mjs +3 -3
  3. package/package.json +48 -48
  4. package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
  5. package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
  6. package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
  7. package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
  8. package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
  9. package/scripts/postinstall.mjs +20 -20
  10. package/scripts/sync-bundled-skill.mjs +245 -245
  11. package/scripts/sync-bundled-skill.test.mjs +152 -152
  12. package/skills/clawclaw/SKILL.md +244 -240
  13. package/skills/clawclaw/references/CHATTERBOX.md +142 -142
  14. package/skills/clawclaw/references/COMMANDS.md +132 -132
  15. package/skills/clawclaw/references/GAME-MECHANICS.md +186 -186
  16. package/skills/clawclaw/references/HUB.md +48 -48
  17. package/skills/clawclaw/references/KNOWLEDGE.md +43 -43
  18. package/skills/clawclaw/references/STRATEGIES.md +57 -57
  19. package/skills/clawclaw/references/STREAM.md +59 -58
  20. package/skills/clawclaw/references/TACTICS.md +65 -65
  21. package/src/assets/clawclaw-ascii-map.txt +40 -40
  22. package/src/cli.ts +110 -153
  23. package/src/commands/_schema.ts +109 -109
  24. package/src/commands/account.ts +209 -209
  25. package/src/commands/do.test.ts +37 -37
  26. package/src/commands/do.ts +95 -95
  27. package/src/commands/events.ts +22 -22
  28. package/src/commands/game-map.test.ts +28 -28
  29. package/src/commands/game-start-plan.test.ts +84 -142
  30. package/src/commands/game.ts +1027 -882
  31. package/src/commands/history-player.test.ts +102 -102
  32. package/src/commands/history.ts +573 -573
  33. package/src/commands/hub.test.ts +96 -96
  34. package/src/commands/hub.ts +234 -234
  35. package/src/commands/knowledge.test.ts +19 -19
  36. package/src/commands/knowledge.ts +168 -168
  37. package/src/commands/load.test.ts +51 -51
  38. package/src/commands/load.ts +13 -13
  39. package/src/commands/meeting-history.test.ts +106 -106
  40. package/src/commands/memory.ts +40 -40
  41. package/src/commands/peek.ts +45 -38
  42. package/src/commands/persona.ts +57 -57
  43. package/src/commands/skill.ts +128 -128
  44. package/src/commands/state.ts +46 -46
  45. package/src/commands/strategy.test.ts +135 -135
  46. package/src/commands/strategy.ts +180 -189
  47. package/src/commands/tts.ts +128 -128
  48. package/src/commands/upgrade.test.ts +82 -82
  49. package/src/commands/upgrade.ts +148 -148
  50. package/src/commands/watch.test.ts +969 -973
  51. package/src/commands/watch.ts +720 -709
  52. package/src/lib/auth.test.ts +59 -59
  53. package/src/lib/auth.ts +186 -186
  54. package/src/lib/command-meta.ts +37 -37
  55. package/src/lib/game-client.ts +391 -391
  56. package/src/lib/http-keepalive.ts +15 -15
  57. package/src/lib/http-transport.test.ts +42 -42
  58. package/src/lib/http-transport.ts +113 -113
  59. package/src/lib/hub-client.test.ts +56 -56
  60. package/src/lib/hub-client.ts +88 -88
  61. package/src/lib/hub-install.test.ts +98 -98
  62. package/src/lib/hub-install.ts +121 -121
  63. package/src/lib/hub-reminder.ts +75 -75
  64. package/src/lib/hub-unzip.test.ts +69 -69
  65. package/src/lib/hub-unzip.ts +62 -62
  66. package/src/lib/init-command.test.ts +75 -75
  67. package/src/lib/init-command.ts +120 -120
  68. package/src/lib/knowledge-store.test.ts +180 -180
  69. package/src/lib/knowledge-store.ts +374 -374
  70. package/src/lib/load-context.test.ts +52 -52
  71. package/src/lib/load-context.ts +52 -52
  72. package/src/lib/match-state.test.ts +134 -134
  73. package/src/lib/match-state.ts +94 -94
  74. package/src/lib/netease-tts.ts +83 -83
  75. package/src/lib/normalize.ts +42 -42
  76. package/src/lib/persona.test.ts +41 -41
  77. package/src/lib/persona.ts +72 -72
  78. package/src/lib/server-registry.ts +152 -152
  79. package/src/lib/skill-version.test.ts +48 -48
  80. package/src/lib/skill-version.ts +19 -19
  81. package/src/lib/strategy-export.test.ts +232 -232
  82. package/src/lib/strategy-export.ts +242 -242
  83. package/src/lib/tts-keys.ts +7 -7
  84. package/src/lib/tts-speech.test.ts +63 -63
  85. package/src/lib/tts-speech.ts +76 -76
  86. package/src/lib/workspace-argv.test.ts +49 -49
  87. package/src/lib/workspace-argv.ts +44 -44
  88. package/src/perception/player-history-store.test.ts +87 -87
  89. package/src/perception/player-history-store.ts +194 -194
  90. package/src/pipeline/event-store.ts +124 -124
  91. package/src/pipeline/pipeline.ts +35 -35
  92. package/src/runtime/auto-upgrade.test.ts +66 -66
  93. package/src/runtime/auto-upgrade.ts +31 -31
  94. package/src/runtime/event-daemon.test.ts +107 -28
  95. package/src/runtime/event-daemon.ts +409 -371
  96. package/src/runtime/owner-control.ts +150 -0
  97. package/src/runtime/raw-ws-log.test.ts +33 -33
  98. package/src/runtime/raw-ws-log.ts +32 -32
  99. package/src/runtime/runtime-logger.ts +107 -99
  100. package/src/runtime/ws-client.test.ts +104 -47
  101. package/src/runtime/ws-client.ts +272 -272
  102. package/src/sdk/action.ts +166 -166
  103. package/src/sdk/index.ts +110 -110
  104. package/src/sdk/types.ts +146 -146
  105. package/src/strategies/avoid-lone.ts +11 -11
  106. package/src/strategies/avoid-players.knowledge.md +20 -20
  107. package/src/strategies/avoid-players.ts +15 -15
  108. package/src/strategies/corpse-patrol.ts +22 -22
  109. package/src/strategies/crab-sabotage.ts +21 -21
  110. package/src/strategies/custom-module.test.ts +269 -269
  111. package/src/strategies/find-player.ts +16 -16
  112. package/src/strategies/game-utils.test.ts +164 -164
  113. package/src/strategies/game-utils.ts +737 -721
  114. package/src/strategies/goals/avoid-lone-top.ts +168 -168
  115. package/src/strategies/goals/avoid-players-top.test.ts +83 -83
  116. package/src/strategies/goals/avoid-players-top.ts +121 -121
  117. package/src/strategies/goals/conversation-goal.ts +51 -51
  118. package/src/strategies/goals/corpse-patrol-top.ts +91 -91
  119. package/src/strategies/goals/crab-octopus-reflexes.ts +93 -93
  120. package/src/strategies/goals/crab-sabotage-top.ts +197 -197
  121. package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
  122. package/src/strategies/goals/find-player-top.ts +93 -93
  123. package/src/strategies/goals/flee-players-goal.ts +53 -53
  124. package/src/strategies/goals/goal-manager.ts +41 -41
  125. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  126. package/src/strategies/goals/goal.ts +28 -28
  127. package/src/strategies/goals/keep-away-goal.ts +206 -206
  128. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  129. package/src/strategies/goals/kill-lone-top.ts +160 -160
  130. package/src/strategies/goals/kill-target-goal.ts +59 -59
  131. package/src/strategies/goals/kill-target-top.ts +109 -109
  132. package/src/strategies/goals/leaf-goal.ts +25 -25
  133. package/src/strategies/goals/linger-corpse-goal.ts +79 -79
  134. package/src/strategies/goals/lone-kill-core.ts +82 -82
  135. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  136. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  137. package/src/strategies/goals/lone-kill-task-top.ts +86 -86
  138. package/src/strategies/goals/move-room-goal.ts +60 -60
  139. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  140. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  141. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  142. package/src/strategies/goals/paradise-fish-top.ts +219 -219
  143. package/src/strategies/goals/patrol-top.ts +57 -57
  144. package/src/strategies/goals/report-patrol-top.ts +80 -80
  145. package/src/strategies/goals/safe-task-goal.ts +102 -102
  146. package/src/strategies/goals/social-task-top.ts +161 -161
  147. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  148. package/src/strategies/goals/task-only-top.ts +57 -57
  149. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  150. package/src/strategies/goals/task-report-top.ts +57 -57
  151. package/src/strategies/goals/wander-task-goal.ts +33 -33
  152. package/src/strategies/goals/warrior-shrimp-top.test.ts +86 -86
  153. package/src/strategies/goals/warrior-shrimp-top.ts +248 -248
  154. package/src/strategies/greeting.ts +53 -53
  155. package/src/strategies/kill-frenzy.ts +12 -12
  156. package/src/strategies/kill-lone.knowledge.md +20 -20
  157. package/src/strategies/kill-lone.ts +13 -13
  158. package/src/strategies/kill-target.ts +18 -18
  159. package/src/strategies/loader.test.ts +678 -678
  160. package/src/strategies/loader.ts +172 -172
  161. package/src/strategies/lone-kill-task.ts +21 -21
  162. package/src/strategies/meeting-gate.test.ts +59 -59
  163. package/src/strategies/meeting-gate.ts +23 -23
  164. package/src/strategies/move-room.ts +15 -15
  165. package/src/strategies/new-events-backfill.ts +98 -98
  166. package/src/strategies/paradise-fish.knowledge.md +20 -20
  167. package/src/strategies/paradise-fish.ts +25 -25
  168. package/src/strategies/pathfind/distance-field.ts +150 -150
  169. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  170. package/src/strategies/pathfind/escape-planner.ts +348 -348
  171. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  172. package/src/strategies/patrol.ts +11 -11
  173. package/src/strategies/player-targets.ts +13 -13
  174. package/src/strategies/report-patrol.ts +11 -11
  175. package/src/strategies/shrimp-memory.knowledge.md +20 -20
  176. package/src/strategies/shrimp-memory.ts +25 -25
  177. package/src/strategies/social-task.test.ts +28 -28
  178. package/src/strategies/social-task.ts +49 -49
  179. package/src/strategies/spawn.ts +82 -71
  180. package/src/strategies/speech-module.ts +123 -123
  181. package/src/strategies/strategy-loop.ts +763 -757
  182. package/src/strategies/task-kill-report.ts +17 -17
  183. package/src/strategies/task-only.ts +11 -11
  184. package/src/strategies/task-report.ts +22 -22
  185. package/src/strategies/types.ts +96 -96
  186. package/src/strategies/warrior-memory.knowledge.md +20 -20
  187. package/src/strategies/warrior-memory.ts +16 -16
  188. package/src/runtime/daemon.ts +0 -100
  189. package/src/runtime/opening-mover.ts +0 -303
@@ -1,371 +1,409 @@
1
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
2
- import { join } from 'path';
3
- import { GameClient } from '../lib/game-client.js';
4
- import { EventStore } from '../pipeline/event-store.js';
5
- import { getProfileStateDir } from '../lib/init-command.js';
6
- import { AuthStore } from '../lib/auth.js';
7
- import { RuntimeLogger } from './runtime-logger.js';
8
- import { spawnOpeningMover } from './opening-mover.js';
9
- import { isStrategyRunning as isAutoRunning } from '../strategies/strategy-loop.js';
10
- import { isCclTestEnabled, rawWsLogPathForSession } from './raw-ws-log.js';
11
- import { PlayerHistoryStore } from '../perception/player-history-store.js';
12
-
13
- const log = new RuntimeLogger();
14
-
15
- let feedSerial = 0;
16
- const MAX_RECENT_EVENTS = 50;
17
- const recentEvents: Record<string, any>[] = [];
18
-
19
- function pushRecentEvent(evt: Record<string, any>): void {
20
- recentEvents.push(evt);
21
- if (recentEvents.length > MAX_RECENT_EVENTS) recentEvents.shift();
22
- }
23
-
24
- export function buildMeetingStateProjection(meeting: any): Record<string, any> {
25
- return {
26
- caller: meeting?.caller ?? null,
27
- sub_phase: meeting?.sub_phase ?? null,
28
- first_speaker: meeting?.first_speaker ?? null,
29
- speech_order: Array.isArray(meeting?.speech_order) ? meeting.speech_order : undefined,
30
- current_speaker: meeting?.current_speaker ?? null,
31
- speech_skipped: Array.isArray(meeting?.speech_skipped) ? meeting.speech_skipped : undefined,
32
- votes_submitted: Array.isArray(meeting?.votes_submitted) ? meeting.votes_submitted : undefined,
33
- alive_players: Array.isArray(meeting?.alive_players) ? meeting.alive_players : undefined,
34
- };
35
- }
36
-
37
- /** Build feed.json consumed by the `ccl game start` stream — tracks phase, current_speaker, urgent conditions. */
38
- function buildFeed(
39
- youName: string,
40
- phase: string,
41
- urgent: Record<string, any>,
42
- meeting: any,
43
- you?: Record<string, any>,
44
- game?: Record<string, any>,
45
- ): any {
46
- return {
47
- ts: new Date().toISOString(),
48
- serial: ++feedSerial,
49
- phase,
50
- you: { name: youName, ...(you ?? {}) },
51
- game,
52
- urgent,
53
- meeting,
54
- recent_events: recentEvents.slice(-10),
55
- };
56
- }
57
-
58
- export async function startEventDaemon(authStore?: AuthStore): Promise<void> {
59
- const store = authStore ?? new AuthStore();
60
- const profile = store.getActive();
61
- if (!profile) throw new Error('Not logged in.');
62
-
63
- const events = EventStore.forActiveAccount();
64
- const playerHistory = PlayerHistoryStore.forSession(events.path);
65
- playerHistory.reset();
66
- const stateDir = getProfileStateDir(profile);
67
- const cclTest = isCclTestEnabled();
68
- const rawWsLogPath = cclTest ? rawWsLogPathForSession(events.path) : undefined;
69
- const client = GameClient.fromAuth({ ws: true, authStore: store, rawWsLogPath });
70
- const controlPath = join(stateDir, 'control.json');
71
- const runtimePath = join(stateDir, 'runtime.json');
72
- const feedPath = join(stateDir, 'feed.json');
73
-
74
- let currentPhase = 'lobby';
75
- if (existsSync(join(stateDir, 'match-state.json'))) {
76
- currentPhase = 'matching';
77
- pushRecentEvent({ type: 'match_start', ts: new Date().toISOString() });
78
- events.append({ type: 'match_start', ts: new Date().toISOString() });
79
- }
80
- let currentMeeting: any = null;
81
- let currentYou: Record<string, any> = {};
82
- let currentGame: Record<string, any> = {};
83
- let currentUrgent: Record<string, any> = {};
84
- let mapCacheLoaded = false;
85
- let mapCachePromise: Promise<void> | null = null;
86
- let currentMeetingRound = 0;
87
- let lastMeetingProjectionJson: string | null = null;
88
- /** Track last speech_your_turn tick (server-sent or synthetic) so we don't
89
- * double-emit when the server already shipped one in new_events. */
90
- let lastSpeechYourTurnTick: number | null = null;
91
- /** Track previous current_speaker so we only synthesize on real transitions. */
92
- let prevCurrentSpeaker: string | null = null;
93
-
94
- const refreshPlayerHistoryMapCache = () => {
95
- if (mapCacheLoaded || mapCachePromise) return;
96
- mapCachePromise = client.getMap()
97
- .then((mapData) => {
98
- if (!mapData || typeof mapData !== 'object') return;
99
- playerHistory.updateMapCache(mapData);
100
- mapCacheLoaded = true;
101
- })
102
- .catch(() => { })
103
- .finally(() => {
104
- mapCachePromise = null;
105
- });
106
- };
107
-
108
- writeFileSync(runtimePath, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
109
-
110
- // Seed feed.json synchronously on daemon startup so a watcher attached
111
- // during the matchmaking phase (before the WS connects and pushes _state)
112
- // does NOT immediately fail its `existsSync(feedPath)` precondition.
113
- // The real WS-driven write further down overwrites this minimal record as
114
- // soon as the first _state arrives.
115
- try {
116
- writeFileSync(
117
- feedPath,
118
- JSON.stringify(
119
- buildFeed(profile.agentName, currentPhase, currentUrgent, currentMeeting, currentYou, currentGame),
120
- null, 2,
121
- ),
122
- );
123
- } catch { }
124
-
125
- events.append({ type: 'daemon_started' });
126
- log.info('DAEMON', `started pid=${process.pid}`);
127
-
128
- let reconnecting = false;
129
- const ensureWsConnected = async () => {
130
- if (reconnecting || client.wsConnected) return;
131
- reconnecting = true;
132
- try {
133
- await client.discoverGameServer();
134
- if (!client.wsConnected) {
135
- await client.connectWs();
136
- }
137
- if (client.wsConnected) {
138
- log.info('DAEMON', `ws connected, game_server=${client._gameServerUrl}`);
139
- } else {
140
- log.info('DAEMON', 'waiting for game server / event websocket...');
141
- }
142
- } finally {
143
- reconnecting = false;
144
- }
145
- };
146
-
147
- let reconnectPoll: ReturnType<typeof setInterval> | null = null;
148
- const controlPoll = setInterval(() => {
149
- if (existsSync(controlPath)) {
150
- try {
151
- const cmd = JSON.parse(readFileSync(controlPath, 'utf8'));
152
- unlinkSync(controlPath);
153
- if (cmd.command === 'stop') {
154
- events.append({ type: 'daemon_stopped' });
155
- log.info('DAEMON', 'stopping (control)');
156
- client.disconnectWs();
157
- clearInterval(controlPoll);
158
- if (reconnectPoll) clearInterval(reconnectPoll);
159
- cleanup(runtimePath, feedPath, playerHistory, cclTest);
160
- process.exit(0);
161
- }
162
- } catch { }
163
- }
164
- }, 1000);
165
-
166
- client.on('*', (data) => {
167
- // Track server-sent speech_your_turn tick so _state synthesis can dedup.
168
- if (data.type === 'speech_your_turn' && data.tick != null) {
169
- lastSpeechYourTurnTick = data.tick;
170
- }
171
-
172
- // _state is a synthetic event with full game state from WS — not a real game event
173
- if (data.type === '_state') {
174
- const s = data as any;
175
- currentPhase = s.phase ?? currentPhase;
176
- currentYou = s.you ? { ...s.you } : currentYou;
177
- currentGame = {
178
- game_id: s.game_id,
179
- alive_count: s.alive_count,
180
- task_progress: s.task_progress,
181
- };
182
- if (currentPhase !== 'lobby') refreshPlayerHistoryMapCache();
183
-
184
- if (s.meeting) {
185
- if (currentMeetingRound === 0) {
186
- currentMeetingRound = 1;
187
- prevCurrentSpeaker = null; // new meeting, reset speaker tracking
188
- }
189
- const meetingProjection = buildMeetingStateProjection(s.meeting);
190
- const meetingProjectionJson = JSON.stringify(meetingProjection);
191
- if (meetingProjectionJson !== lastMeetingProjectionJson) {
192
- events.append({
193
- type: 'meeting_state',
194
- synthetic: true,
195
- tick: s.tick,
196
- round: currentMeetingRound,
197
- ...meetingProjection,
198
- });
199
- lastMeetingProjectionJson = meetingProjectionJson;
200
- }
201
- currentMeeting = {
202
- caller: s.meeting.caller,
203
- sub_phase: s.meeting.sub_phase,
204
- current_speaker: s.meeting.current_speaker,
205
- is_my_turn: s.meeting.current_speaker === (s.you?.name ?? currentYou.name),
206
- alive_players: s.meeting.alive_players,
207
- speech_order: s.meeting.speech_order,
208
- };
209
- // Synthesize speech_your_turn when current_speaker transitions to the
210
- // current player but the server didn't ship one in new_events.
211
- // Dedup: skip when server already emitted one at the same tick.
212
- const myName = s.you?.name ?? currentYou.name;
213
- const newSpeaker = s.meeting.current_speaker;
214
- if (
215
- typeof newSpeaker === 'string' &&
216
- newSpeaker === myName &&
217
- newSpeaker !== prevCurrentSpeaker &&
218
- (s.tick == null || s.tick !== lastSpeechYourTurnTick)
219
- ) {
220
- events.append({
221
- type: 'speech_your_turn',
222
- synthetic: true,
223
- tick: s.tick,
224
- actor_name: myName,
225
- });
226
- pushRecentEvent({ type: 'speech_your_turn', synthetic: true, tick: s.tick, actor_name: myName });
227
- lastSpeechYourTurnTick = s.tick;
228
- }
229
- prevCurrentSpeaker = typeof newSpeaker === 'string' ? newSpeaker : prevCurrentSpeaker;
230
- if (s.meeting.sub_phase === 'vote') {
231
- currentMeeting.votes_submitted = s.meeting.votes_submitted;
232
- }
233
- } else if (currentPhase !== 'meeting') {
234
- currentMeeting = null;
235
- }
236
-
237
- // Detect corpses nearby
238
- const corpses: any[] = s.corpses ?? [];
239
- let corpseNearby: any = null;
240
- if (corpses.length > 0 && s.you?.x != null && s.you?.y != null) {
241
- let bestDist = Infinity;
242
- for (const c of corpses) {
243
- if (c.x == null || c.y == null) continue;
244
- const d = Math.sqrt((c.x - s.you.x) ** 2 + (c.y - s.you.y) ** 2);
245
- if (d < bestDist) {
246
- bestDist = d;
247
- corpseNearby = { name: c.name, x: c.x, y: c.y, distance: Math.round(d) };
248
- }
249
- }
250
- }
251
-
252
- currentUrgent = {
253
- meeting_started: currentPhase === 'meeting',
254
- emergency_active: false, // server doesn't expose via WS state
255
- corpse_nearby: corpseNearby,
256
- game_over: currentPhase === 'game_over',
257
- };
258
-
259
- try {
260
- writeFileSync(
261
- feedPath,
262
- JSON.stringify(
263
- buildFeed(profile.agentName, currentPhase, currentUrgent, currentMeeting, currentYou, currentGame),
264
- null, 2,
265
- ),
266
- );
267
- } catch { }
268
- if (currentPhase === 'game_over') {
269
- events.append({ type: 'daemon_stopped', reason: 'game_over' });
270
- log.info('DAEMON', 'stopping (game_over via _state)');
271
- client.disconnectWs();
272
- clearInterval(controlPoll);
273
- if (reconnectPoll) clearInterval(reconnectPoll);
274
- cleanup(runtimePath, feedPath, playerHistory, cclTest);
275
- process.exit(0);
276
- }
277
- return; // _state is synthetic, don't log to events.jsonl
278
- }
279
-
280
- events.append(data);
281
- pushRecentEvent(data);
282
- if (data.type === 'player_spotted') {
283
- refreshPlayerHistoryMapCache();
284
- playerHistory.recordPlayerSpotted(data);
285
- }
286
-
287
- // Track phase from event types (fallback when _state isn't available yet)
288
- if (data.type === 'meeting_start' || data.type === 'meeting_started') {
289
- currentMeetingRound += 1;
290
- lastMeetingProjectionJson = null;
291
- prevCurrentSpeaker = null; // new meeting, reset speaker tracking
292
- currentPhase = 'meeting';
293
- currentMeeting = { sub_phase: 'speech' };
294
- } else if (data.type === 'meeting_ended' || data.type === 'meeting_result_pending') {
295
- currentPhase = 'wandering';
296
- currentMeeting = null;
297
- } else if (data.type === 'game_over') {
298
- currentPhase = 'game_over';
299
- currentMeeting = null;
300
- } else if (data.type === 'game_started') {
301
- currentPhase = 'wandering';
302
- }
303
-
304
- // Write feed.json for the game start stream (also written by _state, but this is a fallback)
305
- const urgent: Record<string, any> = {
306
- meeting_started: currentPhase === 'meeting',
307
- game_over: currentPhase === 'game_over',
308
- };
309
- try {
310
- writeFileSync(
311
- feedPath,
312
- JSON.stringify(
313
- buildFeed(profile.agentName, currentPhase, urgent, currentMeeting, currentYou, currentGame),
314
- null, 2,
315
- ),
316
- );
317
- } catch { }
318
-
319
- if (data.type === 'meeting_ended') {
320
- if (isAutoRunning()) {
321
- events.append({ type: 'opening_mover_skipped', source: 'meeting_ended', reason: 'auto_running' });
322
- } else {
323
- const { pid, logFile } = spawnOpeningMover();
324
- events.append({ type: 'opening_mover_started', source: 'meeting_ended', pid, logFile });
325
- }
326
- }
327
- if (data.type === 'game_over') {
328
- log.info('DAEMON', 'game over, stopping');
329
- client.disconnectWs();
330
- clearInterval(controlPoll);
331
- if (reconnectPoll) clearInterval(reconnectPoll);
332
- cleanup(runtimePath, feedPath, playerHistory, cclTest);
333
- process.exit(0);
334
- }
335
- });
336
-
337
- await ensureWsConnected();
338
- reconnectPoll = setInterval(() => {
339
- void ensureWsConnected();
340
- }, 3000);
341
-
342
- process.on('SIGINT', () => {
343
- events.append({ type: 'daemon_stopped', reason: 'SIGINT' });
344
- log.info('DAEMON', 'stopping (SIGINT)');
345
- client.disconnectWs();
346
- clearInterval(controlPoll);
347
- if (reconnectPoll) clearInterval(reconnectPoll);
348
- cleanup(runtimePath, feedPath, playerHistory, cclTest);
349
- process.exit(0);
350
- });
351
-
352
- process.on('SIGTERM', () => {
353
- events.append({ type: 'daemon_stopped', reason: 'SIGTERM' });
354
- client.disconnectWs();
355
- clearInterval(controlPoll);
356
- if (reconnectPoll) clearInterval(reconnectPoll);
357
- cleanup(runtimePath, feedPath, playerHistory, cclTest);
358
- process.exit(0);
359
- });
360
- }
361
-
362
- function cleanup(
363
- runtimePath: string,
364
- feedPath?: string,
365
- playerHistory?: PlayerHistoryStore,
366
- preservePlayerHistory = false,
367
- ): void {
368
- try { unlinkSync(runtimePath); } catch { }
369
- if (feedPath) try { unlinkSync(feedPath); } catch { }
370
- if (playerHistory && !preservePlayerHistory) playerHistory.delete();
371
- }
1
+ import { existsSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { GameClient } from '../lib/game-client.js';
4
+ import { EventStore } from '../pipeline/event-store.js';
5
+ import { getProfileStateDir } from '../lib/init-command.js';
6
+ import { AuthStore } from '../lib/auth.js';
7
+ import { RuntimeLogger } from './runtime-logger.js';
8
+ import { isCclTestEnabled, rawWsLogPathForSession } from './raw-ws-log.js';
9
+ import { PlayerHistoryStore } from '../perception/player-history-store.js';
10
+
11
+ const log = new RuntimeLogger();
12
+
13
+ let feedSerial = 0;
14
+ const MAX_RECENT_EVENTS = 50;
15
+
16
+ function isTrue(value: string | undefined): boolean {
17
+ return typeof value === 'string' && value.trim().toLowerCase() === 'true';
18
+ }
19
+
20
+ function shouldWriteFeedFile(): boolean {
21
+ return isCclTestEnabled() || isTrue(process.env.CCL_DEBUG_RUNTIME);
22
+ }
23
+
24
+ function pushRecentEvent(recentEvents: Record<string, any>[], evt: Record<string, any>): void {
25
+ recentEvents.push(evt);
26
+ if (recentEvents.length > MAX_RECENT_EVENTS) recentEvents.shift();
27
+ }
28
+
29
+ function cleanObject<T extends Record<string, any>>(obj: T): T {
30
+ for (const key of Object.keys(obj)) {
31
+ if (obj[key] === undefined) delete obj[key];
32
+ }
33
+ return obj;
34
+ }
35
+
36
+ function isGameOverState(data: Record<string, any>): boolean {
37
+ return data.phase === 'game_over';
38
+ }
39
+
40
+ function isGameOverEvent(data: Record<string, any>): boolean {
41
+ return data.type === 'game_over';
42
+ }
43
+
44
+ export function buildMeetingStateProjection(meeting: any): Record<string, any> {
45
+ return {
46
+ caller: meeting?.caller ?? null,
47
+ sub_phase: meeting?.sub_phase ?? null,
48
+ first_speaker: meeting?.first_speaker ?? null,
49
+ speech_order: Array.isArray(meeting?.speech_order) ? meeting.speech_order : undefined,
50
+ current_speaker: meeting?.current_speaker ?? null,
51
+ speech_skipped: Array.isArray(meeting?.speech_skipped) ? meeting.speech_skipped : undefined,
52
+ votes_submitted: Array.isArray(meeting?.votes_submitted) ? meeting.votes_submitted : undefined,
53
+ alive_players: Array.isArray(meeting?.alive_players) ? meeting.alive_players : undefined,
54
+ };
55
+ }
56
+
57
+ /** Build the in-memory owner snapshot consumed by Monitor and owner-control snapshot requests. */
58
+ function buildFeed(
59
+ youName: string,
60
+ phase: string,
61
+ urgent: Record<string, any>,
62
+ meeting: any,
63
+ recentEvents: Record<string, any>[],
64
+ you?: Record<string, any>,
65
+ game?: Record<string, any>,
66
+ automation?: Record<string, any>,
67
+ ): any {
68
+ return {
69
+ ts: new Date().toISOString(),
70
+ serial: ++feedSerial,
71
+ phase,
72
+ you: { name: youName, ...(you ?? {}) },
73
+ game,
74
+ urgent,
75
+ meeting,
76
+ automation,
77
+ recent_events: recentEvents.slice(-10),
78
+ };
79
+ }
80
+
81
+ export type EventRuntimeStopReason = 'game_over' | 'manual' | 'SIGINT' | 'SIGTERM' | 'error';
82
+
83
+ export interface EventRuntimeStop {
84
+ reason: EventRuntimeStopReason;
85
+ error?: unknown;
86
+ }
87
+
88
+ export interface EventRuntimeOptions {
89
+ authStore?: AuthStore;
90
+ onStop?: (stop: EventRuntimeStop) => void;
91
+ getAutomation?: () => Record<string, any> | undefined;
92
+ }
93
+
94
+ export class EventRuntime {
95
+ private readonly store: AuthStore;
96
+ private readonly onStop?: (stop: EventRuntimeStop) => void;
97
+ private readonly getAutomation?: () => Record<string, any> | undefined;
98
+ private events: EventStore | null = null;
99
+ private playerHistory: PlayerHistoryStore | null = null;
100
+ private client: GameClient | null = null;
101
+ private reconnectPoll: ReturnType<typeof setInterval> | null = null;
102
+ private reconnecting = false;
103
+ private stopped = false;
104
+ private notifyStop = false;
105
+ private gameOverEmitted = false;
106
+ private recentEvents: Record<string, any>[] = [];
107
+
108
+ private profileName = '';
109
+ private feedPath = '';
110
+ private currentPhase = 'lobby';
111
+ private currentMeeting: any = null;
112
+ private currentYou: Record<string, any> = {};
113
+ private currentGame: Record<string, any> = {};
114
+ private currentUrgent: Record<string, any> = {};
115
+ private mapCacheLoaded = false;
116
+ private mapCachePromise: Promise<void> | null = null;
117
+ private currentMeetingRound = 0;
118
+ private lastMeetingProjectionJson: string | null = null;
119
+ private lastSpeechYourTurnTick: number | null = null;
120
+ private prevCurrentSpeaker: string | null = null;
121
+ private latestFeed: any = null;
122
+
123
+ constructor(opts: EventRuntimeOptions = {}) {
124
+ this.store = opts.authStore ?? new AuthStore();
125
+ this.onStop = opts.onStop;
126
+ this.getAutomation = opts.getAutomation;
127
+ }
128
+
129
+ snapshot(): any | null {
130
+ return this.latestFeed;
131
+ }
132
+
133
+ refreshFeed(): void {
134
+ this.writeFeed();
135
+ }
136
+
137
+ async start(): Promise<void> {
138
+ const profile = this.store.getActive();
139
+ if (!profile) throw new Error('Not logged in.');
140
+
141
+ this.profileName = profile.agentName;
142
+ this.events = EventStore.forActiveAccount();
143
+ this.playerHistory = PlayerHistoryStore.forSession(this.events.path);
144
+ this.playerHistory.reset();
145
+
146
+ const stateDir = getProfileStateDir(profile);
147
+ const cclTest = isCclTestEnabled();
148
+ const rawWsLogPath = cclTest ? rawWsLogPathForSession(this.events.path) : undefined;
149
+ this.client = GameClient.fromAuth({ ws: true, authStore: this.store, rawWsLogPath });
150
+ this.feedPath = join(stateDir, 'feed.json');
151
+
152
+ if (existsSync(join(stateDir, 'match-state.json'))) {
153
+ this.currentPhase = 'matching';
154
+ const matchStart = { type: 'match_start', ts: new Date().toISOString() };
155
+ pushRecentEvent(this.recentEvents, matchStart);
156
+ this.events.append(matchStart);
157
+ }
158
+
159
+ this.writeFeed();
160
+ this.events.append({ type: 'event_runtime_started', pid: process.pid });
161
+ log.info('EVENT_RUNTIME', `started pid=${process.pid}`);
162
+
163
+ this.client.on('*', (data) => this.handleEvent(data));
164
+
165
+ await this.ensureWsConnected();
166
+ this.reconnectPoll = setInterval(() => {
167
+ void this.ensureWsConnected();
168
+ }, 3000);
169
+ }
170
+
171
+ stop(reason: EventRuntimeStopReason = 'manual'): void {
172
+ this.finish({ reason }, false);
173
+ }
174
+
175
+ private finish(stop: EventRuntimeStop, notify: boolean): void {
176
+ if (this.stopped) return;
177
+ this.stopped = true;
178
+ this.notifyStop = notify;
179
+ if (this.reconnectPoll) clearInterval(this.reconnectPoll);
180
+ this.reconnectPoll = null;
181
+ this.client?.disconnectWs();
182
+ this.client = null;
183
+ try {
184
+ this.events?.append({ type: 'event_runtime_stopped', reason: stop.reason });
185
+ } catch {}
186
+ if (this.notifyStop) this.onStop?.(stop);
187
+ }
188
+
189
+ private requestStop(reason: EventRuntimeStopReason, error?: unknown): void {
190
+ this.finish({ reason, error }, true);
191
+ }
192
+
193
+ private async ensureWsConnected(): Promise<void> {
194
+ const client = this.client;
195
+ if (!client || this.stopped || this.reconnecting || client.wsConnected) return;
196
+ this.reconnecting = true;
197
+ try {
198
+ await client.discoverGameServer();
199
+ if (!client.wsConnected) {
200
+ await client.connectWs();
201
+ }
202
+ if (client.wsConnected) {
203
+ log.info('EVENT_RUNTIME', `ws connected, game_server=${client._gameServerUrl}`);
204
+ } else {
205
+ log.info('EVENT_RUNTIME', 'waiting for game server / event websocket...');
206
+ }
207
+ } catch (err) {
208
+ const message = err instanceof Error ? err.message : String(err);
209
+ log.warn('EVENT_RUNTIME', `ws connection attempt failed: ${message}`);
210
+ } finally {
211
+ this.reconnecting = false;
212
+ }
213
+ }
214
+
215
+ private refreshPlayerHistoryMapCache(): void {
216
+ if (this.mapCacheLoaded || this.mapCachePromise || !this.client || !this.playerHistory) return;
217
+ this.mapCachePromise = this.client.getMap()
218
+ .then((mapData) => {
219
+ if (!mapData || typeof mapData !== 'object') return;
220
+ this.playerHistory?.updateMapCache(mapData);
221
+ this.mapCacheLoaded = true;
222
+ })
223
+ .catch(() => {})
224
+ .finally(() => {
225
+ this.mapCachePromise = null;
226
+ });
227
+ }
228
+
229
+ private writeFeed(): void {
230
+ this.latestFeed = buildFeed(
231
+ this.profileName,
232
+ this.currentPhase,
233
+ this.currentUrgent,
234
+ this.currentMeeting,
235
+ this.recentEvents,
236
+ this.currentYou,
237
+ this.currentGame,
238
+ this.getAutomation?.(),
239
+ );
240
+ if (!shouldWriteFeedFile()) return;
241
+ try {
242
+ writeFileSync(
243
+ this.feedPath,
244
+ JSON.stringify(this.latestFeed, null, 2),
245
+ );
246
+ } catch {}
247
+ }
248
+
249
+ private appendGameOverFromState(state: any): void {
250
+ if (this.gameOverEmitted || !this.events) return;
251
+ const event = cleanObject({
252
+ type: 'game_over',
253
+ synthetic: true,
254
+ source: '_state',
255
+ tick: state.tick,
256
+ winner: state.winner,
257
+ winning_faction: state.winning_faction,
258
+ result: state.result,
259
+ reason: state.reason,
260
+ });
261
+ this.events.append(event);
262
+ pushRecentEvent(this.recentEvents, event);
263
+ this.gameOverEmitted = true;
264
+ }
265
+
266
+ private handleState(data: Record<string, any>): void {
267
+ const s = data as any;
268
+ const stateSaysGameOver = isGameOverState(s);
269
+ this.currentPhase = s.phase ?? this.currentPhase;
270
+ this.currentYou = s.you ? { ...s.you } : this.currentYou;
271
+ this.currentGame = {
272
+ game_id: s.game_id,
273
+ alive_count: s.alive_count,
274
+ task_progress: s.task_progress,
275
+ };
276
+ if (this.currentPhase !== 'lobby') this.refreshPlayerHistoryMapCache();
277
+
278
+ if (s.meeting) {
279
+ if (this.currentMeetingRound === 0) {
280
+ this.currentMeetingRound = 1;
281
+ this.prevCurrentSpeaker = null;
282
+ }
283
+ const meetingProjection = buildMeetingStateProjection(s.meeting);
284
+ const meetingProjectionJson = JSON.stringify(meetingProjection);
285
+ if (meetingProjectionJson !== this.lastMeetingProjectionJson) {
286
+ this.events?.append({
287
+ type: 'meeting_state',
288
+ synthetic: true,
289
+ tick: s.tick,
290
+ round: this.currentMeetingRound,
291
+ ...meetingProjection,
292
+ });
293
+ this.lastMeetingProjectionJson = meetingProjectionJson;
294
+ }
295
+ this.currentMeeting = {
296
+ caller: s.meeting.caller,
297
+ sub_phase: s.meeting.sub_phase,
298
+ current_speaker: s.meeting.current_speaker,
299
+ is_my_turn: s.meeting.current_speaker === (s.you?.name ?? this.currentYou.name),
300
+ alive_players: s.meeting.alive_players,
301
+ speech_order: s.meeting.speech_order,
302
+ };
303
+ const myName = s.you?.name ?? this.currentYou.name;
304
+ const newSpeaker = s.meeting.current_speaker;
305
+ if (
306
+ typeof newSpeaker === 'string' &&
307
+ newSpeaker === myName &&
308
+ newSpeaker !== this.prevCurrentSpeaker &&
309
+ (s.tick == null || s.tick !== this.lastSpeechYourTurnTick)
310
+ ) {
311
+ const event = {
312
+ type: 'speech_your_turn',
313
+ synthetic: true,
314
+ tick: s.tick,
315
+ actor_name: myName,
316
+ };
317
+ this.events?.append(event);
318
+ pushRecentEvent(this.recentEvents, event);
319
+ this.lastSpeechYourTurnTick = s.tick;
320
+ }
321
+ this.prevCurrentSpeaker = typeof newSpeaker === 'string' ? newSpeaker : this.prevCurrentSpeaker;
322
+ if (s.meeting.sub_phase === 'vote') {
323
+ this.currentMeeting.votes_submitted = s.meeting.votes_submitted;
324
+ }
325
+ } else if (this.currentPhase !== 'meeting') {
326
+ this.currentMeeting = null;
327
+ }
328
+
329
+ const corpses: any[] = s.corpses ?? [];
330
+ let corpseNearby: any = null;
331
+ if (corpses.length > 0 && s.you?.x != null && s.you?.y != null) {
332
+ let bestDist = Infinity;
333
+ for (const c of corpses) {
334
+ if (c.x == null || c.y == null) continue;
335
+ const d = Math.sqrt((c.x - s.you.x) ** 2 + (c.y - s.you.y) ** 2);
336
+ if (d < bestDist) {
337
+ bestDist = d;
338
+ corpseNearby = { name: c.name, x: c.x, y: c.y, distance: Math.round(d) };
339
+ }
340
+ }
341
+ }
342
+
343
+ this.currentUrgent = {
344
+ meeting_started: this.currentPhase === 'meeting',
345
+ emergency_active: false,
346
+ corpse_nearby: corpseNearby,
347
+ game_over: this.currentPhase === 'game_over',
348
+ };
349
+
350
+ if (stateSaysGameOver || this.currentPhase === 'game_over') {
351
+ this.appendGameOverFromState(s);
352
+ }
353
+ this.writeFeed();
354
+ if (stateSaysGameOver || this.currentPhase === 'game_over') {
355
+ this.requestStop('game_over');
356
+ }
357
+ }
358
+
359
+ private handleEvent(data: Record<string, any>): void {
360
+ if (this.stopped) return;
361
+ try {
362
+ const eventSaysGameOver = isGameOverEvent(data);
363
+ if (data.type === 'speech_your_turn' && data.tick != null) {
364
+ this.lastSpeechYourTurnTick = data.tick;
365
+ }
366
+
367
+ if (data.type === '_state') {
368
+ this.handleState(data);
369
+ return;
370
+ }
371
+
372
+ this.events?.append(data);
373
+ pushRecentEvent(this.recentEvents, data);
374
+ if (data.type === 'player_spotted') {
375
+ this.refreshPlayerHistoryMapCache();
376
+ this.playerHistory?.recordPlayerSpotted(data);
377
+ }
378
+
379
+ if (data.type === 'meeting_start' || data.type === 'meeting_started') {
380
+ this.currentMeetingRound += 1;
381
+ this.lastMeetingProjectionJson = null;
382
+ this.prevCurrentSpeaker = null;
383
+ this.currentPhase = 'meeting';
384
+ this.currentMeeting = { sub_phase: 'speech' };
385
+ } else if (data.type === 'meeting_ended' || data.type === 'meeting_result_pending') {
386
+ this.currentPhase = 'wandering';
387
+ this.currentMeeting = null;
388
+ } else if (eventSaysGameOver) {
389
+ this.currentPhase = 'game_over';
390
+ this.currentMeeting = null;
391
+ this.gameOverEmitted = true;
392
+ } else if (data.type === 'game_started') {
393
+ this.currentPhase = 'wandering';
394
+ }
395
+
396
+ this.currentUrgent = {
397
+ meeting_started: this.currentPhase === 'meeting',
398
+ game_over: this.currentPhase === 'game_over',
399
+ };
400
+ this.writeFeed();
401
+
402
+ if (eventSaysGameOver) {
403
+ this.requestStop('game_over');
404
+ }
405
+ } catch (err) {
406
+ this.requestStop('error', err);
407
+ }
408
+ }
409
+ }