foundryvtt-mcp 0.11.0

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/dist/character/manager.d.ts +92 -0
  4. package/dist/character/manager.d.ts.map +1 -0
  5. package/dist/character/manager.js +208 -0
  6. package/dist/character/manager.js.map +1 -0
  7. package/dist/combat/manager.d.ts +94 -0
  8. package/dist/combat/manager.d.ts.map +1 -0
  9. package/dist/combat/manager.js +373 -0
  10. package/dist/combat/manager.js.map +1 -0
  11. package/dist/config/index.d.ts +114 -0
  12. package/dist/config/index.d.ts.map +1 -0
  13. package/dist/config/index.js +177 -0
  14. package/dist/config/index.js.map +1 -0
  15. package/dist/diagnostics/client.d.ts +193 -0
  16. package/dist/diagnostics/client.d.ts.map +1 -0
  17. package/dist/diagnostics/client.js +314 -0
  18. package/dist/diagnostics/client.js.map +1 -0
  19. package/dist/diagnostics/types.d.ts +414 -0
  20. package/dist/diagnostics/types.d.ts.map +1 -0
  21. package/dist/diagnostics/types.js +94 -0
  22. package/dist/diagnostics/types.js.map +1 -0
  23. package/dist/foundry/auth.d.ts +19 -0
  24. package/dist/foundry/auth.d.ts.map +1 -0
  25. package/dist/foundry/auth.js +135 -0
  26. package/dist/foundry/auth.js.map +1 -0
  27. package/dist/foundry/client.d.ts +95 -0
  28. package/dist/foundry/client.d.ts.map +1 -0
  29. package/dist/foundry/client.js +692 -0
  30. package/dist/foundry/client.js.map +1 -0
  31. package/dist/foundry/types.d.ts +805 -0
  32. package/dist/foundry/types.d.ts.map +1 -0
  33. package/dist/foundry/types.js +13 -0
  34. package/dist/foundry/types.js.map +1 -0
  35. package/dist/index.d.ts +16 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +199 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/resources/index.d.ts +3 -0
  40. package/dist/resources/index.d.ts.map +1 -0
  41. package/dist/resources/index.js +93 -0
  42. package/dist/resources/index.js.map +1 -0
  43. package/dist/tools/base.d.ts +80 -0
  44. package/dist/tools/base.d.ts.map +1 -0
  45. package/dist/tools/base.js +73 -0
  46. package/dist/tools/base.js.map +1 -0
  47. package/dist/tools/definitions.d.ts +414 -0
  48. package/dist/tools/definitions.d.ts.map +1 -0
  49. package/dist/tools/definitions.js +429 -0
  50. package/dist/tools/definitions.js.map +1 -0
  51. package/dist/tools/handlers/actors.d.ts +31 -0
  52. package/dist/tools/handlers/actors.d.ts.map +1 -0
  53. package/dist/tools/handlers/actors.js +76 -0
  54. package/dist/tools/handlers/actors.js.map +1 -0
  55. package/dist/tools/handlers/chat.d.ts +13 -0
  56. package/dist/tools/handlers/chat.d.ts.map +1 -0
  57. package/dist/tools/handlers/chat.js +38 -0
  58. package/dist/tools/handlers/chat.js.map +1 -0
  59. package/dist/tools/handlers/combat.d.ts +11 -0
  60. package/dist/tools/handlers/combat.d.ts.map +1 -0
  61. package/dist/tools/handlers/combat.js +48 -0
  62. package/dist/tools/handlers/combat.js.map +1 -0
  63. package/dist/tools/handlers/diagnostics.d.ts +64 -0
  64. package/dist/tools/handlers/diagnostics.d.ts.map +1 -0
  65. package/dist/tools/handlers/diagnostics.js +182 -0
  66. package/dist/tools/handlers/diagnostics.js.map +1 -0
  67. package/dist/tools/handlers/dice.d.ts +39 -0
  68. package/dist/tools/handlers/dice.d.ts.map +1 -0
  69. package/dist/tools/handlers/dice.js +53 -0
  70. package/dist/tools/handlers/dice.js.map +1 -0
  71. package/dist/tools/handlers/generation.d.ts +44 -0
  72. package/dist/tools/handlers/generation.d.ts.map +1 -0
  73. package/dist/tools/handlers/generation.js +205 -0
  74. package/dist/tools/handlers/generation.js.map +1 -0
  75. package/dist/tools/handlers/items.d.ts +21 -0
  76. package/dist/tools/handlers/items.d.ts.map +1 -0
  77. package/dist/tools/handlers/items.js +50 -0
  78. package/dist/tools/handlers/items.js.map +1 -0
  79. package/dist/tools/handlers/journals.d.ts +22 -0
  80. package/dist/tools/handlers/journals.d.ts.map +1 -0
  81. package/dist/tools/handlers/journals.js +62 -0
  82. package/dist/tools/handlers/journals.js.map +1 -0
  83. package/dist/tools/handlers/resources.d.ts +13 -0
  84. package/dist/tools/handlers/resources.d.ts.map +1 -0
  85. package/dist/tools/handlers/resources.js +188 -0
  86. package/dist/tools/handlers/resources.js.map +1 -0
  87. package/dist/tools/handlers/scenes.d.ts +18 -0
  88. package/dist/tools/handlers/scenes.d.ts.map +1 -0
  89. package/dist/tools/handlers/scenes.js +34 -0
  90. package/dist/tools/handlers/scenes.js.map +1 -0
  91. package/dist/tools/handlers/users.d.ts +11 -0
  92. package/dist/tools/handlers/users.d.ts.map +1 -0
  93. package/dist/tools/handlers/users.js +34 -0
  94. package/dist/tools/handlers/users.js.map +1 -0
  95. package/dist/tools/handlers/utils.d.ts +11 -0
  96. package/dist/tools/handlers/utils.d.ts.map +1 -0
  97. package/dist/tools/handlers/utils.js +24 -0
  98. package/dist/tools/handlers/utils.js.map +1 -0
  99. package/dist/tools/handlers/world.d.ts +26 -0
  100. package/dist/tools/handlers/world.d.ts.map +1 -0
  101. package/dist/tools/handlers/world.js +92 -0
  102. package/dist/tools/handlers/world.js.map +1 -0
  103. package/dist/tools/index.d.ts +19 -0
  104. package/dist/tools/index.d.ts.map +1 -0
  105. package/dist/tools/index.js +22 -0
  106. package/dist/tools/index.js.map +1 -0
  107. package/dist/tools/new-router.d.ts +29 -0
  108. package/dist/tools/new-router.d.ts.map +1 -0
  109. package/dist/tools/new-router.js +97 -0
  110. package/dist/tools/new-router.js.map +1 -0
  111. package/dist/tools/registry.d.ts +51 -0
  112. package/dist/tools/registry.d.ts.map +1 -0
  113. package/dist/tools/registry.js +79 -0
  114. package/dist/tools/registry.js.map +1 -0
  115. package/dist/tools/resources.d.ts +16 -0
  116. package/dist/tools/resources.d.ts.map +1 -0
  117. package/dist/tools/resources.js +63 -0
  118. package/dist/tools/resources.js.map +1 -0
  119. package/dist/tools/router.d.ts +22 -0
  120. package/dist/tools/router.d.ts.map +1 -0
  121. package/dist/tools/router.js +128 -0
  122. package/dist/tools/router.js.map +1 -0
  123. package/dist/utils/cache-instance.d.ts +25 -0
  124. package/dist/utils/cache-instance.d.ts.map +1 -0
  125. package/dist/utils/cache-instance.js +30 -0
  126. package/dist/utils/cache-instance.js.map +1 -0
  127. package/dist/utils/cache.d.ts +80 -0
  128. package/dist/utils/cache.d.ts.map +1 -0
  129. package/dist/utils/cache.js +205 -0
  130. package/dist/utils/cache.js.map +1 -0
  131. package/dist/utils/diagnostics.d.ts +114 -0
  132. package/dist/utils/diagnostics.d.ts.map +1 -0
  133. package/dist/utils/diagnostics.js +330 -0
  134. package/dist/utils/diagnostics.js.map +1 -0
  135. package/dist/utils/logger.d.ts +137 -0
  136. package/dist/utils/logger.d.ts.map +1 -0
  137. package/dist/utils/logger.js +158 -0
  138. package/dist/utils/logger.js.map +1 -0
  139. package/package.json +98 -0
@@ -0,0 +1,692 @@
1
+ /**
2
+ * FoundryVTT client for API communication via Socket.IO
3
+ *
4
+ * Connects to FoundryVTT using the proven 4-step authentication flow,
5
+ * caches worldData in memory, and serves all queries from the snapshot.
6
+ */
7
+ import axios from 'axios';
8
+ import { io } from 'socket.io-client';
9
+ import { z } from 'zod';
10
+ import { logger } from '../utils/logger.js';
11
+ import { authenticateFoundry } from './auth.js';
12
+ /** FoundryVTT document IDs are 16-character alphanumeric strings. */
13
+ const FOUNDRY_ID_PATTERN = /^[a-zA-Z0-9]{16}$/;
14
+ /**
15
+ * Minimal Zod schema for the WorldData Socket.IO payload.
16
+ * Validates the required top-level array fields; extra fields pass through.
17
+ */
18
+ const WorldDataSchema = z.object({
19
+ userId: z.string(),
20
+ actors: z.array(z.unknown()),
21
+ scenes: z.array(z.unknown()),
22
+ items: z.array(z.unknown()),
23
+ journal: z.array(z.unknown()),
24
+ messages: z.array(z.unknown()),
25
+ combats: z.array(z.unknown()),
26
+ users: z.array(z.unknown()),
27
+ activeUsers: z.array(z.string()),
28
+ macros: z.array(z.unknown()),
29
+ playlists: z.array(z.unknown()),
30
+ tables: z.array(z.unknown()),
31
+ folders: z.array(z.unknown()),
32
+ });
33
+ export class FoundryClient {
34
+ http;
35
+ socket = null;
36
+ config;
37
+ _isConnected = false;
38
+ worldData = null;
39
+ constructor(config) {
40
+ if (!config.baseUrl || config.baseUrl.trim() === '') {
41
+ throw new Error('baseUrl is required and cannot be empty');
42
+ }
43
+ try {
44
+ new URL(config.baseUrl);
45
+ }
46
+ catch {
47
+ throw new Error(`Invalid baseUrl: ${config.baseUrl}`);
48
+ }
49
+ this.config = {
50
+ timeout: 10000,
51
+ retryAttempts: 3,
52
+ retryDelay: 1000,
53
+ socketPath: '/socket.io/',
54
+ ...config,
55
+ };
56
+ this.http = axios.create({
57
+ baseURL: this.config.baseUrl,
58
+ timeout: this.config.timeout || 30000,
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'User-Agent': 'FoundryMCP/0.2.0',
62
+ },
63
+ maxRedirects: 3,
64
+ maxContentLength: 50 * 1024 * 1024,
65
+ maxBodyLength: 50 * 1024 * 1024,
66
+ validateStatus: (status) => status >= 200 && status < 300,
67
+ });
68
+ if (this.config.apiKey) {
69
+ this.http.interceptors.request.use((reqConfig) => {
70
+ reqConfig.headers['x-api-key'] = this.config.apiKey;
71
+ return reqConfig;
72
+ });
73
+ }
74
+ const mode = this.config.apiKey ? 'REST API' : 'Socket.IO';
75
+ logger.info(`FoundryVTT client initialized (${mode} mode)`);
76
+ }
77
+ /**
78
+ * Connects to FoundryVTT.
79
+ * REST API mode: tests /api/status endpoint.
80
+ * Socket.IO mode: authenticates and loads full worldData.
81
+ */
82
+ async connect() {
83
+ if (this.config.apiKey) {
84
+ try {
85
+ await this.http.get('/api/status');
86
+ this._isConnected = true;
87
+ logger.info('Connected to FoundryVTT via REST API module');
88
+ }
89
+ catch (error) {
90
+ logger.error('Failed to connect via REST API module:', error);
91
+ throw error;
92
+ }
93
+ return;
94
+ }
95
+ const user = this.config.userId || this.config.username;
96
+ if (!user || !this.config.password) {
97
+ throw new Error('Socket.IO mode requires username/userId and password. ' +
98
+ 'Set FOUNDRY_USERNAME + FOUNDRY_PASSWORD or FOUNDRY_USER_ID + FOUNDRY_PASSWORD.');
99
+ }
100
+ const { session } = await authenticateFoundry(this.config.baseUrl, user, this.config.password);
101
+ // Connect authenticated socket and load world data
102
+ this.worldData = await this.connectAndLoadWorld(session);
103
+ this._isConnected = true;
104
+ logger.info('Connected to FoundryVTT via Socket.IO', {
105
+ actors: this.worldData.actors.length,
106
+ scenes: this.worldData.scenes.length,
107
+ items: this.worldData.items.length,
108
+ });
109
+ }
110
+ /**
111
+ * Connects Socket.IO with an authenticated session and loads worldData.
112
+ */
113
+ connectAndLoadWorld(session) {
114
+ return new Promise((resolve, reject) => {
115
+ this.socket = io(this.config.baseUrl, {
116
+ transports: ['websocket'],
117
+ query: { session },
118
+ });
119
+ const cleanup = () => {
120
+ this.socket?.off('session', onSession);
121
+ this.socket?.off('connect_error', onConnectError);
122
+ };
123
+ const timeout = setTimeout(() => {
124
+ cleanup();
125
+ this.socket?.disconnect();
126
+ reject(new Error('Timeout waiting for world data (15s)'));
127
+ }, 15000);
128
+ const onSession = (data) => {
129
+ if (!data?.userId) {
130
+ clearTimeout(timeout);
131
+ cleanup();
132
+ this.socket?.disconnect();
133
+ return reject(new Error('Authentication failed — session event returned no userId'));
134
+ }
135
+ this.socket?.emit('world', (worldData) => {
136
+ clearTimeout(timeout);
137
+ cleanup();
138
+ const parsed = WorldDataSchema.safeParse(worldData);
139
+ if (!parsed.success) {
140
+ logger.warn('WorldData failed schema validation — proceeding with raw data', {
141
+ issues: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
142
+ });
143
+ }
144
+ resolve(worldData);
145
+ });
146
+ };
147
+ const onConnectError = (err) => {
148
+ clearTimeout(timeout);
149
+ cleanup();
150
+ reject(new Error(`Socket.IO connection failed: ${err.message}`));
151
+ };
152
+ this.socket.on('session', onSession);
153
+ this.socket.on('connect_error', onConnectError);
154
+ });
155
+ }
156
+ async disconnect() {
157
+ if (this.socket) {
158
+ this.socket.disconnect();
159
+ this.socket = null;
160
+ }
161
+ this.worldData = null;
162
+ this._isConnected = false;
163
+ logger.info('FoundryVTT client disconnected');
164
+ }
165
+ isConnected() {
166
+ return this._isConnected;
167
+ }
168
+ /**
169
+ * Returns true if worldData is available (Socket.IO mode connected).
170
+ */
171
+ hasWorldData() {
172
+ return this.worldData !== null;
173
+ }
174
+ // ==========================================================================
175
+ // World data accessors
176
+ // ==========================================================================
177
+ /**
178
+ * Re-emits 'world' on the existing socket to refresh the cached snapshot.
179
+ */
180
+ async refreshWorldData() {
181
+ if (!this.socket?.connected) {
182
+ throw new Error('Not connected — cannot refresh world data');
183
+ }
184
+ this.worldData = await new Promise((resolve, reject) => {
185
+ const timeout = setTimeout(() => reject(new Error('Refresh timeout')), 15000);
186
+ this.socket?.emit('world', (data) => {
187
+ clearTimeout(timeout);
188
+ const parsed = WorldDataSchema.safeParse(data);
189
+ if (!parsed.success) {
190
+ logger.warn('WorldData refresh failed schema validation — proceeding with raw data', {
191
+ issues: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
192
+ });
193
+ }
194
+ resolve(data);
195
+ });
196
+ });
197
+ logger.info('World data refreshed', {
198
+ actors: this.worldData.actors.length,
199
+ items: this.worldData.items.length,
200
+ });
201
+ }
202
+ getWorldData() {
203
+ return this.worldData;
204
+ }
205
+ // ==========================================================================
206
+ // Actor methods
207
+ // ==========================================================================
208
+ async searchActors(params) {
209
+ if (this.config.apiKey) {
210
+ return this.executeWithRetry(async () => {
211
+ const response = await this.http.get('/api/actors', { params });
212
+ return response.data;
213
+ });
214
+ }
215
+ if (!this.worldData) {
216
+ return { actors: [], total: 0, page: 1, limit: params.limit || 10 };
217
+ }
218
+ let results = this.worldData.actors;
219
+ if (params.query) {
220
+ const q = params.query.toLowerCase();
221
+ results = results.filter((a) => a.name.toLowerCase().includes(q));
222
+ }
223
+ if (params.type) {
224
+ const t = params.type.toLowerCase();
225
+ results = results.filter((a) => a.type.toLowerCase() === t);
226
+ }
227
+ const total = results.length;
228
+ const limit = params.limit || 10;
229
+ const actors = results.slice(0, limit).map(worldActorToFoundry);
230
+ return { actors, total, page: 1, limit };
231
+ }
232
+ async getActor(actorId) {
233
+ if (!FOUNDRY_ID_PATTERN.test(actorId)) {
234
+ throw new Error(`Invalid actorId format: ${actorId}`);
235
+ }
236
+ if (this.config.apiKey) {
237
+ return this.executeWithRetry(async () => {
238
+ const response = await this.http.get(`/api/actors/${actorId}`);
239
+ return response.data;
240
+ });
241
+ }
242
+ if (!this.worldData) {
243
+ throw new Error('Not connected — no world data available');
244
+ }
245
+ const actor = this.worldData.actors.find((a) => a._id === actorId);
246
+ if (!actor) {
247
+ throw new Error(`Actor not found: ${actorId}`);
248
+ }
249
+ return worldActorToFoundry(actor);
250
+ }
251
+ /**
252
+ * Returns the raw WorldActor with the full system data (game-system specific).
253
+ */
254
+ getRawActor(actorId) {
255
+ return this.worldData?.actors.find((a) => a._id === actorId);
256
+ }
257
+ // ==========================================================================
258
+ // Item methods
259
+ // ==========================================================================
260
+ async searchItems(params) {
261
+ if (this.config.apiKey) {
262
+ return this.executeWithRetry(async () => {
263
+ const response = await this.http.get('/api/items', { params });
264
+ return response.data;
265
+ });
266
+ }
267
+ if (!this.worldData) {
268
+ return { items: [], total: 0, page: 1, limit: params.limit || 10 };
269
+ }
270
+ let results = this.worldData.items;
271
+ if (params.query) {
272
+ const q = params.query.toLowerCase();
273
+ results = results.filter((i) => i.name.toLowerCase().includes(q));
274
+ }
275
+ if (params.type) {
276
+ const t = params.type.toLowerCase();
277
+ results = results.filter((i) => i.type.toLowerCase() === t);
278
+ }
279
+ const total = results.length;
280
+ const limit = params.limit || 10;
281
+ const items = results.slice(0, limit).map((i) => {
282
+ const item = {
283
+ _id: i._id,
284
+ name: i.name,
285
+ type: i.type,
286
+ };
287
+ if (i.img) {
288
+ item.img = i.img;
289
+ }
290
+ const desc = extractString(i.system, 'description', 'value');
291
+ if (desc) {
292
+ item.description = desc;
293
+ }
294
+ const rar = extractString(i.system, 'rarity');
295
+ if (rar) {
296
+ item.rarity = rar;
297
+ }
298
+ return item;
299
+ });
300
+ return { items, total, page: 1, limit };
301
+ }
302
+ // ==========================================================================
303
+ // Scene methods
304
+ // ==========================================================================
305
+ async getCurrentScene(sceneId) {
306
+ if (sceneId !== undefined && !FOUNDRY_ID_PATTERN.test(sceneId)) {
307
+ throw new Error(`Invalid sceneId format: ${sceneId}`);
308
+ }
309
+ if (this.config.apiKey) {
310
+ return this.executeWithRetry(async () => {
311
+ const endpoint = sceneId ? `/api/scenes/${sceneId}` : '/api/scenes/current';
312
+ const response = await this.http.get(endpoint);
313
+ return response.data;
314
+ });
315
+ }
316
+ if (!this.worldData) {
317
+ throw new Error('Not connected — no world data available');
318
+ }
319
+ let scene;
320
+ if (sceneId) {
321
+ scene = this.worldData.scenes.find((s) => s._id === sceneId);
322
+ }
323
+ else {
324
+ scene = this.worldData.scenes.find((s) => s.active);
325
+ }
326
+ if (!scene) {
327
+ throw new Error(sceneId ? `Scene not found: ${sceneId}` : 'No active scene');
328
+ }
329
+ return worldSceneToFoundry(scene);
330
+ }
331
+ async getScene(sceneId) {
332
+ return this.getCurrentScene(sceneId);
333
+ }
334
+ getScenes() {
335
+ return this.worldData?.scenes || [];
336
+ }
337
+ // ==========================================================================
338
+ // World info
339
+ // ==========================================================================
340
+ async getWorldInfo() {
341
+ if (this.config.apiKey) {
342
+ return this.executeWithRetry(async () => {
343
+ const response = await this.http.get('/api/world');
344
+ return response.data;
345
+ });
346
+ }
347
+ if (!this.worldData) {
348
+ return {
349
+ id: 'unknown',
350
+ title: 'Not connected',
351
+ description: 'Connect to FoundryVTT to retrieve world information',
352
+ system: 'unknown',
353
+ coreVersion: 'unknown',
354
+ systemVersion: 'unknown',
355
+ playtime: 0,
356
+ created: new Date().toISOString(),
357
+ modified: new Date().toISOString(),
358
+ };
359
+ }
360
+ const w = this.worldData.world;
361
+ const s = this.worldData.system;
362
+ const r = this.worldData.release;
363
+ return {
364
+ id: w.id || 'unknown',
365
+ title: w.title || 'Unknown World',
366
+ description: w.description || '',
367
+ system: s.id || 'unknown',
368
+ coreVersion: r.version || r.generation || 'unknown',
369
+ systemVersion: s.version || 'unknown',
370
+ playtime: 0,
371
+ created: new Date().toISOString(),
372
+ modified: new Date().toISOString(),
373
+ };
374
+ }
375
+ // ==========================================================================
376
+ // Combat
377
+ // ==========================================================================
378
+ getCombatState() {
379
+ if (!this.worldData) {
380
+ return null;
381
+ }
382
+ return this.worldData.combats.find((c) => c.active) ?? null;
383
+ }
384
+ // ==========================================================================
385
+ // Chat messages
386
+ // ==========================================================================
387
+ getChatMessages(limit = 20) {
388
+ if (!this.worldData) {
389
+ return [];
390
+ }
391
+ return this.worldData.messages.slice(-limit);
392
+ }
393
+ // ==========================================================================
394
+ // Users
395
+ // ==========================================================================
396
+ getUsers() {
397
+ if (!this.worldData) {
398
+ return { users: [], activeUsers: [] };
399
+ }
400
+ return {
401
+ users: this.worldData.users,
402
+ activeUsers: this.worldData.activeUsers,
403
+ };
404
+ }
405
+ // ==========================================================================
406
+ // Journals
407
+ // ==========================================================================
408
+ getJournals() {
409
+ return this.worldData?.journal || [];
410
+ }
411
+ searchJournals(query) {
412
+ if (!this.worldData) {
413
+ return [];
414
+ }
415
+ const q = query.toLowerCase();
416
+ return this.worldData.journal.filter((j) => {
417
+ if (j.name.toLowerCase().includes(q)) {
418
+ return true;
419
+ }
420
+ return j.pages?.some((p) => p.name.toLowerCase().includes(q) || p.text?.content?.toLowerCase().includes(q));
421
+ });
422
+ }
423
+ getJournal(journalId) {
424
+ return this.worldData?.journal.find((j) => j._id === journalId);
425
+ }
426
+ // ==========================================================================
427
+ // Cross-collection search
428
+ // ==========================================================================
429
+ searchWorld(query) {
430
+ if (!this.worldData) {
431
+ return { actors: [], items: [], scenes: [], journals: [] };
432
+ }
433
+ const q = query.toLowerCase();
434
+ return {
435
+ actors: this.worldData.actors.filter((a) => a.name.toLowerCase().includes(q)),
436
+ items: this.worldData.items.filter((i) => i.name.toLowerCase().includes(q)),
437
+ scenes: this.worldData.scenes.filter((s) => s.name.toLowerCase().includes(q)),
438
+ journals: this.worldData.journal.filter((j) => j.name.toLowerCase().includes(q)),
439
+ };
440
+ }
441
+ // ==========================================================================
442
+ // World summary
443
+ // ==========================================================================
444
+ getWorldSummary() {
445
+ if (!this.worldData) {
446
+ return {};
447
+ }
448
+ return {
449
+ actors: this.worldData.actors.length,
450
+ items: this.worldData.items.length,
451
+ scenes: this.worldData.scenes.length,
452
+ journals: this.worldData.journal.length,
453
+ combats: this.worldData.combats.length,
454
+ users: this.worldData.users.length,
455
+ messages: this.worldData.messages.length,
456
+ macros: this.worldData.macros.length,
457
+ playlists: this.worldData.playlists.length,
458
+ tables: this.worldData.tables.length,
459
+ folders: this.worldData.folders.length,
460
+ };
461
+ }
462
+ // ==========================================================================
463
+ // Dice rolling
464
+ // ==========================================================================
465
+ async rollDice(formula, reason) {
466
+ const DICE_FORMULA_REGEX = /^[0-9d\s+\-()]+$/;
467
+ if (!formula || formula.length > 100 || !DICE_FORMULA_REGEX.test(formula)) {
468
+ throw new Error(`Invalid dice formula: ${formula}`);
469
+ }
470
+ if (this.config.apiKey) {
471
+ try {
472
+ const response = await this.http.post('/api/dice/roll', {
473
+ formula,
474
+ flavor: reason,
475
+ });
476
+ const result = {
477
+ formula,
478
+ total: response.data.total,
479
+ breakdown: response.data.terms
480
+ ?.map((term) => term.results?.join(', '))
481
+ .join(' + ') || formula,
482
+ timestamp: new Date().toISOString(),
483
+ };
484
+ if (reason) {
485
+ result.reason = reason;
486
+ }
487
+ return result;
488
+ }
489
+ catch {
490
+ // Fall through to local roll
491
+ }
492
+ }
493
+ return this.fallbackDiceRoll(formula, reason);
494
+ }
495
+ fallbackDiceRoll(formula, reason) {
496
+ const diceRegex = /(\d+)d(\d+)([+-]\d+)?/g;
497
+ let total = 0;
498
+ const breakdown = [];
499
+ let match = diceRegex.exec(formula);
500
+ while (match !== null) {
501
+ const [, numDice, numSides, modifier] = match;
502
+ const diceCount = parseInt(numDice || '1', 10);
503
+ const sides = parseInt(numSides || '6', 10);
504
+ const mod = modifier ? parseInt(modifier, 10) : 0;
505
+ const rolls = [];
506
+ for (let i = 0; i < diceCount; i++) {
507
+ rolls.push(Math.floor(Math.random() * sides) + 1);
508
+ }
509
+ const rollSum = rolls.reduce((sum, roll) => sum + roll, 0) + mod;
510
+ total += rollSum;
511
+ breakdown.push(`${rolls.join(', ')}${mod !== 0 ? ` ${modifier}` : ''} = ${rollSum}`);
512
+ match = diceRegex.exec(formula);
513
+ }
514
+ const result = {
515
+ formula,
516
+ total,
517
+ breakdown: breakdown.join(' | '),
518
+ timestamp: new Date().toISOString(),
519
+ };
520
+ if (reason) {
521
+ result.reason = reason;
522
+ }
523
+ return result;
524
+ }
525
+ // ==========================================================================
526
+ // Connection test
527
+ // ==========================================================================
528
+ async testConnection() {
529
+ try {
530
+ if (this.config.username && this.config.password) {
531
+ await this.connect();
532
+ return true;
533
+ }
534
+ const response = await this.http.get('/');
535
+ logger.debug('Connection test successful', { status: response.status });
536
+ return true;
537
+ }
538
+ catch (error) {
539
+ logger.error('Failed to connect to FoundryVTT:', error);
540
+ throw error;
541
+ }
542
+ }
543
+ // ==========================================================================
544
+ // HTTP helpers (preserved for REST API mode and diagnostics)
545
+ // ==========================================================================
546
+ async executeWithRetry(operation) {
547
+ let lastError;
548
+ const maxAttempts = (this.config.retryAttempts || 3) + 1;
549
+ const baseDelay = this.config.retryDelay || 1000;
550
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
551
+ try {
552
+ return await operation();
553
+ }
554
+ catch (error) {
555
+ lastError = error;
556
+ if (axios.isAxiosError(error)) {
557
+ const status = error.response?.status;
558
+ if (status && status >= 400 && status < 500 && status !== 429) {
559
+ throw lastError;
560
+ }
561
+ }
562
+ if (attempt === maxAttempts) {
563
+ throw lastError;
564
+ }
565
+ const exponentialDelay = baseDelay * 2 ** (attempt - 1);
566
+ const jitter = Math.random() * 0.1 * exponentialDelay;
567
+ await new Promise((resolve) => setTimeout(resolve, exponentialDelay + jitter));
568
+ }
569
+ }
570
+ throw lastError || new Error('Request failed after all retry attempts');
571
+ }
572
+ async get(url, config) {
573
+ return this.executeWithRetry(() => this.http.get(url, config));
574
+ }
575
+ async post(url, data, config) {
576
+ return this.executeWithRetry(() => this.http.post(url, data, config));
577
+ }
578
+ async put(url, data, config) {
579
+ return this.executeWithRetry(() => this.http.put(url, data, config));
580
+ }
581
+ async delete(url, config) {
582
+ return this.executeWithRetry(() => this.http.delete(url, config));
583
+ }
584
+ }
585
+ // ============================================================================
586
+ // Mapping helpers — WorldData raw documents → display interfaces
587
+ // ============================================================================
588
+ function worldActorToFoundry(a) {
589
+ const sys = a.system || {};
590
+ const hpRaw = extractNested(sys, 'attributes', 'hp');
591
+ const hp = isRecord(hpRaw) ? hpRaw : undefined;
592
+ const acRaw = extractNested(sys, 'attributes', 'ac');
593
+ const ac = isRecord(acRaw) ? acRaw : undefined;
594
+ const details = isRecord(sys.details) ? sys.details : {};
595
+ const abilitiesRaw = sys.abilities;
596
+ let mappedAbilities;
597
+ if (isRecord(abilitiesRaw)) {
598
+ mappedAbilities = {};
599
+ for (const [key, val] of Object.entries(abilitiesRaw)) {
600
+ if (isRecord(val)) {
601
+ const entry = {
602
+ value: typeof val.value === 'number' ? val.value : 10,
603
+ mod: typeof val.mod === 'number' ? val.mod : 0,
604
+ };
605
+ if (typeof val.save === 'number') {
606
+ entry.save = val.save;
607
+ }
608
+ mappedAbilities[key] = entry;
609
+ }
610
+ }
611
+ }
612
+ const actor = {
613
+ _id: a._id,
614
+ name: a.name,
615
+ type: a.type,
616
+ };
617
+ if (a.img) {
618
+ actor.img = a.img;
619
+ }
620
+ if (hp) {
621
+ const hpValue = typeof hp.value === 'number' ? hp.value : 0;
622
+ const hpMax = typeof hp.max === 'number' ? hp.max : 0;
623
+ const hpObj = { value: hpValue, max: hpMax };
624
+ if (typeof hp.temp === 'number') {
625
+ hpObj.temp = hp.temp;
626
+ }
627
+ actor.hp = hpObj;
628
+ }
629
+ if (ac && typeof ac.value === 'number') {
630
+ actor.ac = { value: ac.value };
631
+ }
632
+ if (typeof details.level === 'number') {
633
+ actor.level = details.level;
634
+ }
635
+ if (mappedAbilities) {
636
+ actor.abilities = mappedAbilities;
637
+ }
638
+ const bio = extractString(details, 'biography', 'value') || extractString(details, 'biography');
639
+ if (bio) {
640
+ actor.biography = bio;
641
+ }
642
+ return actor;
643
+ }
644
+ function worldSceneToFoundry(s) {
645
+ const scene = {
646
+ _id: s._id,
647
+ name: s.name,
648
+ active: s.active,
649
+ navigation: s.navigation,
650
+ width: s.width,
651
+ height: s.height,
652
+ padding: s.padding,
653
+ shiftX: 0,
654
+ shiftY: 0,
655
+ globalLight: s.globalLight,
656
+ darkness: s.darkness,
657
+ };
658
+ if (s.img) {
659
+ scene.img = s.img;
660
+ }
661
+ const desc = s.flags?.description;
662
+ if (typeof desc === 'string') {
663
+ scene.description = desc;
664
+ }
665
+ return scene;
666
+ }
667
+ /**
668
+ * Safely extracts a nested value from a Record tree.
669
+ */
670
+ function isRecord(value) {
671
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
672
+ }
673
+ function extractNested(obj, ...keys) {
674
+ let current = obj;
675
+ for (const key of keys) {
676
+ if (isRecord(current) && key in current) {
677
+ current = current[key];
678
+ }
679
+ else {
680
+ return undefined;
681
+ }
682
+ }
683
+ return current;
684
+ }
685
+ /**
686
+ * Extracts a string from nested Record, following a chain of keys.
687
+ */
688
+ function extractString(obj, ...keys) {
689
+ const val = extractNested(obj, ...keys);
690
+ return typeof val === 'string' ? val : null;
691
+ }
692
+ //# sourceMappingURL=client.js.map