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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/character/manager.d.ts +92 -0
- package/dist/character/manager.d.ts.map +1 -0
- package/dist/character/manager.js +208 -0
- package/dist/character/manager.js.map +1 -0
- package/dist/combat/manager.d.ts +94 -0
- package/dist/combat/manager.d.ts.map +1 -0
- package/dist/combat/manager.js +373 -0
- package/dist/combat/manager.js.map +1 -0
- package/dist/config/index.d.ts +114 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +177 -0
- package/dist/config/index.js.map +1 -0
- package/dist/diagnostics/client.d.ts +193 -0
- package/dist/diagnostics/client.d.ts.map +1 -0
- package/dist/diagnostics/client.js +314 -0
- package/dist/diagnostics/client.js.map +1 -0
- package/dist/diagnostics/types.d.ts +414 -0
- package/dist/diagnostics/types.d.ts.map +1 -0
- package/dist/diagnostics/types.js +94 -0
- package/dist/diagnostics/types.js.map +1 -0
- package/dist/foundry/auth.d.ts +19 -0
- package/dist/foundry/auth.d.ts.map +1 -0
- package/dist/foundry/auth.js +135 -0
- package/dist/foundry/auth.js.map +1 -0
- package/dist/foundry/client.d.ts +95 -0
- package/dist/foundry/client.d.ts.map +1 -0
- package/dist/foundry/client.js +692 -0
- package/dist/foundry/client.js.map +1 -0
- package/dist/foundry/types.d.ts +805 -0
- package/dist/foundry/types.d.ts.map +1 -0
- package/dist/foundry/types.js +13 -0
- package/dist/foundry/types.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/index.d.ts +3 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +93 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/tools/base.d.ts +80 -0
- package/dist/tools/base.d.ts.map +1 -0
- package/dist/tools/base.js +73 -0
- package/dist/tools/base.js.map +1 -0
- package/dist/tools/definitions.d.ts +414 -0
- package/dist/tools/definitions.d.ts.map +1 -0
- package/dist/tools/definitions.js +429 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/handlers/actors.d.ts +31 -0
- package/dist/tools/handlers/actors.d.ts.map +1 -0
- package/dist/tools/handlers/actors.js +76 -0
- package/dist/tools/handlers/actors.js.map +1 -0
- package/dist/tools/handlers/chat.d.ts +13 -0
- package/dist/tools/handlers/chat.d.ts.map +1 -0
- package/dist/tools/handlers/chat.js +38 -0
- package/dist/tools/handlers/chat.js.map +1 -0
- package/dist/tools/handlers/combat.d.ts +11 -0
- package/dist/tools/handlers/combat.d.ts.map +1 -0
- package/dist/tools/handlers/combat.js +48 -0
- package/dist/tools/handlers/combat.js.map +1 -0
- package/dist/tools/handlers/diagnostics.d.ts +64 -0
- package/dist/tools/handlers/diagnostics.d.ts.map +1 -0
- package/dist/tools/handlers/diagnostics.js +182 -0
- package/dist/tools/handlers/diagnostics.js.map +1 -0
- package/dist/tools/handlers/dice.d.ts +39 -0
- package/dist/tools/handlers/dice.d.ts.map +1 -0
- package/dist/tools/handlers/dice.js +53 -0
- package/dist/tools/handlers/dice.js.map +1 -0
- package/dist/tools/handlers/generation.d.ts +44 -0
- package/dist/tools/handlers/generation.d.ts.map +1 -0
- package/dist/tools/handlers/generation.js +205 -0
- package/dist/tools/handlers/generation.js.map +1 -0
- package/dist/tools/handlers/items.d.ts +21 -0
- package/dist/tools/handlers/items.d.ts.map +1 -0
- package/dist/tools/handlers/items.js +50 -0
- package/dist/tools/handlers/items.js.map +1 -0
- package/dist/tools/handlers/journals.d.ts +22 -0
- package/dist/tools/handlers/journals.d.ts.map +1 -0
- package/dist/tools/handlers/journals.js +62 -0
- package/dist/tools/handlers/journals.js.map +1 -0
- package/dist/tools/handlers/resources.d.ts +13 -0
- package/dist/tools/handlers/resources.d.ts.map +1 -0
- package/dist/tools/handlers/resources.js +188 -0
- package/dist/tools/handlers/resources.js.map +1 -0
- package/dist/tools/handlers/scenes.d.ts +18 -0
- package/dist/tools/handlers/scenes.d.ts.map +1 -0
- package/dist/tools/handlers/scenes.js +34 -0
- package/dist/tools/handlers/scenes.js.map +1 -0
- package/dist/tools/handlers/users.d.ts +11 -0
- package/dist/tools/handlers/users.d.ts.map +1 -0
- package/dist/tools/handlers/users.js +34 -0
- package/dist/tools/handlers/users.js.map +1 -0
- package/dist/tools/handlers/utils.d.ts +11 -0
- package/dist/tools/handlers/utils.d.ts.map +1 -0
- package/dist/tools/handlers/utils.js +24 -0
- package/dist/tools/handlers/utils.js.map +1 -0
- package/dist/tools/handlers/world.d.ts +26 -0
- package/dist/tools/handlers/world.d.ts.map +1 -0
- package/dist/tools/handlers/world.js +92 -0
- package/dist/tools/handlers/world.js.map +1 -0
- package/dist/tools/index.d.ts +19 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +22 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/new-router.d.ts +29 -0
- package/dist/tools/new-router.d.ts.map +1 -0
- package/dist/tools/new-router.js +97 -0
- package/dist/tools/new-router.js.map +1 -0
- package/dist/tools/registry.d.ts +51 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +79 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/resources.d.ts +16 -0
- package/dist/tools/resources.d.ts.map +1 -0
- package/dist/tools/resources.js +63 -0
- package/dist/tools/resources.js.map +1 -0
- package/dist/tools/router.d.ts +22 -0
- package/dist/tools/router.d.ts.map +1 -0
- package/dist/tools/router.js +128 -0
- package/dist/tools/router.js.map +1 -0
- package/dist/utils/cache-instance.d.ts +25 -0
- package/dist/utils/cache-instance.d.ts.map +1 -0
- package/dist/utils/cache-instance.js +30 -0
- package/dist/utils/cache-instance.js.map +1 -0
- package/dist/utils/cache.d.ts +80 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +205 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/diagnostics.d.ts +114 -0
- package/dist/utils/diagnostics.d.ts.map +1 -0
- package/dist/utils/diagnostics.js +330 -0
- package/dist/utils/diagnostics.js.map +1 -0
- package/dist/utils/logger.d.ts +137 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +158 -0
- package/dist/utils/logger.js.map +1 -0
- 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
|