@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,573 +1,573 @@
1
- import { Command } from 'commander';
2
- import { EventStore } from '../pipeline/event-store.js';
3
- import {
4
- PlayerHistoryStore,
5
- type FramePlayer,
6
- type LobsterTaskPoint,
7
- type PlayerHistoryData,
8
- type VisibleFrame,
9
- } from '../perception/player-history-store.js';
10
-
11
- type HistoryKind = 'stay' | 'last_appear' | 'rooms_seen';
12
-
13
- const KINDS = new Set<HistoryKind>(['stay', 'last_appear', 'rooms_seen']);
14
- const STOP_MIN_SECS = 2;
15
- const TASK_DONE_RADIUS = 15;
16
- const DIR_MIN_MOVE = 6;
17
-
18
- interface PlayerPoint {
19
- frame: VisibleFrame;
20
- pos: FramePlayer;
21
- }
22
-
23
- function round1(value: number): number {
24
- return Math.round(value * 10) / 10;
25
- }
26
-
27
- function dist(a: { x: number; y: number }, b: { x: number; y: number }): number {
28
- return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
29
- }
30
-
31
- function ageSecs(time: string, now: number): number {
32
- const ms = Date.parse(time);
33
- if (!Number.isFinite(ms)) return 0;
34
- return Math.max(0, round1((now - ms) / 1000));
35
- }
36
-
37
- function durationSecs(start: string, end: string): number {
38
- const startMs = Date.parse(start);
39
- const endMs = Date.parse(end);
40
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
41
- return Math.max(0, round1((endMs - startMs) / 1000));
42
- }
43
-
44
- function splitSelectors(values: string[]): string[] {
45
- return values
46
- .flatMap((value) => String(value).split(','))
47
- .map((value) => value.trim())
48
- .filter(Boolean);
49
- }
50
-
51
- function knownPlayerNames(data: PlayerHistoryData): string[] {
52
- const names = new Set<string>(Object.keys(data.seats ?? {}));
53
- for (const frame of data.visible_frames ?? []) {
54
- for (const name of Object.keys(frame.players ?? {})) names.add(name);
55
- }
56
- return Array.from(names).sort((a, b) => a.localeCompare(b));
57
- }
58
-
59
- function resolvePlayers(data: PlayerHistoryData, selectors: string[]): { players?: string[]; error?: Record<string, any> } {
60
- const names = knownPlayerNames(data);
61
- if (selectors.length === 0) {
62
- return { error: { code: 'missing_player_id', message: 'Provide a player id/name, list, or all.' } };
63
- }
64
- if (selectors.some((selector) => selector.toLowerCase() === 'all')) return { players: names };
65
-
66
- const resolved: string[] = [];
67
- for (const selector of selectors) {
68
- const seatMatch = selector.match(/^seat:(\d+)$/i);
69
- const seat = seatMatch ? Number(seatMatch[1]) : /^\d+$/.test(selector) ? Number(selector) : null;
70
- let matches: string[] = [];
71
- if (seat != null) {
72
- matches = names.filter((name) => data.seats?.[name] === seat);
73
- } else {
74
- matches = names.filter((name) => name === selector);
75
- if (matches.length === 0) matches = names.filter((name) => name.startsWith(selector));
76
- if (matches.length === 0) matches = names.filter((name) => name.toLowerCase().includes(selector.toLowerCase()));
77
- }
78
- if (matches.length === 0) {
79
- return { error: { code: 'not_found', selector, known_players: names } };
80
- }
81
- if (matches.length > 1) {
82
- return { error: { code: 'ambiguous', selector, candidates: matches } };
83
- }
84
- if (!resolved.includes(matches[0])) resolved.push(matches[0]);
85
- }
86
- return { players: resolved };
87
- }
88
-
89
- function pointsForPlayer(frames: VisibleFrame[], player: string): PlayerPoint[] {
90
- const points: PlayerPoint[] = [];
91
- for (const frame of frames) {
92
- const pos = frame.players?.[player];
93
- if (pos) points.push({ frame, pos });
94
- }
95
- return points;
96
- }
97
-
98
- function continuousRuns(points: PlayerPoint[]): PlayerPoint[][] {
99
- const runs: PlayerPoint[][] = [];
100
- let current: PlayerPoint[] = [];
101
- for (const point of points) {
102
- const prev = current[current.length - 1];
103
- if (!prev || point.frame.tick === prev.frame.tick + 1) {
104
- current.push(point);
105
- } else {
106
- if (current.length > 0) runs.push(current);
107
- current = [point];
108
- }
109
- }
110
- if (current.length > 0) runs.push(current);
111
- return runs;
112
- }
113
-
114
- function centroid(points: PlayerPoint[]): { x: number; y: number } {
115
- const sum = points.reduce((acc, point) => ({ x: acc.x + point.pos.x, y: acc.y + point.pos.y }), { x: 0, y: 0 });
116
- return { x: sum.x / points.length, y: sum.y / points.length };
117
- }
118
-
119
- function mostCommonRoom(points: PlayerPoint[]): string | undefined {
120
- const counts = new Map<string, number>();
121
- for (const point of points) {
122
- if (!point.pos.room) continue;
123
- counts.set(point.pos.room, (counts.get(point.pos.room) ?? 0) + 1);
124
- }
125
- return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
126
- }
127
-
128
- function nearTask(tasks: LobsterTaskPoint[], point: { x: number; y: number }): string | null {
129
- let best: { task: LobsterTaskPoint; distance: number } | null = null;
130
- for (const task of tasks) {
131
- const distance = dist(point, task);
132
- if (distance > TASK_DONE_RADIUS) continue;
133
- if (!best || distance < best.distance) best = { task, distance };
134
- }
135
- return best?.task.name ?? null;
136
- }
137
-
138
- function stayKey(data: PlayerHistoryData, point: PlayerPoint): string {
139
- const task = nearTask(data.lobster_tasks, point.pos);
140
- if (task) return `task:${task}`;
141
- return `pos:${point.pos.x},${point.pos.y}`;
142
- }
143
-
144
- function buildStayResult(data: PlayerHistoryData, player: string, now: number): Record<string, any> {
145
- const stays: Record<string, any>[] = [];
146
- for (const run of continuousRuns(pointsForPlayer(data.visible_frames, player))) {
147
- let segment: PlayerPoint[] = [];
148
- let segmentKey: string | null = null;
149
- for (const point of run) {
150
- const key = stayKey(data, point);
151
- if (segment.length === 0 || key === segmentKey) {
152
- segment.push(point);
153
- segmentKey = key;
154
- } else {
155
- appendStay(stays, segment, data.lobster_tasks, now);
156
- segment = [point];
157
- segmentKey = key;
158
- }
159
- }
160
- appendStay(stays, segment, data.lobster_tasks, now);
161
- }
162
- stays.sort((a, b) => a.to_ago - b.to_ago);
163
- return {
164
- player,
165
- ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
166
- stays,
167
- };
168
- }
169
-
170
- function appendStay(out: Record<string, any>[], segment: PlayerPoint[], tasks: LobsterTaskPoint[], now: number): void {
171
- if (segment.length < 2) return;
172
- const first = segment[0];
173
- const last = segment[segment.length - 1];
174
- const secs = durationSecs(first.frame.time, last.frame.time);
175
- if (secs < STOP_MIN_SECS) return;
176
- const center = centroid(segment);
177
- const room = mostCommonRoom(segment);
178
- out.push({
179
- from_ago: ageSecs(first.frame.time, now),
180
- to_ago: ageSecs(last.frame.time, now),
181
- secs,
182
- room,
183
- x: round1(center.x),
184
- y: round1(center.y),
185
- pos: `${round1(center.x)},${round1(center.y)}`,
186
- near_task: nearTask(tasks, center),
187
- });
188
- }
189
-
190
- function direction(from: { x: number; y: number }, to: { x: number; y: number }): string {
191
- const dx = to.x - from.x;
192
- const dy = to.y - from.y;
193
- if (Math.sqrt(dx ** 2 + dy ** 2) < DIR_MIN_MOVE) return 'stay';
194
- const horizontal = Math.abs(dx) >= DIR_MIN_MOVE / 2 ? (dx > 0 ? '右' : '左') : '';
195
- const vertical = Math.abs(dy) >= DIR_MIN_MOVE / 2 ? (dy > 0 ? '下' : '上') : '';
196
- return `${horizontal}${vertical}` || 'stay';
197
- }
198
-
199
- function buildLastAppearResult(data: PlayerHistoryData, player: string, now: number): Record<string, any> {
200
- const runs = continuousRuns(pointsForPlayer(data.visible_frames, player));
201
- const latestFrame = data.visible_frames[data.visible_frames.length - 1];
202
- const lastRun = runs[runs.length - 1] ?? [];
203
- const last = lastRun[lastRun.length - 1];
204
- if (!last) {
205
- return {
206
- player,
207
- ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
208
- visible: false,
209
- ago: null,
210
- };
211
- }
212
-
213
- const previous = lastRun.length >= 2 ? lastRun[lastRun.length - 2] : last;
214
- return {
215
- player,
216
- ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
217
- visible: latestFrame?.players?.[player] != null,
218
- ago: ageSecs(last.frame.time, now),
219
- room: last.pos.room,
220
- x: round1(last.pos.x),
221
- y: round1(last.pos.y),
222
- dir: direction(previous.pos, last.pos),
223
- };
224
- }
225
-
226
- function buildRoomsSeenResult(data: PlayerHistoryData, player: string, now: number): Record<string, any> {
227
- const rooms: Record<string, any>[] = [];
228
- for (const run of continuousRuns(pointsForPlayer(data.visible_frames, player))) {
229
- let currentRoom = run[0]?.pos.room ?? 'unknown';
230
- let start = run[0];
231
- let previous = run[0];
232
- for (const point of run.slice(1)) {
233
- const room = point.pos.room ?? 'unknown';
234
- if (room !== currentRoom) {
235
- rooms.push(roomSegment(currentRoom, start, previous, now));
236
- currentRoom = room;
237
- start = point;
238
- }
239
- previous = point;
240
- }
241
- if (start && previous) rooms.push(roomSegment(currentRoom, start, previous, now));
242
- }
243
- rooms.sort((a, b) => a.to_ago - b.to_ago);
244
- return {
245
- player,
246
- ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
247
- rooms,
248
- };
249
- }
250
-
251
- function roomSegment(room: string, start: PlayerPoint, end: PlayerPoint, now: number): Record<string, any> {
252
- return {
253
- room,
254
- from_ago: ageSecs(start.frame.time, now),
255
- to_ago: ageSecs(end.frame.time, now),
256
- secs: durationSecs(start.frame.time, end.frame.time),
257
- };
258
- }
259
-
260
- interface MeetingRecord {
261
- round: number;
262
- start?: Record<string, any>;
263
- briefing?: Record<string, any>;
264
- states: Record<string, any>[];
265
- speechEvents: Record<string, any>[];
266
- voteEvents: Record<string, any>[];
267
- resultEvent?: Record<string, any>;
268
- ended?: Record<string, any>;
269
- interrupted?: boolean;
270
- }
271
-
272
- interface DeathInfo {
273
- type: 'found_in_meeting' | 'exiled';
274
- round: number;
275
- }
276
-
277
- function buildMeetingRecords(events: Record<string, any>[]): MeetingRecord[] {
278
- const meetings: MeetingRecord[] = [];
279
- let current: MeetingRecord | null = null;
280
-
281
- const finishCurrent = (interrupted = false) => {
282
- if (!current) return;
283
- current.interrupted = current.interrupted || interrupted;
284
- meetings.push(current);
285
- current = null;
286
- };
287
-
288
- for (const event of events) {
289
- if (event.type === 'meeting_start') {
290
- finishCurrent(true);
291
- current = {
292
- round: meetings.length + 1,
293
- start: event,
294
- states: [],
295
- speechEvents: [],
296
- voteEvents: [],
297
- };
298
- continue;
299
- }
300
- if (!current) continue;
301
-
302
- if (event.type === 'meeting_briefing') current.briefing = event;
303
- if (event.type === 'meeting_state' && (event.round == null || event.round === current.round)) current.states.push(event);
304
- if (event.type === 'speech' || event.type === 'speech_skipped' || event.type === 'speech_turn') current.speechEvents.push(event);
305
- if (event.type === 'vote_cast') current.voteEvents.push(event);
306
- if (event.type === 'exile' || event.type === 'no_exile') current.resultEvent = event;
307
- if (event.type === 'meeting_ended') {
308
- current.ended = event;
309
- finishCurrent(false);
310
- continue;
311
- }
312
- if (event.type === 'game_over') {
313
- finishCurrent(true);
314
- }
315
- }
316
- finishCurrent(false);
317
- return meetings;
318
- }
319
-
320
- function latestMeetingState(record: MeetingRecord): Record<string, any> | undefined {
321
- return record.states[record.states.length - 1];
322
- }
323
-
324
- function meetingVotes(record: MeetingRecord): Record<string, string> {
325
- const resultVotes = record.resultEvent?.votes;
326
- if (resultVotes && typeof resultVotes === 'object' && !Array.isArray(resultVotes)) {
327
- return resultVotes as Record<string, string>;
328
- }
329
- const votes: Record<string, string> = {};
330
- for (const vote of record.voteEvents) {
331
- if (typeof vote.actor_name === 'string' && typeof vote.target === 'string') votes[vote.actor_name] = vote.target;
332
- }
333
- return votes;
334
- }
335
-
336
- function exiledPlayer(record: MeetingRecord): string | null {
337
- if (record.ended?.result === 'exiled' && typeof record.ended.result_target === 'string') return record.ended.result_target;
338
- if (record.resultEvent?.type === 'exile' && typeof record.resultEvent.actor_name === 'string') return record.resultEvent.actor_name;
339
- return null;
340
- }
341
-
342
- function meetingState(record: MeetingRecord): string {
343
- if (record.ended) return 'ended';
344
- const subPhase = latestMeetingState(record)?.sub_phase;
345
- if (typeof subPhase === 'string' && subPhase.length > 0) return subPhase;
346
- if (record.interrupted) return 'interrupted_or_missing_end';
347
- return 'interrupted_or_missing_end';
348
- }
349
-
350
- function meetingAllSeats(record: MeetingRecord): Array<{ name: string; seat: number }> {
351
- const allSeats = record.briefing?.all_seats;
352
- if (!Array.isArray(allSeats)) return [];
353
- return allSeats
354
- .filter((player: any) => typeof player?.name === 'string' && Number.isFinite(player.seat))
355
- .map((player: any) => ({ name: player.name, seat: player.seat }))
356
- .sort((a, b) => a.seat - b.seat);
357
- }
358
-
359
- function latestAlivePlayers(record: MeetingRecord): Set<string> | null {
360
- for (let i = record.states.length - 1; i >= 0; i--) {
361
- const alive = record.states[i].alive_players;
362
- if (Array.isArray(alive)) return new Set(alive.filter((name: any) => typeof name === 'string'));
363
- }
364
- return null;
365
- }
366
-
367
- function collectSpeechOrder(record: MeetingRecord): string[] {
368
- for (const state of record.states) {
369
- if (Array.isArray(state.speech_order)) {
370
- return state.speech_order.filter((name: any) => typeof name === 'string');
371
- }
372
- }
373
- const order: string[] = [];
374
- for (const event of record.speechEvents) {
375
- const speaker = event.actor_name;
376
- if (typeof speaker === 'string' && !order.includes(speaker)) order.push(speaker);
377
- }
378
- return order;
379
- }
380
-
381
- function meetingSpeeches(record: MeetingRecord): Array<{ speaker: string; text: string; skipped: boolean }> {
382
- const order = collectSpeechOrder(record);
383
- const texts = new Map<string, string>();
384
- const skipped = new Set<string>();
385
- const seen = new Set<string>();
386
-
387
- for (const state of record.states) {
388
- const stateSpeeches = state.speeches;
389
- if (stateSpeeches && typeof stateSpeeches === 'object' && !Array.isArray(stateSpeeches)) {
390
- for (const [speaker, text] of Object.entries(stateSpeeches)) {
391
- if (typeof speaker !== 'string') continue;
392
- texts.set(speaker, typeof text === 'string' ? text : '');
393
- seen.add(speaker);
394
- }
395
- }
396
- if (Array.isArray(state.speech_skipped)) {
397
- for (const speaker of state.speech_skipped) {
398
- if (typeof speaker !== 'string') continue;
399
- skipped.add(speaker);
400
- seen.add(speaker);
401
- }
402
- }
403
- }
404
-
405
- for (const event of record.speechEvents) {
406
- const speaker = event.actor_name;
407
- if (typeof speaker !== 'string') continue;
408
- if (event.type === 'speech') {
409
- texts.set(speaker, typeof event.text === 'string' ? event.text : '');
410
- seen.add(speaker);
411
- } else if (event.type === 'speech_skipped') {
412
- skipped.add(speaker);
413
- seen.add(speaker);
414
- }
415
- }
416
-
417
- const outputOrder = [...order, ...Array.from(seen).filter((speaker) => !order.includes(speaker))];
418
- return outputOrder
419
- .filter((speaker) => seen.has(speaker))
420
- .map((speaker) => ({
421
- speaker,
422
- text: texts.get(speaker) ?? '',
423
- skipped: skipped.has(speaker),
424
- }));
425
- }
426
-
427
- function applyMeetingDeaths(record: MeetingRecord, deaths: Map<string, DeathInfo>): void {
428
- const alive = latestAlivePlayers(record);
429
- if (alive) {
430
- for (const player of meetingAllSeats(record)) {
431
- if (!alive.has(player.name) && !deaths.has(player.name)) {
432
- deaths.set(player.name, { type: 'found_in_meeting', round: record.round });
433
- }
434
- }
435
- }
436
- const exiled = exiledPlayer(record);
437
- if (exiled) deaths.set(exiled, { type: 'exiled', round: record.round });
438
- }
439
-
440
- function meetingPlayers(record: MeetingRecord, deaths: Map<string, DeathInfo>): Record<string, any>[] {
441
- return meetingAllSeats(record).map((player) => {
442
- const death = deaths.get(player.name) ?? null;
443
- return {
444
- name: player.name,
445
- seat: player.seat,
446
- status: death ? 'dead' : 'alive',
447
- death,
448
- };
449
- });
450
- }
451
-
452
- function meetingOutput(record: MeetingRecord, deaths: Map<string, DeathInfo>): Record<string, any> {
453
- applyMeetingDeaths(record, deaths);
454
- return {
455
- round: record.round,
456
- tick: record.start?.tick ?? record.briefing?.tick ?? null,
457
- caller: record.start?.actor_name ?? record.briefing?.caller ?? latestMeetingState(record)?.caller ?? null,
458
- corpses_found: null,
459
- state: meetingState(record),
460
- exiled_player: exiledPlayer(record),
461
- players: meetingPlayers(record, deaths),
462
- votes: meetingVotes(record),
463
- speeches: meetingSpeeches(record),
464
- };
465
- }
466
-
467
- function allMeetingOutputs(records: MeetingRecord[]): Record<string, any>[] {
468
- const deaths = new Map<string, DeathInfo>();
469
- return records.map((record) => meetingOutput(record, deaths));
470
- }
471
-
472
- function createMeetingsCommand(): Command {
473
- return new Command('meetings')
474
- .description('Show meeting history')
475
- .argument('[round]', 'Meeting round number, starting from 1')
476
- .action((roundText?: string) => {
477
- const records = buildMeetingRecords(EventStore.forLatestGame().all());
478
- const outputs = allMeetingOutputs(records);
479
- if (roundText != null) {
480
- const round = Number(roundText);
481
- const record = Number.isInteger(round) && round > 0 ? outputs.find((meeting) => meeting.round === round) : null;
482
- if (!record) {
483
- console.log(JSON.stringify({
484
- error: {
485
- code: 'meeting_round_not_found',
486
- round: roundText,
487
- available_rounds: outputs.map((meeting) => meeting.round),
488
- },
489
- }, null, 2));
490
- return;
491
- }
492
- console.log(JSON.stringify(record, null, 2));
493
- return;
494
- }
495
- console.log(JSON.stringify({ meetings: outputs }, null, 2));
496
- });
497
- }
498
-
499
- function createPlayerHistorySubcommand(): Command {
500
- return new Command('player')
501
- .description('Show derived visible-player history')
502
- .argument('<kind>', 'stay | last_appear | rooms_seen')
503
- .argument('[player_id...]', 'Player name/seat/list, or all')
504
- .action(async (kind: string, playerArgs: string[]) => {
505
- if (!KINDS.has(kind as HistoryKind)) {
506
- console.log(JSON.stringify({ error: { code: 'invalid_kind', allowed: Array.from(KINDS) } }, null, 2));
507
- return;
508
- }
509
-
510
- const sessionPath = EventStore.latestSessionPath();
511
- if (!sessionPath) {
512
- console.log(JSON.stringify({
513
- kind,
514
- error: { code: 'no_game_events', message: 'No game events found. Join a game first.' },
515
- }, null, 2));
516
- return;
517
- }
518
- const data = PlayerHistoryStore.forSession(sessionPath).read();
519
- if (!data || !Array.isArray(data.visible_frames) || data.visible_frames.length === 0) {
520
- console.log(JSON.stringify({
521
- kind,
522
- error: {
523
- code: 'no_player_history',
524
- message: 'No player_spotted history yet. Join a game and wait for the event daemon to receive player_spotted events.',
525
- },
526
- }, null, 2));
527
- return;
528
- }
529
-
530
- const selectors = splitSelectors(playerArgs);
531
- const resolved = resolvePlayers(data, selectors);
532
- if (resolved.error) {
533
- console.log(JSON.stringify({ kind, error: resolved.error }, null, 2));
534
- return;
535
- }
536
-
537
- const now = Date.now();
538
- const players = resolved.players ?? [];
539
- const resultPlayers = players.map((player) => {
540
- if (kind === 'stay') return buildStayResult(data, player, now);
541
- if (kind === 'last_appear') return buildLastAppearResult(data, player, now);
542
- return buildRoomsSeenResult(data, player, now);
543
- });
544
-
545
- console.log(JSON.stringify({
546
- kind,
547
- time: new Date(now).toISOString(),
548
- players: resultPlayers,
549
- }, null, 2));
550
- });
551
- }
552
-
553
- export function createHistoryCommand(): Command {
554
- const history = new Command('history')
555
- .description('Show derived player and meeting history');
556
- history.addCommand(createPlayerHistorySubcommand());
557
- history.addCommand(createMeetingsCommand());
558
- return history;
559
- }
560
-
561
- export const playerHistoryCommandInternals = {
562
- direction,
563
- resolvePlayers,
564
- buildStayResult,
565
- buildLastAppearResult,
566
- buildRoomsSeenResult,
567
- };
568
-
569
- export const meetingHistoryCommandInternals = {
570
- buildMeetingRecords,
571
- allMeetingOutputs,
572
- meetingSpeeches,
573
- };
1
+ import { Command } from 'commander';
2
+ import { EventStore } from '../pipeline/event-store.js';
3
+ import {
4
+ PlayerHistoryStore,
5
+ type FramePlayer,
6
+ type LobsterTaskPoint,
7
+ type PlayerHistoryData,
8
+ type VisibleFrame,
9
+ } from '../perception/player-history-store.js';
10
+
11
+ type HistoryKind = 'stay' | 'last_appear' | 'rooms_seen';
12
+
13
+ const KINDS = new Set<HistoryKind>(['stay', 'last_appear', 'rooms_seen']);
14
+ const STOP_MIN_SECS = 2;
15
+ const TASK_DONE_RADIUS = 15;
16
+ const DIR_MIN_MOVE = 6;
17
+
18
+ interface PlayerPoint {
19
+ frame: VisibleFrame;
20
+ pos: FramePlayer;
21
+ }
22
+
23
+ function round1(value: number): number {
24
+ return Math.round(value * 10) / 10;
25
+ }
26
+
27
+ function dist(a: { x: number; y: number }, b: { x: number; y: number }): number {
28
+ return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
29
+ }
30
+
31
+ function ageSecs(time: string, now: number): number {
32
+ const ms = Date.parse(time);
33
+ if (!Number.isFinite(ms)) return 0;
34
+ return Math.max(0, round1((now - ms) / 1000));
35
+ }
36
+
37
+ function durationSecs(start: string, end: string): number {
38
+ const startMs = Date.parse(start);
39
+ const endMs = Date.parse(end);
40
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
41
+ return Math.max(0, round1((endMs - startMs) / 1000));
42
+ }
43
+
44
+ function splitSelectors(values: string[]): string[] {
45
+ return values
46
+ .flatMap((value) => String(value).split(','))
47
+ .map((value) => value.trim())
48
+ .filter(Boolean);
49
+ }
50
+
51
+ function knownPlayerNames(data: PlayerHistoryData): string[] {
52
+ const names = new Set<string>(Object.keys(data.seats ?? {}));
53
+ for (const frame of data.visible_frames ?? []) {
54
+ for (const name of Object.keys(frame.players ?? {})) names.add(name);
55
+ }
56
+ return Array.from(names).sort((a, b) => a.localeCompare(b));
57
+ }
58
+
59
+ function resolvePlayers(data: PlayerHistoryData, selectors: string[]): { players?: string[]; error?: Record<string, any> } {
60
+ const names = knownPlayerNames(data);
61
+ if (selectors.length === 0) {
62
+ return { error: { code: 'missing_player_id', message: 'Provide a player id/name, list, or all.' } };
63
+ }
64
+ if (selectors.some((selector) => selector.toLowerCase() === 'all')) return { players: names };
65
+
66
+ const resolved: string[] = [];
67
+ for (const selector of selectors) {
68
+ const seatMatch = selector.match(/^seat:(\d+)$/i);
69
+ const seat = seatMatch ? Number(seatMatch[1]) : /^\d+$/.test(selector) ? Number(selector) : null;
70
+ let matches: string[] = [];
71
+ if (seat != null) {
72
+ matches = names.filter((name) => data.seats?.[name] === seat);
73
+ } else {
74
+ matches = names.filter((name) => name === selector);
75
+ if (matches.length === 0) matches = names.filter((name) => name.startsWith(selector));
76
+ if (matches.length === 0) matches = names.filter((name) => name.toLowerCase().includes(selector.toLowerCase()));
77
+ }
78
+ if (matches.length === 0) {
79
+ return { error: { code: 'not_found', selector, known_players: names } };
80
+ }
81
+ if (matches.length > 1) {
82
+ return { error: { code: 'ambiguous', selector, candidates: matches } };
83
+ }
84
+ if (!resolved.includes(matches[0])) resolved.push(matches[0]);
85
+ }
86
+ return { players: resolved };
87
+ }
88
+
89
+ function pointsForPlayer(frames: VisibleFrame[], player: string): PlayerPoint[] {
90
+ const points: PlayerPoint[] = [];
91
+ for (const frame of frames) {
92
+ const pos = frame.players?.[player];
93
+ if (pos) points.push({ frame, pos });
94
+ }
95
+ return points;
96
+ }
97
+
98
+ function continuousRuns(points: PlayerPoint[]): PlayerPoint[][] {
99
+ const runs: PlayerPoint[][] = [];
100
+ let current: PlayerPoint[] = [];
101
+ for (const point of points) {
102
+ const prev = current[current.length - 1];
103
+ if (!prev || point.frame.tick === prev.frame.tick + 1) {
104
+ current.push(point);
105
+ } else {
106
+ if (current.length > 0) runs.push(current);
107
+ current = [point];
108
+ }
109
+ }
110
+ if (current.length > 0) runs.push(current);
111
+ return runs;
112
+ }
113
+
114
+ function centroid(points: PlayerPoint[]): { x: number; y: number } {
115
+ const sum = points.reduce((acc, point) => ({ x: acc.x + point.pos.x, y: acc.y + point.pos.y }), { x: 0, y: 0 });
116
+ return { x: sum.x / points.length, y: sum.y / points.length };
117
+ }
118
+
119
+ function mostCommonRoom(points: PlayerPoint[]): string | undefined {
120
+ const counts = new Map<string, number>();
121
+ for (const point of points) {
122
+ if (!point.pos.room) continue;
123
+ counts.set(point.pos.room, (counts.get(point.pos.room) ?? 0) + 1);
124
+ }
125
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
126
+ }
127
+
128
+ function nearTask(tasks: LobsterTaskPoint[], point: { x: number; y: number }): string | null {
129
+ let best: { task: LobsterTaskPoint; distance: number } | null = null;
130
+ for (const task of tasks) {
131
+ const distance = dist(point, task);
132
+ if (distance > TASK_DONE_RADIUS) continue;
133
+ if (!best || distance < best.distance) best = { task, distance };
134
+ }
135
+ return best?.task.name ?? null;
136
+ }
137
+
138
+ function stayKey(data: PlayerHistoryData, point: PlayerPoint): string {
139
+ const task = nearTask(data.lobster_tasks, point.pos);
140
+ if (task) return `task:${task}`;
141
+ return `pos:${point.pos.x},${point.pos.y}`;
142
+ }
143
+
144
+ function buildStayResult(data: PlayerHistoryData, player: string, now: number): Record<string, any> {
145
+ const stays: Record<string, any>[] = [];
146
+ for (const run of continuousRuns(pointsForPlayer(data.visible_frames, player))) {
147
+ let segment: PlayerPoint[] = [];
148
+ let segmentKey: string | null = null;
149
+ for (const point of run) {
150
+ const key = stayKey(data, point);
151
+ if (segment.length === 0 || key === segmentKey) {
152
+ segment.push(point);
153
+ segmentKey = key;
154
+ } else {
155
+ appendStay(stays, segment, data.lobster_tasks, now);
156
+ segment = [point];
157
+ segmentKey = key;
158
+ }
159
+ }
160
+ appendStay(stays, segment, data.lobster_tasks, now);
161
+ }
162
+ stays.sort((a, b) => a.to_ago - b.to_ago);
163
+ return {
164
+ player,
165
+ ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
166
+ stays,
167
+ };
168
+ }
169
+
170
+ function appendStay(out: Record<string, any>[], segment: PlayerPoint[], tasks: LobsterTaskPoint[], now: number): void {
171
+ if (segment.length < 2) return;
172
+ const first = segment[0];
173
+ const last = segment[segment.length - 1];
174
+ const secs = durationSecs(first.frame.time, last.frame.time);
175
+ if (secs < STOP_MIN_SECS) return;
176
+ const center = centroid(segment);
177
+ const room = mostCommonRoom(segment);
178
+ out.push({
179
+ from_ago: ageSecs(first.frame.time, now),
180
+ to_ago: ageSecs(last.frame.time, now),
181
+ secs,
182
+ room,
183
+ x: round1(center.x),
184
+ y: round1(center.y),
185
+ pos: `${round1(center.x)},${round1(center.y)}`,
186
+ near_task: nearTask(tasks, center),
187
+ });
188
+ }
189
+
190
+ function direction(from: { x: number; y: number }, to: { x: number; y: number }): string {
191
+ const dx = to.x - from.x;
192
+ const dy = to.y - from.y;
193
+ if (Math.sqrt(dx ** 2 + dy ** 2) < DIR_MIN_MOVE) return 'stay';
194
+ const horizontal = Math.abs(dx) >= DIR_MIN_MOVE / 2 ? (dx > 0 ? '右' : '左') : '';
195
+ const vertical = Math.abs(dy) >= DIR_MIN_MOVE / 2 ? (dy > 0 ? '下' : '上') : '';
196
+ return `${horizontal}${vertical}` || 'stay';
197
+ }
198
+
199
+ function buildLastAppearResult(data: PlayerHistoryData, player: string, now: number): Record<string, any> {
200
+ const runs = continuousRuns(pointsForPlayer(data.visible_frames, player));
201
+ const latestFrame = data.visible_frames[data.visible_frames.length - 1];
202
+ const lastRun = runs[runs.length - 1] ?? [];
203
+ const last = lastRun[lastRun.length - 1];
204
+ if (!last) {
205
+ return {
206
+ player,
207
+ ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
208
+ visible: false,
209
+ ago: null,
210
+ };
211
+ }
212
+
213
+ const previous = lastRun.length >= 2 ? lastRun[lastRun.length - 2] : last;
214
+ return {
215
+ player,
216
+ ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
217
+ visible: latestFrame?.players?.[player] != null,
218
+ ago: ageSecs(last.frame.time, now),
219
+ room: last.pos.room,
220
+ x: round1(last.pos.x),
221
+ y: round1(last.pos.y),
222
+ dir: direction(previous.pos, last.pos),
223
+ };
224
+ }
225
+
226
+ function buildRoomsSeenResult(data: PlayerHistoryData, player: string, now: number): Record<string, any> {
227
+ const rooms: Record<string, any>[] = [];
228
+ for (const run of continuousRuns(pointsForPlayer(data.visible_frames, player))) {
229
+ let currentRoom = run[0]?.pos.room ?? 'unknown';
230
+ let start = run[0];
231
+ let previous = run[0];
232
+ for (const point of run.slice(1)) {
233
+ const room = point.pos.room ?? 'unknown';
234
+ if (room !== currentRoom) {
235
+ rooms.push(roomSegment(currentRoom, start, previous, now));
236
+ currentRoom = room;
237
+ start = point;
238
+ }
239
+ previous = point;
240
+ }
241
+ if (start && previous) rooms.push(roomSegment(currentRoom, start, previous, now));
242
+ }
243
+ rooms.sort((a, b) => a.to_ago - b.to_ago);
244
+ return {
245
+ player,
246
+ ...(data.seats?.[player] != null ? { seat: data.seats[player] } : {}),
247
+ rooms,
248
+ };
249
+ }
250
+
251
+ function roomSegment(room: string, start: PlayerPoint, end: PlayerPoint, now: number): Record<string, any> {
252
+ return {
253
+ room,
254
+ from_ago: ageSecs(start.frame.time, now),
255
+ to_ago: ageSecs(end.frame.time, now),
256
+ secs: durationSecs(start.frame.time, end.frame.time),
257
+ };
258
+ }
259
+
260
+ interface MeetingRecord {
261
+ round: number;
262
+ start?: Record<string, any>;
263
+ briefing?: Record<string, any>;
264
+ states: Record<string, any>[];
265
+ speechEvents: Record<string, any>[];
266
+ voteEvents: Record<string, any>[];
267
+ resultEvent?: Record<string, any>;
268
+ ended?: Record<string, any>;
269
+ interrupted?: boolean;
270
+ }
271
+
272
+ interface DeathInfo {
273
+ type: 'found_in_meeting' | 'exiled';
274
+ round: number;
275
+ }
276
+
277
+ function buildMeetingRecords(events: Record<string, any>[]): MeetingRecord[] {
278
+ const meetings: MeetingRecord[] = [];
279
+ let current: MeetingRecord | null = null;
280
+
281
+ const finishCurrent = (interrupted = false) => {
282
+ if (!current) return;
283
+ current.interrupted = current.interrupted || interrupted;
284
+ meetings.push(current);
285
+ current = null;
286
+ };
287
+
288
+ for (const event of events) {
289
+ if (event.type === 'meeting_start') {
290
+ finishCurrent(true);
291
+ current = {
292
+ round: meetings.length + 1,
293
+ start: event,
294
+ states: [],
295
+ speechEvents: [],
296
+ voteEvents: [],
297
+ };
298
+ continue;
299
+ }
300
+ if (!current) continue;
301
+
302
+ if (event.type === 'meeting_briefing') current.briefing = event;
303
+ if (event.type === 'meeting_state' && (event.round == null || event.round === current.round)) current.states.push(event);
304
+ if (event.type === 'speech' || event.type === 'speech_skipped' || event.type === 'speech_turn') current.speechEvents.push(event);
305
+ if (event.type === 'vote_cast') current.voteEvents.push(event);
306
+ if (event.type === 'exile' || event.type === 'no_exile') current.resultEvent = event;
307
+ if (event.type === 'meeting_ended') {
308
+ current.ended = event;
309
+ finishCurrent(false);
310
+ continue;
311
+ }
312
+ if (event.type === 'game_over') {
313
+ finishCurrent(true);
314
+ }
315
+ }
316
+ finishCurrent(false);
317
+ return meetings;
318
+ }
319
+
320
+ function latestMeetingState(record: MeetingRecord): Record<string, any> | undefined {
321
+ return record.states[record.states.length - 1];
322
+ }
323
+
324
+ function meetingVotes(record: MeetingRecord): Record<string, string> {
325
+ const resultVotes = record.resultEvent?.votes;
326
+ if (resultVotes && typeof resultVotes === 'object' && !Array.isArray(resultVotes)) {
327
+ return resultVotes as Record<string, string>;
328
+ }
329
+ const votes: Record<string, string> = {};
330
+ for (const vote of record.voteEvents) {
331
+ if (typeof vote.actor_name === 'string' && typeof vote.target === 'string') votes[vote.actor_name] = vote.target;
332
+ }
333
+ return votes;
334
+ }
335
+
336
+ function exiledPlayer(record: MeetingRecord): string | null {
337
+ if (record.ended?.result === 'exiled' && typeof record.ended.result_target === 'string') return record.ended.result_target;
338
+ if (record.resultEvent?.type === 'exile' && typeof record.resultEvent.actor_name === 'string') return record.resultEvent.actor_name;
339
+ return null;
340
+ }
341
+
342
+ function meetingState(record: MeetingRecord): string {
343
+ if (record.ended) return 'ended';
344
+ const subPhase = latestMeetingState(record)?.sub_phase;
345
+ if (typeof subPhase === 'string' && subPhase.length > 0) return subPhase;
346
+ if (record.interrupted) return 'interrupted_or_missing_end';
347
+ return 'interrupted_or_missing_end';
348
+ }
349
+
350
+ function meetingAllSeats(record: MeetingRecord): Array<{ name: string; seat: number }> {
351
+ const allSeats = record.briefing?.all_seats;
352
+ if (!Array.isArray(allSeats)) return [];
353
+ return allSeats
354
+ .filter((player: any) => typeof player?.name === 'string' && Number.isFinite(player.seat))
355
+ .map((player: any) => ({ name: player.name, seat: player.seat }))
356
+ .sort((a, b) => a.seat - b.seat);
357
+ }
358
+
359
+ function latestAlivePlayers(record: MeetingRecord): Set<string> | null {
360
+ for (let i = record.states.length - 1; i >= 0; i--) {
361
+ const alive = record.states[i].alive_players;
362
+ if (Array.isArray(alive)) return new Set(alive.filter((name: any) => typeof name === 'string'));
363
+ }
364
+ return null;
365
+ }
366
+
367
+ function collectSpeechOrder(record: MeetingRecord): string[] {
368
+ for (const state of record.states) {
369
+ if (Array.isArray(state.speech_order)) {
370
+ return state.speech_order.filter((name: any) => typeof name === 'string');
371
+ }
372
+ }
373
+ const order: string[] = [];
374
+ for (const event of record.speechEvents) {
375
+ const speaker = event.actor_name;
376
+ if (typeof speaker === 'string' && !order.includes(speaker)) order.push(speaker);
377
+ }
378
+ return order;
379
+ }
380
+
381
+ function meetingSpeeches(record: MeetingRecord): Array<{ speaker: string; text: string; skipped: boolean }> {
382
+ const order = collectSpeechOrder(record);
383
+ const texts = new Map<string, string>();
384
+ const skipped = new Set<string>();
385
+ const seen = new Set<string>();
386
+
387
+ for (const state of record.states) {
388
+ const stateSpeeches = state.speeches;
389
+ if (stateSpeeches && typeof stateSpeeches === 'object' && !Array.isArray(stateSpeeches)) {
390
+ for (const [speaker, text] of Object.entries(stateSpeeches)) {
391
+ if (typeof speaker !== 'string') continue;
392
+ texts.set(speaker, typeof text === 'string' ? text : '');
393
+ seen.add(speaker);
394
+ }
395
+ }
396
+ if (Array.isArray(state.speech_skipped)) {
397
+ for (const speaker of state.speech_skipped) {
398
+ if (typeof speaker !== 'string') continue;
399
+ skipped.add(speaker);
400
+ seen.add(speaker);
401
+ }
402
+ }
403
+ }
404
+
405
+ for (const event of record.speechEvents) {
406
+ const speaker = event.actor_name;
407
+ if (typeof speaker !== 'string') continue;
408
+ if (event.type === 'speech') {
409
+ texts.set(speaker, typeof event.text === 'string' ? event.text : '');
410
+ seen.add(speaker);
411
+ } else if (event.type === 'speech_skipped') {
412
+ skipped.add(speaker);
413
+ seen.add(speaker);
414
+ }
415
+ }
416
+
417
+ const outputOrder = [...order, ...Array.from(seen).filter((speaker) => !order.includes(speaker))];
418
+ return outputOrder
419
+ .filter((speaker) => seen.has(speaker))
420
+ .map((speaker) => ({
421
+ speaker,
422
+ text: texts.get(speaker) ?? '',
423
+ skipped: skipped.has(speaker),
424
+ }));
425
+ }
426
+
427
+ function applyMeetingDeaths(record: MeetingRecord, deaths: Map<string, DeathInfo>): void {
428
+ const alive = latestAlivePlayers(record);
429
+ if (alive) {
430
+ for (const player of meetingAllSeats(record)) {
431
+ if (!alive.has(player.name) && !deaths.has(player.name)) {
432
+ deaths.set(player.name, { type: 'found_in_meeting', round: record.round });
433
+ }
434
+ }
435
+ }
436
+ const exiled = exiledPlayer(record);
437
+ if (exiled) deaths.set(exiled, { type: 'exiled', round: record.round });
438
+ }
439
+
440
+ function meetingPlayers(record: MeetingRecord, deaths: Map<string, DeathInfo>): Record<string, any>[] {
441
+ return meetingAllSeats(record).map((player) => {
442
+ const death = deaths.get(player.name) ?? null;
443
+ return {
444
+ name: player.name,
445
+ seat: player.seat,
446
+ status: death ? 'dead' : 'alive',
447
+ death,
448
+ };
449
+ });
450
+ }
451
+
452
+ function meetingOutput(record: MeetingRecord, deaths: Map<string, DeathInfo>): Record<string, any> {
453
+ applyMeetingDeaths(record, deaths);
454
+ return {
455
+ round: record.round,
456
+ tick: record.start?.tick ?? record.briefing?.tick ?? null,
457
+ caller: record.start?.actor_name ?? record.briefing?.caller ?? latestMeetingState(record)?.caller ?? null,
458
+ corpses_found: null,
459
+ state: meetingState(record),
460
+ exiled_player: exiledPlayer(record),
461
+ players: meetingPlayers(record, deaths),
462
+ votes: meetingVotes(record),
463
+ speeches: meetingSpeeches(record),
464
+ };
465
+ }
466
+
467
+ function allMeetingOutputs(records: MeetingRecord[]): Record<string, any>[] {
468
+ const deaths = new Map<string, DeathInfo>();
469
+ return records.map((record) => meetingOutput(record, deaths));
470
+ }
471
+
472
+ function createMeetingsCommand(): Command {
473
+ return new Command('meetings')
474
+ .description('Show meeting history')
475
+ .argument('[round]', 'Meeting round number, starting from 1')
476
+ .action((roundText?: string) => {
477
+ const records = buildMeetingRecords(EventStore.forLatestGame().all());
478
+ const outputs = allMeetingOutputs(records);
479
+ if (roundText != null) {
480
+ const round = Number(roundText);
481
+ const record = Number.isInteger(round) && round > 0 ? outputs.find((meeting) => meeting.round === round) : null;
482
+ if (!record) {
483
+ console.log(JSON.stringify({
484
+ error: {
485
+ code: 'meeting_round_not_found',
486
+ round: roundText,
487
+ available_rounds: outputs.map((meeting) => meeting.round),
488
+ },
489
+ }, null, 2));
490
+ return;
491
+ }
492
+ console.log(JSON.stringify(record, null, 2));
493
+ return;
494
+ }
495
+ console.log(JSON.stringify({ meetings: outputs }, null, 2));
496
+ });
497
+ }
498
+
499
+ function createPlayerHistorySubcommand(): Command {
500
+ return new Command('player')
501
+ .description('Show derived visible-player history')
502
+ .argument('<kind>', 'stay | last_appear | rooms_seen')
503
+ .argument('[player_id...]', 'Player name/seat/list, or all')
504
+ .action(async (kind: string, playerArgs: string[]) => {
505
+ if (!KINDS.has(kind as HistoryKind)) {
506
+ console.log(JSON.stringify({ error: { code: 'invalid_kind', allowed: Array.from(KINDS) } }, null, 2));
507
+ return;
508
+ }
509
+
510
+ const sessionPath = EventStore.latestSessionPath();
511
+ if (!sessionPath) {
512
+ console.log(JSON.stringify({
513
+ kind,
514
+ error: { code: 'no_game_events', message: 'No game events found. Join a game first.' },
515
+ }, null, 2));
516
+ return;
517
+ }
518
+ const data = PlayerHistoryStore.forSession(sessionPath).read();
519
+ if (!data || !Array.isArray(data.visible_frames) || data.visible_frames.length === 0) {
520
+ console.log(JSON.stringify({
521
+ kind,
522
+ error: {
523
+ code: 'no_player_history',
524
+ message: 'No player_spotted history yet. Join a game and wait for the game runtime to receive player_spotted events.',
525
+ },
526
+ }, null, 2));
527
+ return;
528
+ }
529
+
530
+ const selectors = splitSelectors(playerArgs);
531
+ const resolved = resolvePlayers(data, selectors);
532
+ if (resolved.error) {
533
+ console.log(JSON.stringify({ kind, error: resolved.error }, null, 2));
534
+ return;
535
+ }
536
+
537
+ const now = Date.now();
538
+ const players = resolved.players ?? [];
539
+ const resultPlayers = players.map((player) => {
540
+ if (kind === 'stay') return buildStayResult(data, player, now);
541
+ if (kind === 'last_appear') return buildLastAppearResult(data, player, now);
542
+ return buildRoomsSeenResult(data, player, now);
543
+ });
544
+
545
+ console.log(JSON.stringify({
546
+ kind,
547
+ time: new Date(now).toISOString(),
548
+ players: resultPlayers,
549
+ }, null, 2));
550
+ });
551
+ }
552
+
553
+ export function createHistoryCommand(): Command {
554
+ const history = new Command('history')
555
+ .description('Show derived player and meeting history');
556
+ history.addCommand(createPlayerHistorySubcommand());
557
+ history.addCommand(createMeetingsCommand());
558
+ return history;
559
+ }
560
+
561
+ export const playerHistoryCommandInternals = {
562
+ direction,
563
+ resolvePlayers,
564
+ buildStayResult,
565
+ buildLastAppearResult,
566
+ buildRoomsSeenResult,
567
+ };
568
+
569
+ export const meetingHistoryCommandInternals = {
570
+ buildMeetingRecords,
571
+ allMeetingOutputs,
572
+ meetingSpeeches,
573
+ };