@myclaw163/clawclaw-cli 0.6.56 → 0.6.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/bin/clawclaw-cli.mjs +3 -3
  2. package/package.json +1 -1
  3. package/scripts/sync-bundled-skill.mjs +1 -1
  4. package/skills/clawclaw/SKILL.md +7 -3
  5. package/skills/clawclaw/references/GAME-MECHANICS.md +5 -5
  6. package/skills/clawclaw/references/STREAM.md +4 -3
  7. package/src/cli.ts +0 -43
  8. package/src/commands/config.ts +30 -30
  9. package/src/commands/game-start-plan.test.ts +10 -68
  10. package/src/commands/game.ts +318 -173
  11. package/src/commands/history.ts +1 -1
  12. package/src/commands/peek.ts +16 -9
  13. package/src/commands/setup/codex.ts +248 -248
  14. package/src/commands/setup/hermes.test.ts +96 -96
  15. package/src/commands/setup/hermes.ts +76 -76
  16. package/src/commands/setup/index.ts +13 -13
  17. package/src/commands/setup/openclaw.test.ts +114 -114
  18. package/src/commands/setup/openclaw.ts +147 -147
  19. package/src/commands/strategy.ts +29 -38
  20. package/src/commands/watch.test.ts +7 -11
  21. package/src/commands/watch.ts +77 -66
  22. package/src/lib/game-client.ts +3 -3
  23. package/src/lib/host-config-patcher.test.ts +130 -130
  24. package/src/lib/host-config-patcher.ts +151 -151
  25. package/src/lib/hub-reminder.ts +19 -19
  26. package/src/lib/strategy-export.test.ts +1 -1
  27. package/src/runtime/event-daemon.test.ts +81 -2
  28. package/src/runtime/event-daemon.ts +325 -287
  29. package/src/runtime/owner-control.ts +150 -0
  30. package/src/runtime/runtime-logger.ts +11 -3
  31. package/src/runtime/ws-client.test.ts +57 -0
  32. package/src/runtime/ws-client.ts +2 -2
  33. package/src/sdk/index.ts +4 -4
  34. package/src/strategies/game-utils.test.ts +27 -1
  35. package/src/strategies/game-utils.ts +27 -4
  36. package/src/strategies/goals/crab-octopus-reflexes.ts +4 -4
  37. package/src/strategies/goals/crab-sabotage-top.ts +1 -1
  38. package/src/strategies/goals/keep-away-goal.ts +10 -7
  39. package/src/strategies/goals/kill-frenzy-top.ts +5 -5
  40. package/src/strategies/goals/kill-target-goal.ts +2 -2
  41. package/src/strategies/goals/kill-target-top.ts +2 -2
  42. package/src/strategies/goals/lone-kill-core.ts +3 -3
  43. package/src/strategies/goals/lone-kill-task-top.ts +1 -1
  44. package/src/strategies/goals/task-kill-report-top.ts +3 -3
  45. package/src/strategies/goals/warrior-shrimp-top.ts +2 -2
  46. package/src/strategies/pathfind/escape-planner.ts +10 -3
  47. package/src/strategies/spawn.ts +16 -5
  48. package/src/strategies/strategy-loop.ts +9 -3
  49. package/src/runtime/daemon.ts +0 -100
  50. package/src/runtime/opening-mover.ts +0 -303
@@ -1,12 +1,10 @@
1
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
1
+ import { existsSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { GameClient } from '../lib/game-client.js';
4
4
  import { EventStore } from '../pipeline/event-store.js';
5
5
  import { getProfileStateDir } from '../lib/init-command.js';
6
6
  import { AuthStore } from '../lib/auth.js';
7
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
8
  import { isCclTestEnabled, rawWsLogPathForSession } from './raw-ws-log.js';
11
9
  import { PlayerHistoryStore } from '../perception/player-history-store.js';
12
10
 
@@ -14,13 +12,35 @@ const log = new RuntimeLogger();
14
12
 
15
13
  let feedSerial = 0;
16
14
  const MAX_RECENT_EVENTS = 50;
17
- const recentEvents: Record<string, any>[] = [];
18
15
 
19
- function pushRecentEvent(evt: Record<string, any>): void {
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 {
20
25
  recentEvents.push(evt);
21
26
  if (recentEvents.length > MAX_RECENT_EVENTS) recentEvents.shift();
22
27
  }
23
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
+
24
44
  export function buildMeetingStateProjection(meeting: any): Record<string, any> {
25
45
  return {
26
46
  caller: meeting?.caller ?? null,
@@ -34,14 +54,16 @@ export function buildMeetingStateProjection(meeting: any): Record<string, any> {
34
54
  };
35
55
  }
36
56
 
37
- /** Build feed.json consumed by the `ccl game start` stream tracks phase, current_speaker, urgent conditions. */
57
+ /** Build the in-memory owner snapshot consumed by Monitor and owner-control snapshot requests. */
38
58
  function buildFeed(
39
59
  youName: string,
40
60
  phase: string,
41
61
  urgent: Record<string, any>,
42
62
  meeting: any,
63
+ recentEvents: Record<string, any>[],
43
64
  you?: Record<string, any>,
44
65
  game?: Record<string, any>,
66
+ automation?: Record<string, any>,
45
67
  ): any {
46
68
  return {
47
69
  ts: new Date().toISOString(),
@@ -51,321 +73,337 @@ function buildFeed(
51
73
  game,
52
74
  urgent,
53
75
  meeting,
76
+ automation,
54
77
  recent_events: recentEvents.slice(-10),
55
78
  };
56
79
  }
57
80
 
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() });
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;
79
127
  }
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
128
 
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 { }
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
+ }
124
170
 
125
- events.append({ type: 'daemon_started' });
126
- log.info('DAEMON', `started pid=${process.pid}`);
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
+ }
127
192
 
128
- let reconnecting = false;
129
- const ensureWsConnected = async () => {
130
- if (reconnecting || client.wsConnected) return;
131
- reconnecting = true;
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;
132
197
  try {
133
198
  await client.discoverGameServer();
134
199
  if (!client.wsConnected) {
135
200
  await client.connectWs();
136
201
  }
137
202
  if (client.wsConnected) {
138
- log.info('DAEMON', `ws connected, game_server=${client._gameServerUrl}`);
203
+ log.info('EVENT_RUNTIME', `ws connected, game_server=${client._gameServerUrl}`);
139
204
  } else {
140
- log.info('DAEMON', 'waiting for game server / event websocket...');
205
+ log.info('EVENT_RUNTIME', 'waiting for game server / event websocket...');
141
206
  }
207
+ } catch (err) {
208
+ const message = err instanceof Error ? err.message : String(err);
209
+ log.warn('EVENT_RUNTIME', `ws connection attempt failed: ${message}`);
142
210
  } finally {
143
- reconnecting = false;
211
+ this.reconnecting = false;
144
212
  }
145
- };
213
+ }
146
214
 
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);
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
+ }
165
228
 
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
- }
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
+ }
171
248
 
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();
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
+ }
183
265
 
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
- }
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();
236
277
 
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
- }
278
+ if (s.meeting) {
279
+ if (this.currentMeetingRound === 0) {
280
+ this.currentMeetingRound = 1;
281
+ this.prevCurrentSpeaker = null;
250
282
  }
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',
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,
257
302
  };
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);
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;
276
320
  }
277
- return; // _state is synthetic, don't log to events.jsonl
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;
278
327
  }
279
328
 
280
- events.append(data);
281
- pushRecentEvent(data);
282
- if (data.type === 'player_spotted') {
283
- refreshPlayerHistoryMapCache();
284
- playerHistory.recordPlayerSpotted(data);
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
+ }
285
341
  }
286
342
 
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';
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');
302
356
  }
357
+ }
303
358
 
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
- };
359
+ private handleEvent(data: Record<string, any>): void {
360
+ if (this.stopped) return;
309
361
  try {
310
- writeFileSync(
311
- feedPath,
312
- JSON.stringify(
313
- buildFeed(profile.agentName, currentPhase, urgent, currentMeeting, currentYou, currentGame),
314
- null, 2,
315
- ),
316
- );
317
- } catch { }
362
+ const eventSaysGameOver = isGameOverEvent(data);
363
+ if (data.type === 'speech_your_turn' && data.tick != null) {
364
+ this.lastSpeechYourTurnTick = data.tick;
365
+ }
318
366
 
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 });
367
+ if (data.type === '_state') {
368
+ this.handleState(data);
369
+ return;
325
370
  }
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
371
 
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();
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
+ }
371
409
  }