/**
* FoundryVTT client for API communication via Socket.IO
*
* Connects to FoundryVTT using the proven 4-step authentication flow,
* caches worldData in memory, and serves all queries from the snapshot.
*/
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';
import { io, type Socket } from 'socket.io-client';
import { logger } from '../utils/logger.js';
import { authenticateFoundry } from './auth.js';
import type {
ActorSearchResult,
DiceRoll,
FoundryActor,
FoundryScene,
FoundryWorld,
ItemSearchResult,
WorldActor,
WorldCombat,
WorldData,
WorldItem,
WorldJournal,
WorldMessage,
WorldScene,
WorldUser,
} from './types.js';
export interface FoundryClientConfig {
baseUrl: string;
apiKey?: string;
username?: string;
password?: string;
userId?: string;
timeout?: number;
retryAttempts?: number;
retryDelay?: number;
socketPath?: string;
}
export interface SearchActorsParams {
query?: string;
type?: string;
limit?: number;
}
export interface SearchItemsParams {
query?: string;
type?: string;
rarity?: string;
limit?: number;
}
export class FoundryClient {
private http: AxiosInstance;
private socket: Socket | null = null;
private config: FoundryClientConfig;
private _isConnected = false;
private worldData: WorldData | null = null;
constructor(config: FoundryClientConfig) {
if (!config.baseUrl || config.baseUrl.trim() === '') {
throw new Error('baseUrl is required and cannot be empty');
}
try {
new URL(config.baseUrl);
} catch {
throw new Error(`Invalid baseUrl: ${config.baseUrl}`);
}
this.config = {
timeout: 10000,
retryAttempts: 3,
retryDelay: 1000,
socketPath: '/socket.io/',
...config,
};
this.http = axios.create({
baseURL: this.config.baseUrl,
timeout: this.config.timeout || 30000,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'FoundryMCP/0.2.0',
},
maxRedirects: 3,
maxContentLength: 50 * 1024 * 1024,
maxBodyLength: 50 * 1024 * 1024,
validateStatus: (status) => status >= 200 && status < 300,
});
if (this.config.apiKey) {
this.http.interceptors.request.use((reqConfig) => {
reqConfig.headers['x-api-key'] = this.config.apiKey;
return reqConfig;
});
}
const mode = this.config.apiKey ? 'REST API' : 'Socket.IO';
logger.info(`FoundryVTT client initialized (${mode} mode)`);
}
/**
* Connects to FoundryVTT.
* REST API mode: tests /api/status endpoint.
* Socket.IO mode: authenticates and loads full worldData.
*/
async connect(): Promise<void> {
if (this.config.apiKey) {
try {
await this.http.get('/api/status');
this._isConnected = true;
logger.info('Connected to FoundryVTT via REST API module');
} catch (error) {
logger.error('Failed to connect via REST API module:', error);
throw error;
}
return;
}
const user = this.config.userId || this.config.username;
if (!user || !this.config.password) {
throw new Error(
'Socket.IO mode requires username/userId and password. ' +
'Set FOUNDRY_USERNAME + FOUNDRY_PASSWORD or FOUNDRY_USER_ID + FOUNDRY_PASSWORD.',
);
}
const { session } = await authenticateFoundry(this.config.baseUrl, user, this.config.password);
// Connect authenticated socket and load world data
this.worldData = await this.connectAndLoadWorld(session);
this._isConnected = true;
logger.info('Connected to FoundryVTT via Socket.IO', {
actors: this.worldData.actors.length,
scenes: this.worldData.scenes.length,
items: this.worldData.items.length,
});
}
/**
* Connects Socket.IO with an authenticated session and loads worldData.
*/
private connectAndLoadWorld(session: string): Promise<WorldData> {
return new Promise((resolve, reject) => {
this.socket = io(this.config.baseUrl, {
transports: ['websocket'],
query: { session },
});
const cleanup = () => {
this.socket?.off('session', onSession);
this.socket?.off('connect_error', onConnectError);
};
const timeout = setTimeout(() => {
cleanup();
this.socket?.disconnect();
reject(new Error('Timeout waiting for world data (15s)'));
}, 15000);
const onSession = (data: { userId?: string } | null) => {
if (!data?.userId) {
clearTimeout(timeout);
cleanup();
this.socket?.disconnect();
return reject(new Error('Authentication failed — session event returned no userId'));
}
this.socket?.emit('world', (worldData: WorldData) => {
clearTimeout(timeout);
cleanup();
resolve(worldData);
});
};
const onConnectError = (err: Error) => {
clearTimeout(timeout);
cleanup();
reject(new Error(`Socket.IO connection failed: ${err.message}`));
};
this.socket.on('session', onSession);
this.socket.on('connect_error', onConnectError);
});
}
async disconnect(): Promise<void> {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
this.worldData = null;
this._isConnected = false;
logger.info('FoundryVTT client disconnected');
}
isConnected(): boolean {
return this._isConnected;
}
/**
* Returns true if worldData is available (Socket.IO mode connected).
*/
hasWorldData(): boolean {
return this.worldData !== null;
}
// ==========================================================================
// World data accessors
// ==========================================================================
/**
* Re-emits 'world' on the existing socket to refresh the cached snapshot.
*/
async refreshWorldData(): Promise<void> {
if (!this.socket?.connected) {
throw new Error('Not connected — cannot refresh world data');
}
this.worldData = await new Promise<WorldData>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Refresh timeout')), 15000);
this.socket?.emit('world', (data: WorldData) => {
clearTimeout(timeout);
resolve(data);
});
});
logger.info('World data refreshed', {
actors: this.worldData.actors.length,
items: this.worldData.items.length,
});
}
getWorldData(): WorldData | null {
return this.worldData;
}
// ==========================================================================
// Actor methods
// ==========================================================================
async searchActors(params: SearchActorsParams): Promise<ActorSearchResult> {
if (this.config.apiKey) {
return this.executeWithRetry(async () => {
const response = await this.http.get('/api/actors', { params });
return response.data;
});
}
if (!this.worldData) {
return { actors: [], total: 0, page: 1, limit: params.limit || 10 };
}
let results = this.worldData.actors;
if (params.query) {
const q = params.query.toLowerCase();
results = results.filter((a) => a.name.toLowerCase().includes(q));
}
if (params.type) {
const t = params.type.toLowerCase();
results = results.filter((a) => a.type.toLowerCase() === t);
}
const total = results.length;
const limit = params.limit || 10;
const actors: FoundryActor[] = results.slice(0, limit).map(worldActorToFoundry);
return { actors, total, page: 1, limit };
}
async getActor(actorId: string): Promise<FoundryActor> {
if (this.config.apiKey) {
return this.executeWithRetry(async () => {
const response = await this.http.get(`/api/actors/${actorId}`);
return response.data;
});
}
if (!this.worldData) {
throw new Error('Not connected — no world data available');
}
const actor = this.worldData.actors.find((a) => a._id === actorId);
if (!actor) {
throw new Error(`Actor not found: ${actorId}`);
}
return worldActorToFoundry(actor);
}
/**
* Returns the raw WorldActor with the full system data (game-system specific).
*/
getRawActor(actorId: string): WorldActor | undefined {
return this.worldData?.actors.find((a) => a._id === actorId);
}
// ==========================================================================
// Item methods
// ==========================================================================
async searchItems(params: SearchItemsParams): Promise<ItemSearchResult> {
if (this.config.apiKey) {
return this.executeWithRetry(async () => {
const response = await this.http.get('/api/items', { params });
return response.data;
});
}
if (!this.worldData) {
return { items: [], total: 0, page: 1, limit: params.limit || 10 };
}
let results = this.worldData.items;
if (params.query) {
const q = params.query.toLowerCase();
results = results.filter((i) => i.name.toLowerCase().includes(q));
}
if (params.type) {
const t = params.type.toLowerCase();
results = results.filter((i) => i.type.toLowerCase() === t);
}
const total = results.length;
const limit = params.limit || 10;
const items = results.slice(0, limit).map((i) => {
const item: {
_id: string;
name: string;
type: string;
img?: string;
description?: string;
rarity?: string;
} = {
_id: i._id,
name: i.name,
type: i.type,
};
if (i.img) {
item.img = i.img;
}
const desc = extractString(i.system, 'description', 'value');
if (desc) {
item.description = desc;
}
const rar = extractString(i.system, 'rarity');
if (rar) {
item.rarity = rar;
}
return item;
});
return { items, total, page: 1, limit };
}
// ==========================================================================
// Scene methods
// ==========================================================================
async getCurrentScene(sceneId?: string): Promise<FoundryScene> {
if (this.config.apiKey) {
return this.executeWithRetry(async () => {
const endpoint = sceneId ? `/api/scenes/${sceneId}` : '/api/scenes/current';
const response = await this.http.get(endpoint);
return response.data;
});
}
if (!this.worldData) {
throw new Error('Not connected — no world data available');
}
let scene: WorldScene | undefined;
if (sceneId) {
scene = this.worldData.scenes.find((s) => s._id === sceneId);
} else {
scene = this.worldData.scenes.find((s) => s.active);
}
if (!scene) {
throw new Error(sceneId ? `Scene not found: ${sceneId}` : 'No active scene');
}
return worldSceneToFoundry(scene);
}
async getScene(sceneId: string): Promise<FoundryScene> {
return this.getCurrentScene(sceneId);
}
getScenes(): WorldScene[] {
return this.worldData?.scenes || [];
}
// ==========================================================================
// World info
// ==========================================================================
async getWorldInfo(): Promise<FoundryWorld> {
if (this.config.apiKey) {
return this.executeWithRetry(async () => {
const response = await this.http.get('/api/world');
return response.data;
});
}
if (!this.worldData) {
return {
id: 'unknown',
title: 'Not connected',
description: 'Connect to FoundryVTT to retrieve world information',
system: 'unknown',
coreVersion: 'unknown',
systemVersion: 'unknown',
playtime: 0,
created: new Date().toISOString(),
modified: new Date().toISOString(),
};
}
const w = this.worldData.world as Record<string, unknown>;
const s = this.worldData.system as Record<string, unknown>;
const r = this.worldData.release as Record<string, unknown>;
return {
id: (w.id as string) || 'unknown',
title: (w.title as string) || 'Unknown World',
description: (w.description as string) || '',
system: (s.id as string) || 'unknown',
coreVersion: (r.version as string) || (r.generation as string) || 'unknown',
systemVersion: (s.version as string) || 'unknown',
playtime: 0,
created: new Date().toISOString(),
modified: new Date().toISOString(),
};
}
// ==========================================================================
// Combat
// ==========================================================================
getCombatState(): WorldCombat | null {
if (!this.worldData) {
return null;
}
return this.worldData.combats.find((c) => c.active) ?? null;
}
// ==========================================================================
// Chat messages
// ==========================================================================
getChatMessages(limit = 20): WorldMessage[] {
if (!this.worldData) {
return [];
}
return this.worldData.messages.slice(-limit);
}
// ==========================================================================
// Users
// ==========================================================================
getUsers(): { users: WorldUser[]; activeUsers: string[] } {
if (!this.worldData) {
return { users: [], activeUsers: [] };
}
return {
users: this.worldData.users,
activeUsers: this.worldData.activeUsers,
};
}
// ==========================================================================
// Journals
// ==========================================================================
getJournals(): WorldJournal[] {
return this.worldData?.journal || [];
}
searchJournals(query: string): WorldJournal[] {
if (!this.worldData) {
return [];
}
const q = query.toLowerCase();
return this.worldData.journal.filter((j) => {
if (j.name.toLowerCase().includes(q)) {
return true;
}
return j.pages?.some(
(p) => p.name.toLowerCase().includes(q) || p.text?.content?.toLowerCase().includes(q),
);
});
}
getJournal(journalId: string): WorldJournal | undefined {
return this.worldData?.journal.find((j) => j._id === journalId);
}
// ==========================================================================
// Cross-collection search
// ==========================================================================
searchWorld(query: string): {
actors: WorldActor[];
items: WorldItem[];
scenes: WorldScene[];
journals: WorldJournal[];
} {
if (!this.worldData) {
return { actors: [], items: [], scenes: [], journals: [] };
}
const q = query.toLowerCase();
return {
actors: this.worldData.actors.filter((a) => a.name.toLowerCase().includes(q)),
items: this.worldData.items.filter((i) => i.name.toLowerCase().includes(q)),
scenes: this.worldData.scenes.filter((s) => s.name.toLowerCase().includes(q)),
journals: this.worldData.journal.filter((j) => j.name.toLowerCase().includes(q)),
};
}
// ==========================================================================
// World summary
// ==========================================================================
getWorldSummary(): Record<string, number> {
if (!this.worldData) {
return {};
}
return {
actors: this.worldData.actors.length,
items: this.worldData.items.length,
scenes: this.worldData.scenes.length,
journals: this.worldData.journal.length,
combats: this.worldData.combats.length,
users: this.worldData.users.length,
messages: this.worldData.messages.length,
macros: this.worldData.macros.length,
playlists: this.worldData.playlists.length,
tables: this.worldData.tables.length,
folders: this.worldData.folders.length,
};
}
// ==========================================================================
// Dice rolling
// ==========================================================================
async rollDice(formula: string, reason?: string): Promise<DiceRoll> {
const DICE_FORMULA_REGEX = /^[0-9d\s+\-()]+$/;
if (!formula || formula.length > 100 || !DICE_FORMULA_REGEX.test(formula)) {
throw new Error(`Invalid dice formula: ${formula}`);
}
if (this.config.apiKey) {
try {
const response = await this.http.post('/api/dice/roll', {
formula,
flavor: reason,
});
const result: DiceRoll = {
formula,
total: response.data.total,
breakdown:
response.data.terms
?.map((term: { results?: number[] }) => term.results?.join(', '))
.join(' + ') || formula,
timestamp: new Date().toISOString(),
};
if (reason) {
result.reason = reason;
}
return result;
} catch {
// Fall through to local roll
}
}
return this.fallbackDiceRoll(formula, reason);
}
private fallbackDiceRoll(formula: string, reason?: string): DiceRoll {
const diceRegex = /(\d+)d(\d+)([+-]\d+)?/g;
let total = 0;
const breakdown: string[] = [];
let match: RegExpExecArray | null = diceRegex.exec(formula);
while (match !== null) {
const [, numDice, numSides, modifier] = match;
const diceCount = parseInt(numDice || '1', 10);
const sides = parseInt(numSides || '6', 10);
const mod = modifier ? parseInt(modifier, 10) : 0;
const rolls: number[] = [];
for (let i = 0; i < diceCount; i++) {
rolls.push(Math.floor(Math.random() * sides) + 1);
}
const rollSum = rolls.reduce((sum, roll) => sum + roll, 0) + mod;
total += rollSum;
breakdown.push(`${rolls.join(', ')}${mod !== 0 ? ` ${modifier}` : ''} = ${rollSum}`);
match = diceRegex.exec(formula);
}
const result: DiceRoll = {
formula,
total,
breakdown: breakdown.join(' | '),
timestamp: new Date().toISOString(),
};
if (reason) {
result.reason = reason;
}
return result;
}
// ==========================================================================
// Connection test
// ==========================================================================
async testConnection(): Promise<boolean> {
try {
if (this.config.username && this.config.password) {
await this.connect();
return true;
}
const response = await this.http.get('/');
logger.debug('Connection test successful', { status: response.status });
return true;
} catch (error) {
logger.error('Failed to connect to FoundryVTT:', error);
throw error;
}
}
// ==========================================================================
// HTTP helpers (preserved for REST API mode and diagnostics)
// ==========================================================================
private async executeWithRetry<T>(operation: () => Promise<T>): Promise<T> {
let lastError: Error | undefined;
const maxAttempts = (this.config.retryAttempts || 3) + 1;
const baseDelay = this.config.retryDelay || 1000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status && status >= 400 && status < 500 && status !== 429) {
throw lastError;
}
}
if (attempt === maxAttempts) {
throw lastError;
}
const exponentialDelay = baseDelay * 2 ** (attempt - 1);
const jitter = Math.random() * 0.1 * exponentialDelay;
await new Promise((resolve) => setTimeout(resolve, exponentialDelay + jitter));
}
}
throw lastError || new Error('Request failed after all retry attempts');
}
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.executeWithRetry(() => this.http.get(url, config));
}
async post<T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.executeWithRetry(() => this.http.post(url, data, config));
}
async put<T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.executeWithRetry(() => this.http.put(url, data, config));
}
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.executeWithRetry(() => this.http.delete(url, config));
}
}
// ============================================================================
// Mapping helpers — WorldData raw documents → display interfaces
// ============================================================================
function worldActorToFoundry(a: WorldActor): FoundryActor {
const sys = a.system || {};
const hpRaw = extractNested(sys, 'attributes', 'hp');
const hp = isRecord(hpRaw) ? hpRaw : undefined;
const acRaw = extractNested(sys, 'attributes', 'ac');
const ac = isRecord(acRaw) ? acRaw : undefined;
const details = isRecord(sys.details) ? sys.details : {};
const abilitiesRaw = sys.abilities;
let mappedAbilities: FoundryActor['abilities'];
if (isRecord(abilitiesRaw)) {
mappedAbilities = {};
for (const [key, val] of Object.entries(abilitiesRaw)) {
if (isRecord(val)) {
const entry: { value: number; mod: number; save?: number } = {
value: typeof val.value === 'number' ? val.value : 10,
mod: typeof val.mod === 'number' ? val.mod : 0,
};
if (typeof val.save === 'number') {
entry.save = val.save;
}
mappedAbilities[key] = entry;
}
}
}
const actor: FoundryActor = {
_id: a._id,
name: a.name,
type: a.type,
};
if (a.img) {
actor.img = a.img;
}
if (hp) {
const hpValue = typeof hp.value === 'number' ? hp.value : 0;
const hpMax = typeof hp.max === 'number' ? hp.max : 0;
const hpObj: { value: number; max: number; temp?: number } = { value: hpValue, max: hpMax };
if (typeof hp.temp === 'number') {
hpObj.temp = hp.temp;
}
actor.hp = hpObj;
}
if (ac && typeof ac.value === 'number') {
actor.ac = { value: ac.value };
}
if (typeof details.level === 'number') {
actor.level = details.level;
}
if (mappedAbilities) {
actor.abilities = mappedAbilities;
}
const bio = extractString(details, 'biography', 'value') || extractString(details, 'biography');
if (bio) {
actor.biography = bio;
}
return actor;
}
function worldSceneToFoundry(s: WorldScene): FoundryScene {
const scene: FoundryScene = {
_id: s._id,
name: s.name,
active: s.active,
navigation: s.navigation,
width: s.width,
height: s.height,
padding: s.padding,
shiftX: 0,
shiftY: 0,
globalLight: s.globalLight,
darkness: s.darkness,
};
if (s.img) {
scene.img = s.img;
}
const desc = (s.flags as Record<string, unknown>)?.description;
if (typeof desc === 'string') {
scene.description = desc;
}
return scene;
}
/**
* Safely extracts a nested value from a Record tree.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function extractNested(obj: Record<string, unknown>, ...keys: string[]): unknown {
let current: unknown = obj;
for (const key of keys) {
if (isRecord(current) && key in current) {
current = current[key];
} else {
return undefined;
}
}
return current;
}
/**
* Extracts a string from nested Record, following a chain of keys.
*/
function extractString(obj: Record<string, unknown>, ...keys: string[]): string | null {
const val = extractNested(obj, ...keys);
return typeof val === 'string' ? val : null;
}