Skip to main content
Glama
data-access.ts153 kB
import { MODULE_ID, ERROR_MESSAGES, TOKEN_DISPOSITIONS } from './constants.js'; import { permissionManager } from './permissions.js'; import { transactionManager } from './transaction-manager.js'; // Local type definitions to avoid shared package import issues interface CharacterInfo { id: string; name: string; type: string; img?: string; system: Record<string, unknown>; items: CharacterItem[]; effects: CharacterEffect[]; actions?: any[]; // PF2e actions (strikes, spells, etc.) itemVariants?: any[]; // Item rule element variants (ChoiceSet, etc.) itemToggles?: any[]; // Item rule element toggles (RollOption, ToggleProperty, equipped) } interface CharacterItem { id: string; name: string; type: string; img?: string; system: Record<string, unknown>; } interface CharacterEffect { id: string; name: string; icon?: string; disabled: boolean; duration?: { type: string; duration?: number; remaining?: number; }; } interface CompendiumSearchResult { id: string; name: string; type: string; img?: string; pack: string; packLabel: string; system?: Record<string, unknown>; summary?: string; hasImage?: boolean; description?: string; } // D&D 5e Enhanced Creature Index interface DnD5eCreatureIndex { id: string; name: string; type: string; pack: string; packLabel: string; challengeRating: number; creatureType: string; size: string; hitPoints: number; armorClass: number; hasSpells: boolean; hasLegendaryActions: boolean; alignment: string; description?: string; img?: string; } // Pathfinder 2e Enhanced Creature Index interface PF2eCreatureIndex { id: string; name: string; type: string; pack: string; packLabel: string; level: number; // PF2e: -1 to 25+ traits: string[]; // PF2e: ['dragon', 'fire', 'amphibious'] creatureType: string; // Primary trait extracted from traits array rarity: string; // PF2e: 'common', 'uncommon', 'rare', 'unique' size: string; hitPoints: number; armorClass: number; hasSpells: boolean; alignment: string; description?: string; img?: string; } // Union type for both systems type EnhancedCreatureIndex = DnD5eCreatureIndex | PF2eCreatureIndex; interface PersistentIndexMetadata { version: string; timestamp: number; packFingerprints: Map<string, PackFingerprint>; totalCreatures: number; gameSystem: string; // 'dnd5e' or 'pf2e' } interface PackFingerprint { packId: string; packLabel: string; lastModified: number; documentCount: number; checksum: string; } interface PersistentEnhancedIndex { metadata: PersistentIndexMetadata; creatures: EnhancedCreatureIndex[]; } interface SceneInfo { id: string; name: string; img?: string; background?: string; width: number; height: number; padding: number; active: boolean; navigation: boolean; tokens: SceneToken[]; walls: number; lights: number; sounds: number; notes: SceneNote[]; } interface SceneToken { id: string; name: string; x: number; y: number; width: number; height: number; actorId?: string; img: string; hidden: boolean; disposition: number; } interface SceneNote { id: string; text: string; x: number; y: number; } interface WorldInfo { id: string; title: string; system: string; systemVersion: string; foundryVersion: string; users: WorldUser[]; } interface WorldUser { id: string; name: string; active: boolean; isGM: boolean; } // Phase 2: Write Operation Interfaces interface ActorCreationRequest { creatureType: string; customNames?: string[] | undefined; packPreference?: string | undefined; quantity?: number | undefined; addToScene?: boolean | undefined; } interface ActorCreationResult { success: boolean; actors: CreatedActorInfo[]; errors?: string[] | undefined; tokensPlaced?: number; totalRequested: number; totalCreated: number; } interface CreatedActorInfo { id: string; name: string; originalName: string; type: string; sourcePackId: string; sourcePackLabel: string; img?: string; } interface CompendiumEntryFull { id: string; name: string; type: string; img?: string; pack: string; packLabel: string; system: Record<string, unknown>; items?: CompendiumItem[]; effects?: CompendiumEffect[]; fullData: Record<string, unknown>; } interface CompendiumItem { id: string; name: string; type: string; img?: string; system: Record<string, unknown>; } interface CompendiumEffect { id: string; name: string; icon?: string; disabled: boolean; duration?: Record<string, unknown>; } interface SceneTokenPlacement { actorIds: string[]; placement: 'random' | 'grid' | 'center' | 'coordinates'; hidden: boolean; coordinates?: { x: number; y: number }[]; } interface TokenPlacementResult { success: boolean; tokensCreated: number; tokenIds: string[]; errors?: string[] | undefined; } /** * Persistent Enhanced Creature Index System * Stores pre-computed creature data in JSON file within Foundry world directory for instant filtering * Uses file-based storage following Foundry best practices for large data sets */ class PersistentCreatureIndex { private moduleId: string = MODULE_ID; private readonly INDEX_VERSION = '1.0.0'; private readonly INDEX_FILENAME = 'enhanced-creature-index.json'; private buildInProgress = false; private hooksRegistered = false; constructor() { this.registerFoundryHooks(); } /** * Get the file path for the enhanced creature index */ private getIndexFilePath(): string { // Store in world data directory using world ID return `worlds/${game.world.id}/${this.INDEX_FILENAME}`; } /** * Get or build the enhanced creature index */ async getEnhancedIndex(): Promise<EnhancedCreatureIndex[]> { // Check if we have a valid persistent index const existingIndex = await this.loadPersistedIndex(); if (existingIndex && this.isIndexValid(existingIndex)) { return existingIndex.creatures; } // Build new index if needed return await this.buildEnhancedIndex(); } /** * Force rebuild of the enhanced index */ async rebuildIndex(): Promise<EnhancedCreatureIndex[]> { return await this.buildEnhancedIndex(true); } /** * Load persisted index from JSON file */ private async loadPersistedIndex(): Promise<PersistentEnhancedIndex | null> { try { const filePath = this.getIndexFilePath(); // Check if file exists using Foundry's FilePicker let fileExists = false; try { const browseResult = await (foundry as any).applications.apps.FilePicker.implementation.browse('data', `worlds/${game.world.id}`); fileExists = browseResult.files.some((f: any) => f.endsWith(this.INDEX_FILENAME)); } catch (error) { // Directory doesn't exist or other error, return null return null; } if (!fileExists) { return null; } // Load file content const response = await fetch(filePath); if (!response.ok) { console.warn(`[${this.moduleId}] Failed to load index file: ${response.status}`); return null; } const rawData = await response.json(); // Convert Map data back from JSON const metadata = rawData.metadata; if (metadata && metadata.packFingerprints) { metadata.packFingerprints = new Map(metadata.packFingerprints); } return rawData; } catch (error) { console.warn(`[${this.moduleId}] Failed to load persisted index from file:`, error); return null; } } /** * Save enhanced index to JSON file */ private async savePersistedIndex(index: PersistentEnhancedIndex): Promise<void> { try { // Convert Map to Array for JSON serialization const saveData = { ...index, metadata: { ...index.metadata, packFingerprints: Array.from(index.metadata.packFingerprints.entries()) } }; const jsonContent = JSON.stringify(saveData, null, 2); // Create a File object and upload it using Foundry's file system const file = new File([jsonContent], this.INDEX_FILENAME, { type: 'application/json' }); // Upload the file to the world directory const uploadResponse = await (foundry as any).applications.apps.FilePicker.implementation.upload('data', `worlds/${game.world.id}`, file); if (uploadResponse) { } else { throw new Error('File upload failed'); } } catch (error) { console.error(`[${this.moduleId}] Failed to save enhanced index to file:`, error); throw error; } } /** * Check if existing index is valid (all packs unchanged) */ private isIndexValid(existingIndex: PersistentEnhancedIndex): boolean { // Check version if (existingIndex.metadata.version !== this.INDEX_VERSION) { return false; } // NEW: Check system compatibility const currentSystem = (game as any).system.id; if (existingIndex.metadata.gameSystem !== currentSystem) { console.log(`[${this.moduleId}] System changed from ${existingIndex.metadata.gameSystem} to ${currentSystem}, index invalidated`); return false; } // Check each pack fingerprint const actorPacks = Array.from(game.packs.values()).filter(pack => pack.metadata.type === 'Actor'); for (const pack of actorPacks) { const currentFingerprint = this.generatePackFingerprint(pack); const savedFingerprint = existingIndex.metadata.packFingerprints.get(pack.metadata.id); if (!savedFingerprint) { return false; } if (!this.fingerprintsMatch(currentFingerprint, savedFingerprint)) { return false; } } // Check if any saved packs no longer exist for (const [packId] of existingIndex.metadata.packFingerprints) { if (!game.packs.get(packId)) { return false; } } return true; } /** * Register Foundry hooks for real-time pack change detection */ private registerFoundryHooks(): void { if (this.hooksRegistered) return; // Listen for compendium document changes Hooks.on('createDocument', (document: any) => { if (document.pack && (document.type === 'npc' || document.type === 'character')) { this.invalidateIndex(); } }); Hooks.on('updateDocument', (document: any) => { if (document.pack && (document.type === 'npc' || document.type === 'character')) { this.invalidateIndex(); } }); Hooks.on('deleteDocument', (document: any) => { if (document.pack && (document.type === 'npc' || document.type === 'character')) { this.invalidateIndex(); } }); // Listen for pack creation/deletion Hooks.on('createCompendium', (pack: any) => { if (pack.metadata.type === 'Actor') { this.invalidateIndex(); } }); Hooks.on('deleteCompendium', (pack: any) => { if (pack.metadata.type === 'Actor') { this.invalidateIndex(); } }); this.hooksRegistered = true; } /** * Invalidate the current index (mark for rebuild on next access) */ private async invalidateIndex(): Promise<void> { try { // Check if auto-rebuild is enabled const autoRebuild = game.settings.get(this.moduleId, 'autoRebuildIndex'); if (!autoRebuild) { return; } // Delete the index file to force rebuild const filePath = this.getIndexFilePath(); try { // Check if file exists first by trying to browse to the world directory const browseResult = await (foundry as any).applications.apps.FilePicker.implementation.browse('data', `worlds/${game.world.id}`); const fileExists = browseResult.files.some((f: any) => f.endsWith(this.INDEX_FILENAME)); if (fileExists) { // File exists, delete it using fetch with DELETE method await fetch(filePath, { method: 'DELETE' }); // File deletion completed (or failed silently) } } catch (error) { // File doesn't exist or deletion failed - that's okay } } catch (error) { console.warn(`[${this.moduleId}] Failed to invalidate index:`, error); } } /** * Generate fingerprint for pack change detection with improved accuracy */ private generatePackFingerprint(pack: any): PackFingerprint { // Get actual modification time if available let lastModified = Date.now(); if (pack.metadata.lastModified) { lastModified = new Date(pack.metadata.lastModified).getTime(); } return { packId: pack.metadata.id, packLabel: pack.metadata.label, lastModified: lastModified, documentCount: pack.index?.size || 0, checksum: this.generatePackChecksum(pack) }; } /** * Generate checksum for pack contents */ private generatePackChecksum(pack: any): string { // Simple checksum based on pack metadata and size const data = `${pack.metadata.id}-${pack.metadata.label}-${pack.index?.size || 0}`; return btoa(data).slice(0, 16); // Simple hash for demonstration } /** * Compare two pack fingerprints */ private fingerprintsMatch(current: PackFingerprint, saved: PackFingerprint): boolean { return current.documentCount === saved.documentCount && current.checksum === saved.checksum; } /** * Build enhanced creature index from all Actor packs with detailed progress tracking */ private async buildEnhancedIndex(force = false): Promise<EnhancedCreatureIndex[]> { if (this.buildInProgress && !force) { throw new Error('Index build already in progress'); } // Detect game system ONCE at build time const gameSystem = (game as any).system.id; console.log(`[${this.moduleId}] Building enhanced creature index for system: ${gameSystem}`); // Route to system-specific builder if (gameSystem === 'pf2e') { return await this.buildPF2eIndex(force); } else if (gameSystem === 'dnd5e') { return await this.buildDnD5eIndex(force); } else { throw new Error(`Enhanced creature index not supported for system: ${gameSystem}. Only D&D 5e and Pathfinder 2e are currently supported.`); } } /** * Build D&D 5e enhanced creature index */ private async buildDnD5eIndex(_force = false): Promise<DnD5eCreatureIndex[]> { this.buildInProgress = true; const startTime = Date.now(); let progressNotification: any = null; let totalErrors = 0; // Track extraction errors try { const actorPacks = Array.from(game.packs.values()).filter(pack => pack.metadata.type === 'Actor'); const enhancedCreatures: DnD5eCreatureIndex[] = []; const packFingerprints = new Map<string, PackFingerprint>(); // Show initial progress notification ui.notifications?.info(`Starting enhanced creature index build from ${actorPacks.length} packs...`); for (let i = 0; i < actorPacks.length; i++) { const pack = actorPacks[i]; const progressPercent = Math.round((i / actorPacks.length) * 100); // Update progress notification every few packs or for important packs if (i % 3 === 0 || pack.metadata.label.toLowerCase().includes('monster')) { if (progressNotification) { progressNotification.remove(); } progressNotification = ui.notifications?.info( `Building creature index... ${progressPercent}% (${i + 1}/${actorPacks.length}) Processing: ${pack.metadata.label}` ); } try { // Ensure pack index is loaded if (!pack.indexed) { await pack.getIndex({}); } // Generate pack fingerprint for change detection packFingerprints.set(pack.metadata.id, this.generatePackFingerprint(pack)); // Show pack processing details for large packs const packSize = pack.index?.size || 0; if (packSize > 50) { if (progressNotification) { progressNotification.remove(); } progressNotification = ui.notifications?.info( `Processing large pack: ${pack.metadata.label} (${packSize} documents)...` ); } // Process creatures in this pack const packResult = await this.extractDnD5eDataFromPack(pack); enhancedCreatures.push(...packResult.creatures); totalErrors += packResult.errors; // Pack processing completed: ${pack.metadata.label} - ${packResult.creatures.length} creatures extracted // Show milestone notifications for significant progress if (i === 0 || (i + 1) % 5 === 0 || i === actorPacks.length - 1) { const totalCreaturesSoFar = enhancedCreatures.length; if (progressNotification) { progressNotification.remove(); } progressNotification = ui.notifications?.info( `Index Progress: ${i + 1}/${actorPacks.length} packs complete, ${totalCreaturesSoFar} creatures indexed` ); } } catch (error) { console.warn(`[${this.moduleId}] Failed to process pack ${pack.metadata.label}:`, error); // Show error notification for pack failures ui.notifications?.warn(`Warning: Failed to index pack "${pack.metadata.label}" - continuing with other packs`); } } // Clear progress notification and show final processing step if (progressNotification) { progressNotification.remove(); } ui.notifications?.info(`Saving enhanced index to world database... (${enhancedCreatures.length} creatures)`); // Create persistent index structure const persistentIndex: PersistentEnhancedIndex = { metadata: { version: this.INDEX_VERSION, timestamp: Date.now(), packFingerprints, totalCreatures: enhancedCreatures.length, gameSystem: 'dnd5e' // Mark as D&D 5e index }, creatures: enhancedCreatures }; // Save to world flags await this.savePersistedIndex(persistentIndex); const buildTimeSeconds = Math.round((Date.now() - startTime) / 1000); const errorText = totalErrors > 0 ? ` (${totalErrors} extraction errors)` : ''; const successMessage = `Enhanced creature index complete! ${enhancedCreatures.length} creatures indexed from ${actorPacks.length} packs in ${buildTimeSeconds}s${errorText}`; ui.notifications?.info(successMessage); return enhancedCreatures; } catch (error) { // Clear any progress notifications on error if (progressNotification) { progressNotification.remove(); } const errorMessage = `Failed to build enhanced creature index: ${error instanceof Error ? error.message : 'Unknown error'}`; console.error(`[${this.moduleId}] ${errorMessage}`); ui.notifications?.error(errorMessage); throw error; } finally { this.buildInProgress = false; // Ensure progress notification is cleared if (progressNotification) { progressNotification.remove(); } } } /** * Extract D&D 5e data from all documents in a pack */ private async extractDnD5eDataFromPack(pack: any): Promise<{ creatures: DnD5eCreatureIndex[], errors: number }> { const creatures: DnD5eCreatureIndex[] = []; let errors = 0; try { // Load all documents from pack const documents = await pack.getDocuments(); for (const doc of documents) { try { // Only process NPCs and characters if (doc.type !== 'npc' && doc.type !== 'character') { continue; } const result = this.extractDnD5eCreatureData(doc, pack); if (result) { creatures.push(result.creature); errors += result.errors; } } catch (error) { console.warn(`[${this.moduleId}] Failed to extract data from ${doc.name} in ${pack.metadata.label}:`, error); errors++; } } } catch (error) { console.warn(`[${this.moduleId}] Failed to load documents from ${pack.metadata.label}:`, error); errors++; } return { creatures, errors }; } /** * Extract D&D 5e creature data from a single document */ private extractDnD5eCreatureData(doc: any, pack: any): { creature: DnD5eCreatureIndex, errors: number } | null { try { const system = doc.system || {}; // Extract challenge rating with comprehensive fallbacks // Based on debug logs: system.details.cr contains the actual value let challengeRating = system.details?.cr ?? system.details?.cr?.value ?? system.cr?.value ?? system.cr ?? system.attributes?.cr?.value ?? system.attributes?.cr ?? system.challenge?.rating ?? system.challenge?.cr ?? 0; // Handle null values (spell effects, etc.) if (challengeRating === null || challengeRating === undefined) { challengeRating = 0; } if (typeof challengeRating === 'string') { if (challengeRating === '1/8') challengeRating = 0.125; else if (challengeRating === '1/4') challengeRating = 0.25; else if (challengeRating === '1/2') challengeRating = 0.5; else challengeRating = parseFloat(challengeRating) || 0; } // Ensure it's a number challengeRating = Number(challengeRating) || 0; // Extract creature type with proper type checking // Based on debug logs: system.details.type.value contains the actual value let creatureType = system.details?.type?.value ?? system.details?.type ?? system.type?.value ?? system.type ?? system.race?.value ?? system.race ?? system.details?.race ?? 'unknown'; // Handle null/undefined values properly if (creatureType === null || creatureType === undefined || creatureType === '') { creatureType = 'unknown'; } // Ensure creatureType is a string before calling toLowerCase() if (typeof creatureType !== 'string') { creatureType = String(creatureType || 'unknown'); } // Extract size with proper type checking let size = system.traits?.size?.value || system.traits?.size || system.size?.value || system.size || system.details?.size || 'medium'; // Ensure size is a string if (typeof size !== 'string') { size = String(size || 'medium'); } // Extract hit points with more fallbacks const hitPoints = system.attributes?.hp?.max || system.hp?.max || system.attributes?.hp?.value || system.hp?.value || system.health?.max || system.health?.value || 0; // Extract armor class with more fallbacks const armorClass = system.attributes?.ac?.value || system.ac?.value || system.attributes?.ac || system.ac || system.armor?.value || system.armor || 10; // Extract alignment with proper type checking let alignment = system.details?.alignment?.value || system.details?.alignment || system.alignment?.value || system.alignment || 'unaligned'; // Ensure alignment is a string if (typeof alignment !== 'string') { alignment = String(alignment || 'unaligned'); } // Check for spells with more comprehensive detection const hasSpells = !!(system.spells || system.attributes?.spellcasting || (system.details?.spellLevel && system.details.spellLevel > 0) || (system.resources?.spell && system.resources.spell.max > 0) || system.spellcasting || (system.traits?.spellcasting) || (system.details?.spellcaster)); // Check for legendary actions with more comprehensive detection const hasLegendaryActions = !!(system.resources?.legact || system.legendary || (system.resources?.legres && system.resources.legres.value > 0) || system.details?.legendary || system.traits?.legendary || (system.resources?.legendary && system.resources.legendary.max > 0)); // DEBUG: Log what we extracted for comparison // Successful extraction return { creature: { id: doc._id, name: doc.name, type: doc.type, pack: pack.metadata.id, packLabel: pack.metadata.label, challengeRating: challengeRating, creatureType: creatureType.toLowerCase(), size: size.toLowerCase(), hitPoints: hitPoints, armorClass: armorClass, hasSpells: hasSpells, hasLegendaryActions: hasLegendaryActions, alignment: alignment.toLowerCase(), description: doc.system?.details?.biography || doc.system?.description || '', img: doc.img }, errors: 0 }; } catch (error) { console.warn(`[${this.moduleId}] Failed to extract enhanced data from ${doc.name}:`, error); // Return a basic fallback record with error count instead of null to avoid losing creatures return { creature: { id: doc._id, name: doc.name, type: doc.type, pack: pack.metadata.id, packLabel: pack.metadata.label, challengeRating: 0, creatureType: 'unknown', size: 'medium', hitPoints: 1, armorClass: 10, hasSpells: false, hasLegendaryActions: false, alignment: 'unaligned', description: 'Data extraction failed', img: doc.img || '' }, errors: 1 }; } } /** * Build Pathfinder 2e enhanced creature index */ private async buildPF2eIndex(_force = false): Promise<PF2eCreatureIndex[]> { this.buildInProgress = true; const startTime = Date.now(); let progressNotification: any = null; let totalErrors = 0; try { const actorPacks = Array.from(game.packs.values()).filter(pack => pack.metadata.type === 'Actor'); const enhancedCreatures: PF2eCreatureIndex[] = []; const packFingerprints = new Map<string, PackFingerprint>(); ui.notifications?.info(`Starting PF2e creature index build from ${actorPacks.length} packs...`); let currentPack = 0; for (const pack of actorPacks) { currentPack++; if (progressNotification) { progressNotification.remove(); } progressNotification = ui.notifications?.info( `Building PF2e index: Pack ${currentPack}/${actorPacks.length} (${pack.metadata.label})...` ); const fingerprint = await this.generatePackFingerprint(pack); packFingerprints.set(pack.metadata.id, fingerprint); const result = await this.extractPF2eDataFromPack(pack); enhancedCreatures.push(...result.creatures); totalErrors += result.errors; } if (progressNotification) { progressNotification.remove(); } ui.notifications?.info(`Saving PF2e index to world database... (${enhancedCreatures.length} creatures)`); const persistentIndex: PersistentEnhancedIndex = { metadata: { version: this.INDEX_VERSION, timestamp: Date.now(), packFingerprints, totalCreatures: enhancedCreatures.length, gameSystem: 'pf2e' // Mark as PF2e index }, creatures: enhancedCreatures }; await this.savePersistedIndex(persistentIndex); const buildTimeSeconds = Math.round((Date.now() - startTime) / 1000); const errorText = totalErrors > 0 ? ` (${totalErrors} extraction errors)` : ''; const successMessage = `PF2e creature index complete! ${enhancedCreatures.length} creatures indexed from ${actorPacks.length} packs in ${buildTimeSeconds}s${errorText}`; ui.notifications?.info(successMessage); return enhancedCreatures; } catch (error) { if (progressNotification) { progressNotification.remove(); } const errorMessage = `Failed to build PF2e creature index: ${error instanceof Error ? error.message : 'Unknown error'}`; console.error(`[${this.moduleId}] ${errorMessage}`); ui.notifications?.error(errorMessage); throw error; } finally { this.buildInProgress = false; if (progressNotification) { progressNotification.remove(); } } } /** * Extract PF2e creature data from all documents in a pack */ private async extractPF2eDataFromPack(pack: any): Promise<{ creatures: PF2eCreatureIndex[], errors: number }> { const creatures: PF2eCreatureIndex[] = []; let errors = 0; try { const documents = await pack.getDocuments(); for (const doc of documents) { try { if (doc.type !== 'npc' && doc.type !== 'character') { continue; } const result = this.extractPF2eCreatureData(doc, pack); if (result) { creatures.push(result.creature); errors += result.errors; } } catch (error) { console.warn(`[${this.moduleId}] Failed to extract PF2e data from ${doc.name} in ${pack.metadata.label}:`, error); errors++; } } } catch (error) { console.warn(`[${this.moduleId}] Failed to load documents from ${pack.metadata.label}:`, error); errors++; } return { creatures, errors }; } /** * Extract Pathfinder 2e creature data from a single document */ private extractPF2eCreatureData(doc: any, pack: any): { creature: PF2eCreatureIndex, errors: number } | null { try { const system = doc.system || {}; // Level extraction (PF2e primary power metric) let level = system.details?.level?.value ?? 0; level = Number(level) || 0; // Traits extraction (PF2e uses array of traits) const traitsValue = system.traits?.value || []; const traits = Array.isArray(traitsValue) ? traitsValue : []; // Extract primary creature type from traits const creatureTraits = ['aberration', 'animal', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'fungus', 'humanoid', 'monitor', 'ooze', 'plant', 'undead']; const creatureType = traits.find((t: string) => creatureTraits.includes(t.toLowerCase()) )?.toLowerCase() || 'unknown'; // Rarity extraction (PF2e specific) const rarity = system.traits?.rarity || 'common'; // Size extraction let size = system.traits?.size?.value || 'med'; // Normalize PF2e size values (tiny, sm, med, lg, huge, grg) const sizeMap: Record<string, string> = { 'tiny': 'tiny', 'sm': 'small', 'med': 'medium', 'lg': 'large', 'huge': 'huge', 'grg': 'gargantuan' }; size = sizeMap[size.toLowerCase()] || 'medium'; // Hit Points const hitPoints = system.attributes?.hp?.max || 0; // Armor Class const armorClass = system.attributes?.ac?.value || 10; // Spellcasting detection (PF2e uses spellcasting entries) const spellcasting = system.spellcasting || {}; const hasSpells = Object.keys(spellcasting).length > 0; // Alignment let alignment = system.details?.alignment?.value || 'N'; if (typeof alignment !== 'string') { alignment = String(alignment || 'N'); } return { creature: { id: doc._id, name: doc.name, type: doc.type, pack: pack.metadata.id, packLabel: pack.metadata.label, level: level, traits: traits, creatureType: creatureType, rarity: rarity, size: size, hitPoints: hitPoints, armorClass: armorClass, hasSpells: hasSpells, alignment: alignment.toUpperCase(), description: system.details?.publicNotes || system.details?.biography || '', img: doc.img }, errors: 0 }; } catch (error) { console.warn(`[${this.moduleId}] Failed to extract PF2e data from ${doc.name}:`, error); // Fallback with error count return { creature: { id: doc._id, name: doc.name, type: doc.type, pack: pack.metadata.id, packLabel: pack.metadata.label, level: 0, traits: [], creatureType: 'unknown', rarity: 'common', size: 'medium', hitPoints: 1, armorClass: 10, hasSpells: false, alignment: 'N', description: 'Data extraction failed', img: doc.img || '' }, errors: 1 }; } } } export class FoundryDataAccess { private moduleId: string = MODULE_ID; private persistentIndex: PersistentCreatureIndex = new PersistentCreatureIndex(); constructor() {} /** * Force rebuild of enhanced creature index */ async rebuildEnhancedCreatureIndex(): Promise<{ success: boolean; totalCreatures: number; message: string }> { try { const creatures = await this.persistentIndex.rebuildIndex(); return { success: true, totalCreatures: creatures.length, message: `Enhanced creature index rebuilt: ${creatures.length} creatures indexed from all packs` }; } catch (error) { console.error(`[${this.moduleId}] Failed to rebuild enhanced creature index:`, error); return { success: false, totalCreatures: 0, message: `Failed to rebuild index: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Get character/actor information by name or ID */ async getCharacterInfo(identifier: string): Promise<CharacterInfo> { let actor: Actor | undefined; // Try to find by ID first, then by name if (identifier.length === 16) { // Foundry ID length actor = game.actors.get(identifier); } if (!actor) { actor = game.actors.find(a => a.name?.toLowerCase() === identifier.toLowerCase() ); } if (!actor) { throw new Error(`${ERROR_MESSAGES.CHARACTER_NOT_FOUND}: ${identifier}`); } // Build character data structure const characterData: CharacterInfo = { id: actor.id || '', name: actor.name || '', type: actor.type, ...(actor.img ? { img: actor.img } : {}), system: this.sanitizeData((actor as any).system), items: actor.items.map(item => { return { id: item.id, name: item.name, type: item.type, ...(item.img ? { img: item.img } : {}), system: this.sanitizeData(item.system), }; }), effects: actor.effects.map(effect => ({ id: effect.id, name: (effect as any).name || (effect as any).label || 'Unknown Effect', ...((effect as any).icon ? { icon: (effect as any).icon } : {}), disabled: (effect as any).disabled, ...(((effect as any).duration) ? { duration: { type: (effect as any).duration.type || 'none', duration: (effect as any).duration.duration, remaining: (effect as any).duration.remaining, } } : {}), })), }; // Add PF2e-specific data if available const actorAny = actor as any; // Include actions (PF2e strikes, spells, etc.) if (actorAny.system?.actions) { characterData.actions = actorAny.system.actions.map((action: any) => ({ name: action.label || action.name, type: action.type, ...(action.item ? { itemId: action.item.id } : {}), ...(action.variants ? { variants: action.variants.map((v: any) => ({ label: v.label, ...(v.traits ? { traits: v.traits } : {}) })) } : {}), ...(action.ready !== undefined ? { ready: action.ready } : {}), })); } // Include item variants and toggles const itemVariants: any[] = []; const itemToggles: any[] = []; actor.items.forEach(item => { const itemAny = item as any; // Extract rule element variants (e.g., weapon variants, stance toggles) if (itemAny.system?.rules) { itemAny.system.rules.forEach((rule: any, ruleIndex: number) => { // Variants (ChoiceSet, RollOption with choices) if (rule.key === 'ChoiceSet' || (rule.key === 'RollOption' && rule.choices)) { itemVariants.push({ itemId: item.id, itemName: item.name, ruleIndex: ruleIndex, ruleKey: rule.key, label: rule.label || rule.prompt, ...(rule.selection ? { selected: rule.selection } : {}), ...(rule.choices ? { choices: rule.choices } : {}), }); } // Toggles (RollOption toggleable, ToggleProperty) if ((rule.key === 'RollOption' && rule.toggleable) || rule.key === 'ToggleProperty') { itemToggles.push({ itemId: item.id, itemName: item.name, ruleIndex: ruleIndex, ruleKey: rule.key, label: rule.label, option: rule.option, ...(rule.value !== undefined ? { enabled: rule.value } : {}), ...(rule.toggleable !== undefined ? { toggleable: rule.toggleable } : {}), }); } }); } // Also check for item-level toggles (e.g., equipped, identified) if (itemAny.system?.equipped !== undefined) { itemToggles.push({ itemId: item.id, itemName: item.name, type: 'equipped', enabled: itemAny.system.equipped, }); } }); // Add to character data if any found if (itemVariants.length > 0) { characterData.itemVariants = itemVariants; } if (itemToggles.length > 0) { characterData.itemToggles = itemToggles; } return characterData; } /** * Search compendium packs for items matching query with optional filters */ async searchCompendium(query: string, packType?: string, filters?: { challengeRating?: number | { min?: number; max?: number }; creatureType?: string; size?: string; alignment?: string; hasLegendaryActions?: boolean; spellcaster?: boolean; }): Promise<CompendiumSearchResult[]> { // Add defensive checks for query parameter if (!query || typeof query !== 'string' || query.trim().length < 2) { throw new Error('Search query must be a string with at least 2 characters'); } // ENHANCED SEARCH: If we have creature-specific filters and Actor packType, use enhanced index if (filters && packType === 'Actor' && (filters.challengeRating || filters.creatureType || filters.hasLegendaryActions)) { // Check if enhanced creature index is enabled const enhancedIndexEnabled = game.settings.get(this.moduleId, 'enableEnhancedCreatureIndex'); if (enhancedIndexEnabled) { try { // Convert search criteria and use enhanced search const criteria: any = { limit: 100 }; // Default limit for search if (filters.challengeRating) criteria.challengeRating = filters.challengeRating; if (filters.creatureType) criteria.creatureType = filters.creatureType; if (filters.size) criteria.size = filters.size; if (filters.hasLegendaryActions) criteria.hasLegendaryActions = filters.hasLegendaryActions; const enhancedResult = await this.listCreaturesByCriteria(criteria); // No name filtering needed - trust the enhanced creature index! const filteredResults = enhancedResult.creatures; // Convert to CompendiumSearchResult format return filteredResults.map(creature => ({ id: creature.id || creature.name, name: creature.name, type: creature.type || 'npc', pack: creature.pack, packLabel: creature.packLabel || creature.pack, description: creature.description || '', hasImage: creature.hasImage || !!creature.img, summary: `CR ${creature.challengeRating} ${creature.creatureType} from ${creature.packLabel}`, // Enhanced data (not part of interface but will be included) challengeRating: creature.challengeRating, creatureType: creature.creatureType, size: creature.size, hasLegendaryActions: creature.hasLegendaryActions } as CompendiumSearchResult & { challengeRating: number; creatureType: string; size: string; hasLegendaryActions: boolean; })); } catch (error) { console.warn(`[${this.moduleId}] Enhanced search failed, falling back to basic search:`, error); // Continue to basic search below } } } const results: CompendiumSearchResult[] = []; const cleanQuery = query.toLowerCase().trim(); const searchTerms = cleanQuery.split(' ').filter(term => term && typeof term === 'string' && term.length > 0); if (searchTerms.length === 0) { throw new Error('Search query must contain valid search terms'); } // Filter packs by type if specified const packs = Array.from(game.packs.values()).filter(pack => { if (packType && pack.metadata.type !== packType) { return false; } return pack.metadata.type !== 'Scene'; // Exclude scene packs for safety }); for (const pack of packs) { try { // Ensure pack index is loaded if (!pack.indexed) { await pack.getIndex({}); } // Use basic compendium index for all searches const entriesToSearch = Array.from(pack.index.values()); for (const entry of entriesToSearch) { try { // Type assertion and comprehensive safety checks for entry properties const typedEntry = entry as any; if (!typedEntry || !typedEntry.name || typeof typedEntry.name !== 'string' || typedEntry.name.trim().length === 0) { continue; } // Ensure searchTerms are valid before using them if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) { continue; } // Use already created typedEntry const entryNameLower = typedEntry.name.toLowerCase(); const nameMatch = searchTerms.every(term => { if (!term || typeof term !== 'string') { return false; } return entryNameLower.includes(term); }); if (nameMatch) { // For Actor packs with filters, use simple name/description matching if (filters && this.shouldApplyFilters(entry, filters) && pack.metadata.type === 'Actor') { // Convert filters to search criteria for compatibility const searchCriteria: any = {}; if (filters.challengeRating) { const searchTerms = []; if (typeof filters.challengeRating === 'number') { if (filters.challengeRating >= 15) { searchTerms.push('ancient', 'legendary', 'elder', 'greater'); } else if (filters.challengeRating >= 10) { searchTerms.push('adult', 'warlord', 'champion', 'master'); } else if (filters.challengeRating >= 5) { searchTerms.push('captain', 'knight', 'priest', 'mage'); } else { searchTerms.push('guard', 'soldier', 'warrior', 'scout'); } } searchCriteria.searchTerms = searchTerms; } if (filters.creatureType) { const typeTerms = [filters.creatureType]; if (filters.creatureType.toLowerCase() === 'humanoid') { typeTerms.push('human', 'elf', 'dwarf', 'orc', 'goblin'); } searchCriteria.searchTerms = [...(searchCriteria.searchTerms || []), ...typeTerms]; } if (!this.matchesSearchCriteria(typedEntry, searchCriteria)) { continue; } } // Standard index entry result results.push({ id: typedEntry._id || '', name: typedEntry.name, type: typedEntry.type || 'unknown', img: typedEntry.img || undefined, pack: pack.metadata.id, packLabel: pack.metadata.label, description: typedEntry.description || '', hasImage: !!typedEntry.img, summary: `${typedEntry.type} from ${pack.metadata.label}`, }); } } catch (entryError) { // Log individual entry errors but continue processing console.warn(`[${this.moduleId}] Error processing entry in pack ${pack.metadata.id}:`, entryError); continue; } // Limit results per pack to prevent overwhelming responses if (results.length >= 100) break; } } catch (error) { console.warn(`[${this.moduleId}] Failed to search pack ${pack.metadata.id}:`, error); } // Global limit to prevent memory issues if (results.length >= 100) break; } // Sort results by relevance with enhanced ranking for filtered searches results.sort((a, b) => { // Exact name matches first const aExact = a.name.toLowerCase() === query.toLowerCase(); const bExact = b.name.toLowerCase() === query.toLowerCase(); if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; // If filters are used, prioritize by filter match quality if (filters) { const aScore = this.calculateRelevanceScore(a, filters, query); const bScore = this.calculateRelevanceScore(b, filters, query); if (aScore !== bScore) return bScore - aScore; // Higher score first } // Fallback to alphabetical return a.name.localeCompare(b.name); }); return results.slice(0, 50); // Final limit } /** * Check if filters should be applied to this entry */ private shouldApplyFilters(entry: any, filters: any): boolean { // Only apply filters to Actor entries (which includes NPCs/monsters) if (entry.type !== 'npc' && entry.type !== 'character') { return false; } // Check if any filters are actually specified return Object.keys(filters).some(key => filters[key] !== undefined); } /** * Check if entry passes all specified filters * @unused - Replaced with simple index-only approach */ // @ts-ignore - Unused method kept for compatibility private passesFilters(entry: any, filters: { challengeRating?: number | { min?: number; max?: number }; creatureType?: string; size?: string; alignment?: string; hasLegendaryActions?: boolean; spellcaster?: boolean; }): boolean { const system = entry.system || {}; // Challenge Rating filter if (filters.challengeRating !== undefined) { // Try multiple possible CR locations in D&D 5e data structure let entryCR = system.details?.cr?.value || system.details?.cr || system.cr?.value || system.cr || 0; // Handle fractional CRs (common in D&D 5e) if (typeof entryCR === 'string') { if (entryCR === '1/8') entryCR = 0.125; else if (entryCR === '1/4') entryCR = 0.25; else if (entryCR === '1/2') entryCR = 0.5; else entryCR = parseFloat(entryCR) || 0; } if (typeof filters.challengeRating === 'number') { // Exact CR match if (entryCR !== filters.challengeRating) { return false; } } else if (typeof filters.challengeRating === 'object') { // CR range const { min, max } = filters.challengeRating; if (min !== undefined && entryCR < min) { return false; } if (max !== undefined && entryCR > max) { return false; } } } // Creature Type filter if (filters.creatureType) { const entryType = system.details?.type?.value || system.type?.value || ''; if (entryType.toLowerCase() !== filters.creatureType.toLowerCase()) { return false; } } // Size filter if (filters.size) { const entrySize = system.traits?.size || system.size || ''; if (entrySize.toLowerCase() !== filters.size.toLowerCase()) { return false; } } // Alignment filter if (filters.alignment) { const entryAlignment = system.details?.alignment || system.alignment || ''; if (!entryAlignment.toLowerCase().includes(filters.alignment.toLowerCase())) { return false; } } // Legendary Actions filter if (filters.hasLegendaryActions !== undefined) { const hasLegendary = !!(system.resources?.legact || system.legendary || (system.resources?.legres && system.resources.legres.value > 0)); if (hasLegendary !== filters.hasLegendaryActions) { return false; } } // Spellcaster filter if (filters.spellcaster !== undefined) { const isSpellcaster = !!(system.spells || system.attributes?.spellcasting || (system.details?.spellLevel && system.details.spellLevel > 0)); if (isSpellcaster !== filters.spellcaster) { return false; } } return true; } /** * Calculate relevance score for search result ranking */ private calculateRelevanceScore(entry: any, filters: any, query: string): number { let score = 0; const system = entry.system || {}; // Bonus for creature type match (high importance for encounter building) if (filters.creatureType) { const entryType = system.details?.type?.value || system.type?.value || ''; if (entryType.toLowerCase() === filters.creatureType.toLowerCase()) { score += 20; } } // Bonus for CR match (exact match gets higher score than range) if (filters.challengeRating !== undefined) { const entryCR = system.details?.cr || system.cr || 0; if (typeof filters.challengeRating === 'number') { if (entryCR === filters.challengeRating) score += 15; } else if (typeof filters.challengeRating === 'object') { const { min, max } = filters.challengeRating; if (min !== undefined && max !== undefined) { // Bonus for being in range, extra for being in middle of range if (entryCR >= min && entryCR <= max) { score += 10; const rangeMid = (min + max) / 2; const distFromMid = Math.abs(entryCR - rangeMid); score += Math.max(0, 5 - distFromMid); // Up to 5 bonus for being near middle } } } } // Bonus for common creature names (better for encounters) const commonNames = ['knight', 'warrior', 'guard', 'soldier', 'mage', 'priest', 'bandit', 'orc', 'goblin', 'dragon']; const lowerName = entry.name.toLowerCase(); if (commonNames.some(name => lowerName.includes(name))) { score += 5; } // Bonus for query term matches in name const queryTerms = query.toLowerCase().split(' '); for (const term of queryTerms) { if (term.length > 2 && lowerName.includes(term)) { score += 3; } } return score; } /** * List creatures by criteria using enhanced persistent index - optimized for instant filtering */ async listCreaturesByCriteria(criteria: { challengeRating?: number | { min?: number; max?: number }; creatureType?: string; size?: string; hasSpells?: boolean; hasLegendaryActions?: boolean; limit?: number; }): Promise<{creatures: any[], searchSummary: any}> { const limit = criteria.limit || 500; // Check if enhanced creature index is enabled const enhancedIndexEnabled = game.settings.get(this.moduleId, 'enableEnhancedCreatureIndex'); if (!enhancedIndexEnabled) { return this.fallbackBasicCreatureSearch(criteria, limit); } try { // Get enhanced creature index (builds if needed) const enhancedCreatures = await this.persistentIndex.getEnhancedIndex(); // Apply filters to enhanced data let filteredCreatures = enhancedCreatures.filter(creature => this.passesEnhancedCriteria(creature, criteria)); // Sort by Level/CR then name for consistent ordering (system-aware) filteredCreatures.sort((a, b) => { // Get power level (CR for D&D 5e, Level for PF2e) const powerA = 'level' in a ? (a as PF2eCreatureIndex).level : (a as DnD5eCreatureIndex).challengeRating; const powerB = 'level' in b ? (b as PF2eCreatureIndex).level : (b as DnD5eCreatureIndex).challengeRating; if (powerA !== powerB) { return powerA - powerB; // Lower power first } return a.name.localeCompare(b.name); }); // Apply limit if (filteredCreatures.length > limit) { filteredCreatures = filteredCreatures.slice(0, limit); } // Convert enhanced creatures to result format (system-aware) const results = filteredCreatures.map(creature => { // Type guard for result formatting const isPF2e = 'level' in creature; return { id: creature.id, name: creature.name, type: creature.type, pack: creature.pack, packLabel: creature.packLabel, description: creature.description || '', hasImage: !!creature.img, // System-aware summary summary: isPF2e ? `Level ${(creature as PF2eCreatureIndex).level} ${creature.creatureType} (${(creature as PF2eCreatureIndex).rarity}) from ${creature.packLabel}` : `CR ${(creature as DnD5eCreatureIndex).challengeRating} ${creature.creatureType} from ${creature.packLabel}`, // Include all creature data (conditional based on system) ...(isPF2e ? { level: (creature as PF2eCreatureIndex).level, traits: (creature as PF2eCreatureIndex).traits, rarity: (creature as PF2eCreatureIndex).rarity } : { challengeRating: (creature as DnD5eCreatureIndex).challengeRating, hasLegendaryActions: (creature as DnD5eCreatureIndex).hasLegendaryActions }), creatureType: creature.creatureType, size: creature.size, hitPoints: creature.hitPoints, armorClass: creature.armorClass, hasSpells: creature.hasSpells, alignment: creature.alignment }; }); // Calculate pack distribution for summary const packResults = new Map(); results.forEach(creature => { const count = packResults.get(creature.packLabel) || 0; packResults.set(creature.packLabel, count + 1); }); // Get unique pack information const uniquePacks = Array.from(new Set(enhancedCreatures.map(c => c.pack))); const topPacks = uniquePacks.slice(0, 5).map(packId => { const sampleCreature = enhancedCreatures.find(c => c.pack === packId); return { id: packId, label: sampleCreature?.packLabel || 'Unknown Pack', priority: 100 // All packs are prioritized equally in enhanced index }; }); if (packResults.size > 0) { } return { creatures: results, searchSummary: { packsSearched: uniquePacks.length, topPacks, totalCreaturesFound: results.length, resultsByPack: Object.fromEntries(packResults), criteria: criteria, indexMetadata: { totalIndexedCreatures: enhancedCreatures.length, searchMethod: 'enhanced_persistent_index' } } }; } catch (error) { console.error(`[${this.moduleId}] Enhanced creature search failed:`, error); // Fallback to basic search if enhanced index fails return this.fallbackBasicCreatureSearch(criteria, limit); } } /** * Check if enhanced creature passes all specified criteria (system-aware routing) */ private passesEnhancedCriteria(creature: EnhancedCreatureIndex, criteria: any): boolean { // Type guard for PF2e creatures - check for level property if ('level' in creature) { return this.passesPF2eCriteria(creature as PF2eCreatureIndex, criteria); } else { return this.passesDnD5eCriteria(creature as DnD5eCreatureIndex, criteria); } } /** * Check if D&D 5e creature passes all specified criteria */ private passesDnD5eCriteria(creature: DnD5eCreatureIndex, criteria: { challengeRating?: number | { min?: number; max?: number }; creatureType?: string; size?: string; hasSpells?: boolean; hasLegendaryActions?: boolean; }): boolean { // Challenge Rating filter if (criteria.challengeRating !== undefined) { if (typeof criteria.challengeRating === 'number') { if (creature.challengeRating !== criteria.challengeRating) { return false; } } else if (typeof criteria.challengeRating === 'object') { const { min, max } = criteria.challengeRating; if (min !== undefined && creature.challengeRating < min) { return false; } if (max !== undefined && creature.challengeRating > max) { return false; } } } // Creature Type filter if (criteria.creatureType) { if (creature.creatureType.toLowerCase() !== criteria.creatureType.toLowerCase()) { return false; } } // Size filter if (criteria.size) { if (creature.size.toLowerCase() !== criteria.size.toLowerCase()) { return false; } } // Spellcaster filter if (criteria.hasSpells !== undefined) { if (creature.hasSpells !== criteria.hasSpells) { return false; } } // Legendary Actions filter if (criteria.hasLegendaryActions !== undefined) { if (creature.hasLegendaryActions !== criteria.hasLegendaryActions) { return false; } } return true; } /** * Check if PF2e creature passes all specified criteria */ private passesPF2eCriteria(creature: PF2eCreatureIndex, criteria: { level?: number | { min?: number; max?: number }; traits?: string[]; rarity?: string; creatureType?: string; size?: string; hasSpells?: boolean; }): boolean { // Level filter if (criteria.level !== undefined) { if (typeof criteria.level === 'number') { if (creature.level !== criteria.level) { return false; } } else if (typeof criteria.level === 'object') { const { min = -1, max = 25 } = criteria.level; if (creature.level < min || creature.level > max) { return false; } } } // Traits filter (creature must have ALL specified traits) if (criteria.traits && criteria.traits.length > 0) { const hasAllTraits = criteria.traits.every(requiredTrait => creature.traits.some(t => t.toLowerCase() === requiredTrait.toLowerCase()) ); if (!hasAllTraits) { return false; } } // Rarity filter if (criteria.rarity && creature.rarity !== criteria.rarity) { return false; } // Creature type filter if (criteria.creatureType && creature.creatureType.toLowerCase() !== criteria.creatureType.toLowerCase()) { return false; } // Size filter if (criteria.size && creature.size.toLowerCase() !== criteria.size.toLowerCase()) { return false; } // Spellcasting filter if (criteria.hasSpells !== undefined && creature.hasSpells !== criteria.hasSpells) { return false; } return true; } /** * Fallback to basic creature search if enhanced index fails */ private async fallbackBasicCreatureSearch(criteria: any, limit: number): Promise<{creatures: any[], searchSummary: any}> { console.warn(`[${this.moduleId}] Falling back to basic search due to enhanced index failure`); // Use a simple text-based search as fallback const searchTerms: string[] = []; if (criteria.creatureType) { searchTerms.push(criteria.creatureType); } if (criteria.challengeRating) { if (typeof criteria.challengeRating === 'number') { // Add CR-based name patterns as fallback if (criteria.challengeRating >= 15) searchTerms.push('ancient', 'legendary'); else if (criteria.challengeRating >= 10) searchTerms.push('adult', 'champion'); else if (criteria.challengeRating >= 5) searchTerms.push('captain', 'knight'); } } const searchQuery = searchTerms.join(' ') || 'monster'; const basicResults = await this.searchCompendium(searchQuery, 'Actor'); return { creatures: basicResults.slice(0, limit), searchSummary: { packsSearched: 0, topPacks: [], totalCreaturesFound: basicResults.length, resultsByPack: {}, criteria: criteria, fallback: true, searchMethod: 'basic_fallback' } }; } /** * Prioritize compendium packs by likelihood of containing relevant creatures * @unused - Replaced by enhanced persistent index system */ // @ts-ignore - Unused method kept for compatibility private prioritizePacksForCreatures(packs: any[]): any[] { const priorityOrder = [ // Tier 1: Core D&D 5e content (highest priority) { pattern: /^dnd5e\.monsters/, priority: 100 }, // Core D&D 5e monsters { pattern: /^dnd5e\.actors/, priority: 95 }, // Core D&D 5e actors { pattern: /ddb.*monsters/i, priority: 90 }, // D&D Beyond monsters // Tier 2: Official modules and supplements { pattern: /^world\..*ddb.*monsters/i, priority: 85 }, // World-specific DDB monsters { pattern: /monsters/i, priority: 80 }, // Any pack with "monsters" // Tier 3: Campaign and adventure content { pattern: /^world\.(?!.*summon|.*hero)/i, priority: 70 }, // World packs (not summons/heroes) // Tier 4: Specialized content { pattern: /summon|familiar/i, priority: 40 }, // Summons and familiars // Tier 5: Unlikely to contain monsters (lowest priority) { pattern: /hero|player|pc/i, priority: 10 }, // Player characters ]; return packs.sort((a, b) => { const aScore = this.getPackPriority(a.metadata.id, a.metadata.label, priorityOrder); const bScore = this.getPackPriority(b.metadata.id, b.metadata.label, priorityOrder); if (aScore !== bScore) { return bScore - aScore; // Higher score first } // Secondary sort by pack label alphabetically return a.metadata.label.localeCompare(b.metadata.label); }); } /** * Get priority score for a pack based on ID and label */ private getPackPriority(packId: string, packLabel: string, priorityOrder: { pattern: RegExp; priority: number }[]): number { for (const rule of priorityOrder) { if (rule.pattern.test(packId) || rule.pattern.test(packLabel)) { return rule.priority; } } // Default priority for unmatched packs return 50; } /** * Check if creature entry passes the given criteria * @unused - Legacy method replaced by passesEnhancedCriteria */ // @ts-ignore - Legacy method kept for compatibility private passesCriteria(entry: any, criteria: { challengeRating?: number | { min?: number; max?: number }; creatureType?: string; size?: string; hasSpells?: boolean; hasLegendaryActions?: boolean; }): boolean { const system = entry.system || {}; // Challenge Rating filter - enhanced extraction if (criteria.challengeRating !== undefined) { // Try multiple possible CR locations in D&D 5e data structure let entryCR = system.details?.cr?.value || system.details?.cr || system.cr?.value || system.cr || 0; // Handle fractional CRs (common in D&D 5e) if (typeof entryCR === 'string') { if (entryCR === '1/8') entryCR = 0.125; else if (entryCR === '1/4') entryCR = 0.25; else if (entryCR === '1/2') entryCR = 0.5; else entryCR = parseFloat(entryCR) || 0; } if (typeof criteria.challengeRating === 'number') { if (entryCR !== criteria.challengeRating) { return false; } } else if (typeof criteria.challengeRating === 'object') { const { min = 0, max = 30 } = criteria.challengeRating; if (entryCR < min || entryCR > max) { return false; } } } // Creature Type filter - enhanced extraction if (criteria.creatureType) { // Try multiple possible type locations in D&D 5e data structure const entryType = system.details?.type?.value || system.details?.type || system.type?.value || system.type || ''; if (entryType.toLowerCase() !== criteria.creatureType.toLowerCase()) { return false; } } // Size filter if (criteria.size) { const entrySize = system.traits?.size || system.size || ''; if (entrySize.toLowerCase() !== criteria.size.toLowerCase()) return false; } // Spellcaster filter if (criteria.hasSpells !== undefined) { const isSpellcaster = !!(system.spells || system.attributes?.spellcasting || (system.details?.spellLevel && system.details.spellLevel > 0)); if (isSpellcaster !== criteria.hasSpells) return false; } // Legendary Actions filter if (criteria.hasLegendaryActions !== undefined) { const hasLegendary = !!(system.resources?.legact || system.legendary || (system.resources?.legres && system.resources.legres.value > 0)); if (hasLegendary !== criteria.hasLegendaryActions) return false; } return true; } /** * Simple name/description-based matching for creatures using index data only */ private matchesSearchCriteria(entry: any, criteria: { searchTerms?: string[]; excludeTerms?: string[]; size?: string; hasSpells?: boolean; hasLegendaryActions?: boolean; }): boolean { const name = (entry.name || '').toLowerCase(); const description = (entry.description || '').toLowerCase(); const searchText = `${name} ${description}`; // Include terms - at least one must match if (criteria.searchTerms && criteria.searchTerms.length > 0) { const hasMatch = criteria.searchTerms.some(term => searchText.includes(term.toLowerCase()) ); if (!hasMatch) { return false; } } // Exclude terms - none should match if (criteria.excludeTerms && criteria.excludeTerms.length > 0) { const hasExcluded = criteria.excludeTerms.some(term => searchText.includes(term.toLowerCase()) ); if (hasExcluded) { return false; } } return true; } /** * List all actors with basic information */ async listActors(): Promise<Array<{ id: string; name: string; type: string; img?: string }>> { return game.actors.map(actor => ({ id: actor.id || '', name: actor.name || '', type: actor.type, ...(actor.img ? { img: actor.img } : {}), })); } /** * Get active scene information */ async getActiveScene(): Promise<SceneInfo> { const scene = (game.scenes as any).current; if (!scene) { throw new Error(ERROR_MESSAGES.SCENE_NOT_FOUND); } const sceneData: SceneInfo = { id: scene.id, name: scene.name, img: scene.img || undefined, background: scene.background?.src || undefined, width: scene.width, height: scene.height, padding: scene.padding, active: scene.active, navigation: scene.navigation, tokens: scene.tokens.map((token: any) => ({ id: token.id, name: token.name, x: token.x, y: token.y, width: token.width, height: token.height, actorId: token.actorId || undefined, img: token.texture?.src || '', hidden: token.hidden, disposition: this.getTokenDisposition(token.disposition), })), walls: scene.walls.size, lights: scene.lights.size, sounds: scene.sounds.size, notes: scene.notes.map((note: any) => ({ id: note.id, text: note.text || '', x: note.x, y: note.y, })), }; return sceneData; } /** * Get world information */ async getWorldInfo(): Promise<WorldInfo> { // World info doesn't require special permissions as it's basic metadata return { id: game.world.id, title: game.world.title, system: game.system.id, systemVersion: game.system.version, foundryVersion: game.version, users: game.users.map(user => ({ id: user.id || '', name: user.name || '', active: user.active, isGM: user.isGM, })), }; } /** * Get available compendium packs */ async getAvailablePacks() { return Array.from(game.packs.values()).map(pack => ({ id: pack.metadata.id, label: pack.metadata.label, type: pack.metadata.type, system: pack.metadata.system, private: pack.metadata.private, })); } /** * Sanitize data to remove sensitive information and make it JSON-safe */ private sanitizeData(data: any): any { if (data === null || data === undefined) { return data; } if (typeof data !== 'object') { return data; } try { // removeSensitiveFields now returns a sanitized copy const sanitized = this.removeSensitiveFields(data); // Use custom JSON serializer to avoid deprecated property warnings const jsonString = this.safeJSONStringify(sanitized); return JSON.parse(jsonString); } catch (error) { console.warn(`[${this.moduleId}] Failed to sanitize data:`, error); return {}; } } /** * Remove sensitive fields from data object with circular reference protection * Returns a sanitized copy instead of modifying the original */ private removeSensitiveFields(obj: any, visited: WeakSet<object> = new WeakSet(), depth: number = 0): any { // Handle primitives if (obj === null || typeof obj !== 'object') { return obj; } // Safety depth limit to prevent extremely deep recursion if (depth > 50) { console.warn(`[${this.moduleId}] Sanitization depth limit reached at depth ${depth}`); return '[Max depth reached]'; } // Check for circular reference if (visited.has(obj)) { return '[Circular Reference]'; } // Mark this object as visited visited.add(obj); try { // Handle arrays if (Array.isArray(obj)) { return obj.map(item => this.removeSensitiveFields(item, visited, depth + 1)); } // Create a new sanitized object const sanitized: any = {}; for (const [key, value] of Object.entries(obj)) { // Skip sensitive and problematic fields entirely if (this.isSensitiveOrProblematicField(key)) { continue; } // Skip most private properties except essential ones if (key.startsWith('_') && !['_id', '_stats', '_source'].includes(key)) { continue; } // Recursively sanitize the value sanitized[key] = this.removeSensitiveFields(value, visited, depth + 1); } return sanitized; } catch (error) { console.warn(`[${this.moduleId}] Error during sanitization at depth ${depth}:`, error); return '[Sanitization failed]'; } } /** * Check if a field should be excluded from sanitized output */ private isSensitiveOrProblematicField(key: string): boolean { const sensitiveKeys = [ 'password', 'token', 'secret', 'key', 'auth', 'credential', 'session', 'cookie', 'private' ]; const problematicKeys = [ 'parent', '_parent', 'collection', 'apps', 'document', '_document', 'constructor', 'prototype', '__proto__', 'valueOf', 'toString' ]; // Skip deprecated ability save properties that trigger warnings const deprecatedKeys = [ 'save' // Skip the deprecated 'save' property on abilities ]; return sensitiveKeys.includes(key) || problematicKeys.includes(key) || deprecatedKeys.includes(key); } /** * Custom JSON serializer that handles Foundry objects safely */ private safeJSONStringify(obj: any): string { try { return JSON.stringify(obj, (key, value) => { // Skip deprecated properties during JSON serialization if (key === 'save' && typeof value === 'object' && value !== null) { // If this looks like a deprecated ability save object, skip it return undefined; } return value; }); } catch (error) { console.warn(`[${this.moduleId}] JSON stringify failed, using fallback:`, error); return '{}'; } } /** * Get token disposition as number */ private getTokenDisposition(disposition: any): number { if (typeof disposition === 'number') { return disposition; } // Default to neutral if unknown return TOKEN_DISPOSITIONS.NEUTRAL; } /** * Validate that Foundry is ready and world is active */ validateFoundryState(): void { if (!game || !game.ready) { throw new Error('Foundry VTT is not ready'); } if (!game.world) { throw new Error('No active world'); } if (!game.user) { throw new Error('No active user'); } } /** * Audit log for write operations */ private auditLog(operation: string, data: any, result: 'success' | 'failure', error?: string): void { // Always audit write operations (no setting required) const logEntry = { timestamp: new Date().toISOString(), operation, user: game.user?.name || 'Unknown', userId: game.user?.id || 'unknown', world: game.world?.id || 'unknown', data: this.sanitizeData(data), result, error, }; // Store in flags for persistence (optional) if (game.world && (game.world as any).setFlag) { const auditLogs = (game.world as any).getFlag(this.moduleId, 'auditLogs') || []; auditLogs.push(logEntry); // Keep only last 100 entries to prevent bloat if (auditLogs.length > 100) { auditLogs.splice(0, auditLogs.length - 100); } (game.world as any).setFlag(this.moduleId, 'auditLogs', auditLogs); } } // ===== PHASE 2 & 3: WRITE OPERATIONS ===== /** * Create journal entry for quests */ async createJournalEntry(request: { name: string; content: string; folderName?: string }): Promise<{ id: string; name: string }> { this.validateFoundryState(); // Use permission system for journal creation const permissionCheck = permissionManager.checkWritePermission('createActor', { quantity: 1, // Treat journal creation similar to actor creation for permissions }); if (!permissionCheck.allowed) { throw new Error(`Journal creation denied: ${permissionCheck.reason}`); } try { // Create journal entry with proper Foundry v13 structure const journalData = { name: request.name, pages: [{ type: 'text', name: 'Quest Details', // Use generic page name to avoid title repetition text: { content: request.content } }], ownership: { default: 0 }, // GM only by default folder: await this.getOrCreateFolder(request.folderName || request.name, 'JournalEntry') }; const journal = await JournalEntry.create(journalData); if (!journal) { throw new Error('Failed to create journal entry'); } const result = { id: journal.id, name: journal.name || request.name, }; this.auditLog('createJournalEntry', request, 'success'); return result; } catch (error) { this.auditLog('createJournalEntry', request, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * List all journal entries */ async listJournals(): Promise<Array<{ id: string; name: string; type: string }>> { this.validateFoundryState(); return game.journal.map((journal: any) => ({ id: journal.id || '', name: journal.name || '', type: 'JournalEntry', })); } /** * Get journal entry content */ async getJournalContent(journalId: string): Promise<{ content: string } | null> { this.validateFoundryState(); const journal = game.journal.get(journalId); if (!journal) { return null; } // Get first text page content const firstPage = journal.pages.find((page: any) => page.type === 'text'); if (!firstPage) { return { content: '' }; } return { content: firstPage.text?.content || '', }; } /** * Update journal entry content */ async updateJournalContent(request: { journalId: string; content: string }): Promise<{ success: boolean }> { this.validateFoundryState(); // Use permission system for journal updates - treating as createActor permission level const permissionCheck = permissionManager.checkWritePermission('createActor', { quantity: 1, // Treat journal updates similar to actor creation for permissions }); if (!permissionCheck.allowed) { throw new Error(`Journal update denied: ${permissionCheck.reason}`); } try { const journal = game.journal.get(request.journalId); if (!journal) { throw new Error('Journal entry not found'); } // Update first text page or create one if none exists const firstPage = journal.pages.find((page: any) => page.type === 'text'); if (firstPage) { // Update existing page await firstPage.update({ 'text.content': request.content, }); } else { // Create new text page await journal.createEmbeddedDocuments('JournalEntryPage', [{ type: 'text', name: 'Quest Details', // Use generic page name to avoid title repetition text: { content: request.content, }, }]); } this.auditLog('updateJournalContent', request, 'success'); return { success: true }; } catch (error) { this.auditLog('updateJournalContent', request, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Create actors from compendium entries with custom names */ async createActorFromCompendium(request: ActorCreationRequest): Promise<ActorCreationResult> { this.validateFoundryState(); // Use new permission system const permissionCheck = permissionManager.checkWritePermission('createActor', { quantity: request.quantity || 1, }); if (!permissionCheck.allowed) { throw new Error(`${ERROR_MESSAGES.ACCESS_DENIED}: ${permissionCheck.reason}`); } // Audit the permission check permissionManager.auditPermissionCheck('createActor', permissionCheck, request); const maxActors = game.settings.get(this.moduleId, 'maxActorsPerRequest') as number; const quantity = Math.min(request.quantity || 1, maxActors); // Start transaction for rollback capability const transactionId = transactionManager.startTransaction( `Create ${quantity} actor(s) from compendium: ${request.creatureType}` ); try { // Find matching compendium entry const compendiumEntry = await this.findBestCompendiumMatch(request.creatureType, request.packPreference); if (!compendiumEntry) { throw new Error(`No compendium entry found for "${request.creatureType}"`); } // Get full compendium document const sourceDoc = await this.getCompendiumDocumentFull( compendiumEntry.pack, compendiumEntry.id ); const createdActors: CreatedActorInfo[] = []; const errors: string[] = []; // Create actors with custom names for (let i = 0; i < quantity; i++) { try { const customName = request.customNames?.[i] || (quantity > 1 ? `${sourceDoc.name} ${i + 1}` : sourceDoc.name); const newActor = await this.createActorFromSource(sourceDoc, customName); // Track actor creation for rollback transactionManager.addAction(transactionId, transactionManager.createActorCreationAction(newActor.id) ); createdActors.push({ id: newActor.id, name: newActor.name, originalName: sourceDoc.name, type: newActor.type, sourcePackId: compendiumEntry.pack, sourcePackLabel: compendiumEntry.packLabel, img: newActor.img, }); } catch (error) { errors.push(`Failed to create actor ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } let tokensPlaced = 0; // Add to scene if requested and permission allows if (request.addToScene && createdActors.length > 0) { try { const scenePermissionCheck = permissionManager.checkWritePermission('modifyScene', { targetIds: createdActors.map(a => a.id), }); if (!scenePermissionCheck.allowed) { errors.push(`Cannot add to scene: ${scenePermissionCheck.reason}`); } else { const tokenResult = await this.addActorsToScene({ actorIds: createdActors.map(a => a.id), placement: 'random', hidden: false, }, transactionId); tokensPlaced = tokenResult.tokensCreated; } } catch (error) { errors.push(`Failed to add actors to scene: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // If we had partial failure, decide whether to rollback if (errors.length > 0 && createdActors.length < quantity) { // Rollback if we failed to create more than half the requested actors if (createdActors.length < quantity / 2) { console.warn(`[${this.moduleId}] Rolling back due to significant failures (${createdActors.length}/${quantity} created)`); await transactionManager.rollbackTransaction(transactionId); throw new Error(`Actor creation failed: ${errors.join(', ')}`); } } // Commit transaction transactionManager.commitTransaction(transactionId); const result: ActorCreationResult = { success: createdActors.length > 0, actors: createdActors, ...(errors.length > 0 ? { errors } : {}), tokensPlaced, totalRequested: quantity, totalCreated: createdActors.length, }; this.auditLog('createActorFromCompendium', request, 'success'); return result; } catch (error) { // Rollback on complete failure try { await transactionManager.rollbackTransaction(transactionId); } catch (rollbackError) { console.error(`[${this.moduleId}] Failed to rollback transaction:`, rollbackError); } this.auditLog('createActorFromCompendium', request, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Create actor from specific compendium entry using pack/item IDs */ async createActorFromCompendiumEntry(request: { packId: string; itemId: string; customNames: string[]; quantity?: number; addToScene?: boolean; placement?: { type: 'random' | 'grid' | 'center' | 'coordinates'; coordinates?: { x: number; y: number }[]; }; }): Promise<ActorCreationResult> { this.validateFoundryState(); try { const { packId, itemId, customNames, quantity = 1, addToScene = false, placement } = request; // Validate inputs if (!packId || !itemId) { throw new Error('Both packId and itemId are required'); } // Get the pack const pack = game.packs.get(packId); if (!pack) { throw new Error(`Compendium pack "${packId}" not found`); } // Get the specific document const sourceDocument = await pack.getDocument(itemId); if (!sourceDocument) { throw new Error(`Document "${itemId}" not found in pack "${packId}"`); } if (!['npc', 'character'].includes(sourceDocument.type)) { throw new Error(`Document "${itemId}" is not a valid actor type (type: ${sourceDocument.type}). Expected "npc" or "character".`); } const sourceActor = sourceDocument as Actor; // Prepare custom names const names = customNames.length > 0 ? customNames : [`${sourceActor.name} Copy`]; const finalQuantity = Math.min(quantity, names.length); const createdActors: any[] = []; const errors: string[] = []; // Create actors for (let i = 0; i < finalQuantity; i++) { try { const customName = names[i] || `${sourceActor.name} ${i + 1}`; // Create actor data with full system, items, and effects const sourceData = sourceActor.toObject() as any; const actorData = { name: customName, type: sourceData.type, img: sourceData.img, system: sourceData.system || sourceData.data || {}, items: sourceData.items || [], effects: sourceData.effects || [], folder: null, // Don't inherit folder prototypeToken: sourceData.prototypeToken, // Include prototype token }; // Fix remote image URLs - normalize to local paths if (actorData.prototypeToken?.texture?.src?.startsWith('http')) { actorData.prototypeToken.texture.src = null; // Clear remote URL } // Organize created actors in a folder - use "Foundry MCP Creatures" for generic monsters const folderId = await this.getOrCreateFolder('Foundry MCP Creatures', 'Actor'); if (folderId) { (actorData as any).folder = folderId; } // Create the actor const newActor = await Actor.create(actorData); if (!newActor) { throw new Error(`Failed to create actor "${customName}"`); } createdActors.push({ id: newActor.id, name: newActor.name, originalName: sourceActor.name, sourcePackLabel: pack.metadata.label, }); } catch (error) { const errorMsg = `Failed to create actor ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`; errors.push(errorMsg); console.error(`[${MODULE_ID}] ${errorMsg}`, error); } } // Add to scene if requested let tokensPlaced = 0; if (addToScene && createdActors.length > 0) { try { const sceneResult = await this.addActorsToScene({ actorIds: createdActors.map(a => a.id), placement: placement?.type || 'grid', hidden: false, ...(placement?.coordinates && { coordinates: placement.coordinates }) }); tokensPlaced = sceneResult.success ? sceneResult.tokensCreated : 0; } catch (error) { errors.push(`Failed to add actors to scene: ${error instanceof Error ? error.message : 'Unknown error'}`); } } const result: ActorCreationResult = { success: createdActors.length > 0, totalCreated: createdActors.length, totalRequested: finalQuantity, actors: createdActors, tokensPlaced, errors: errors.length > 0 ? errors : undefined, }; this.auditLog('createActorFromCompendiumEntry', request, 'success'); return result; } catch (error) { console.error(`[${MODULE_ID}] Failed to create actor from compendium entry`, error); this.auditLog('createActorFromCompendiumEntry', request, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Get full compendium document with all embedded data */ async getCompendiumDocumentFull(packId: string, documentId: string): Promise<CompendiumEntryFull> { const pack = game.packs.get(packId); if (!pack) { throw new Error(`Compendium pack ${packId} not found`); } const document = await pack.getDocument(documentId); if (!document) { throw new Error(`Document ${documentId} not found in pack ${packId}`); } // Build comprehensive data structure const fullEntry: CompendiumEntryFull = { id: document.id || '', name: document.name || '', type: (document as any).type || 'unknown', img: (document as any).img || undefined, pack: packId, packLabel: pack.metadata.label, system: this.sanitizeData((document as any).system || {}), fullData: this.sanitizeData(document.toObject()), }; // Add items if the actor has them if ((document as any).items) { fullEntry.items = (document as any).items.map((item: any) => ({ id: item.id, name: item.name, type: item.type, img: item.img || undefined, system: this.sanitizeData(item.system || {}), })); } // Add effects if the actor has them if ((document as any).effects) { fullEntry.effects = (document as any).effects.map((effect: any) => ({ id: effect.id, name: effect.name || effect.label || 'Unknown Effect', icon: effect.icon || undefined, disabled: effect.disabled || false, duration: this.sanitizeData(effect.duration || {}), })); } return fullEntry; } /** * Add actors to the current scene as tokens */ async addActorsToScene(placement: SceneTokenPlacement, transactionId?: string): Promise<TokenPlacementResult> { this.validateFoundryState(); // Use new permission system const permissionCheck = permissionManager.checkWritePermission('modifyScene', { targetIds: placement.actorIds, }); if (!permissionCheck.allowed) { throw new Error(`${ERROR_MESSAGES.ACCESS_DENIED}: ${permissionCheck.reason}`); } // Audit the permission check permissionManager.auditPermissionCheck('modifyScene', permissionCheck, placement); const scene = (game.scenes as any).current; if (!scene) { throw new Error('No active scene found'); } this.auditLog('addActorsToScene', placement, 'success'); try { const tokenData: any[] = []; const errors: string[] = []; for (const actorId of placement.actorIds) { try { const actor = game.actors.get(actorId); if (!actor) { errors.push(`Actor ${actorId} not found`); continue; } const tokenDoc = (actor as any).prototypeToken.toObject(); const position = this.calculateTokenPosition(placement.placement, scene, tokenData.length, placement.coordinates); // Fix token texture if it's still a remote URL (Foundry may have overridden our actor creation fix) if (tokenDoc.texture?.src?.startsWith('http')) { console.error(`[${this.moduleId}] Token texture still has remote URL, clearing: ${tokenDoc.texture.src}`); tokenDoc.texture.src = null; // Use Foundry's fallback } else { } tokenData.push({ ...tokenDoc, x: position.x, y: position.y, actorId: actorId, hidden: placement.hidden, }); } catch (error) { errors.push(`Failed to prepare token for actor ${actorId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } const createdTokens = await scene.createEmbeddedDocuments('Token', tokenData); // Track token creation for rollback if transaction is active if (transactionId && createdTokens.length > 0) { for (const token of createdTokens) { transactionManager.addAction(transactionId, transactionManager.createTokenCreationAction(token.id) ); } } const result: TokenPlacementResult = { success: createdTokens.length > 0, tokensCreated: createdTokens.length, tokenIds: createdTokens.map((token: any) => token.id), ...(errors.length > 0 ? { errors } : {}), }; this.auditLog('addActorsToScene', placement, 'success'); return result; } catch (error) { this.auditLog('addActorsToScene', placement, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Find best matching compendium entry for creature type */ private async findBestCompendiumMatch(creatureType: string, packPreference?: string): Promise<CompendiumSearchResult | null> { // First try exact search const exactResults = await this.searchCompendium(creatureType, 'Actor'); // Look for exact name match first const exactMatch = exactResults.find(result => result.name.toLowerCase() === creatureType.toLowerCase() ); if (exactMatch) return exactMatch; // Look for partial matches, preferring specified pack if (packPreference) { const packMatch = exactResults.find(result => result.pack === packPreference ); if (packMatch) return packMatch; } // Return best fuzzy match return exactResults.length > 0 ? exactResults[0] : null; } /** * Create actor from source document with custom name */ private async createActorFromSource(sourceDoc: CompendiumEntryFull, customName: string): Promise<any> { try { // Clone the source data const actorData = foundry.utils.deepClone(sourceDoc.fullData) as any; // Apply customizations actorData.name = customName; // Fix only token texture - leave portrait (actor.img) alone if (actorData.prototypeToken?.texture?.src?.startsWith('http')) { console.error(`[${this.moduleId}] Removing remote token texture URL: ${actorData.prototypeToken.texture.src}`); actorData.prototypeToken.texture.src = null; // Let Foundry use fallback } // Remove source-specific identifiers delete actorData._id; delete actorData.folder; delete actorData.sort; // Ensure required fields are present if (!actorData.name) actorData.name = customName; if (!actorData.type) actorData.type = sourceDoc.type || 'npc'; // Organize created actors in a folder - use "Foundry MCP Creatures" for generic monsters const folderId = await this.getOrCreateFolder('Foundry MCP Creatures', 'Actor'); if (folderId) { (actorData as any).folder = folderId; } // Create the new actor const createdDocs = await Actor.createDocuments([actorData]); if (!createdDocs || createdDocs.length === 0) { throw new Error('Failed to create actor document'); } return createdDocs[0]; } catch (error) { console.error(`[${this.moduleId}] Actor creation failed:`, error); throw error; } } /** * Calculate token position based on placement strategy */ private calculateTokenPosition(placement: 'random' | 'grid' | 'center' | 'coordinates', scene: any, index: number, coordinates?: { x: number; y: number }[]): { x: number; y: number } { const gridSize = scene.grid?.size || 100; switch (placement) { case 'coordinates': if (coordinates && coordinates[index]) { return coordinates[index]; } // Fallback to grid if coordinates not provided or insufficient const fallbackCols = Math.ceil(Math.sqrt(index + 1)); const fallbackRow = Math.floor(index / fallbackCols); const fallbackCol = index % fallbackCols; return { x: gridSize + (fallbackCol * gridSize * 2), y: gridSize + (fallbackRow * gridSize * 2), }; case 'center': return { x: (scene.width / 2) + (index * gridSize), y: scene.height / 2, }; case 'grid': const cols = Math.ceil(Math.sqrt(index + 1)); const row = Math.floor(index / cols); const col = index % cols; return { x: gridSize + (col * gridSize * 2), y: gridSize + (row * gridSize * 2), }; case 'random': default: return { x: Math.random() * (scene.width - gridSize), y: Math.random() * (scene.height - gridSize), }; } } /** * Validate write operation permissions */ async validateWritePermissions(operation: 'createActor' | 'modifyScene'): Promise<{ allowed: boolean; reason?: string; requiresConfirmation?: boolean; warnings?: string[] }> { this.validateFoundryState(); const permissionCheck = permissionManager.checkWritePermission(operation); // Audit the permission check permissionManager.auditPermissionCheck(operation, permissionCheck); return { allowed: permissionCheck.allowed, ...(permissionCheck.reason ? { reason: permissionCheck.reason } : {}), ...(permissionCheck.requiresConfirmation ? { requiresConfirmation: permissionCheck.requiresConfirmation } : {}), ...(permissionCheck.warnings ? { warnings: permissionCheck.warnings } : {}), }; } /** * Request player rolls - creates interactive roll buttons in chat */ async requestPlayerRolls(data: { rollType: string; rollTarget: string; targetPlayer: string; isPublic: boolean; rollModifier: string; flavor: string; }): Promise<{ success: boolean; message: string; error?: string }> { this.validateFoundryState(); try { // Resolve target player from character name or player name with enhanced error handling const playerInfo = this.resolveTargetPlayer(data.targetPlayer); if (!playerInfo.found) { // Provide structured error message for MCP that Claude Desktop can understand const errorMessage = playerInfo.errorMessage || `Could not find player or character: ${data.targetPlayer}`; return { success: false, message: '', error: errorMessage }; } // Build roll formula based on type and target const rollFormula = this.buildRollFormula(data.rollType, data.rollTarget, data.rollModifier, playerInfo.character); // Generate roll button HTML const buttonId = foundry.utils.randomID(); const buttonLabel = this.buildRollButtonLabel(data.rollType, data.rollTarget, data.isPublic); // Check if this type of roll was already performed (optional: could check for duplicate recent rolls) // For now, we'll just create the button and let the rendering logic handle the state restoration const rollButtonHtml = ` <div class="mcp-roll-request" style="margin: 12px 0; padding: 12px; border: 1px solid #ccc; border-radius: 8px; background: #f9f9f9;"> <p><strong>Roll Request:</strong> ${buttonLabel}</p> <p><strong>Target:</strong> ${playerInfo.targetName} ${playerInfo.character ? `(${playerInfo.character.name})` : ''}</p> ${data.flavor ? `<p><strong>Context:</strong> ${data.flavor}</p>` : ''} <div style="text-align: center; margin-top: 8px;"> <!-- Single Roll Button (clickable by both character owner and GM) --> <button class="mcp-roll-button mcp-button-active" data-button-id="${buttonId}" data-roll-formula="${rollFormula}" data-roll-label="${buttonLabel}" data-is-public="${data.isPublic}" data-character-id="${playerInfo.character?.id || ''}" data-target-user-id="${playerInfo.user?.id || ''}"> 🎲 ${buttonLabel} </button> </div> </div> `; // Create chat message with roll button // For PUBLIC rolls: both roll request and results visible to all players // For PRIVATE rolls: both roll request and results visible to target player + GM only const whisperTargets: string[] = []; if (!data.isPublic) { // Private roll request: whisper to target player + GM only // Always whisper to the character owner if they exist if (playerInfo.user?.id) { whisperTargets.push(playerInfo.user.id); } // Also send to GM (GMs can see all whispered messages anyway, but this ensures they see it) const gmUsers = game.users?.filter((u: User) => u.isGM && u.active); if (gmUsers) { for (const gm of gmUsers) { if (gm.id && !whisperTargets.includes(gm.id)) { whisperTargets.push(gm.id); } } } } else { // Public roll request: visible to all players (empty whisperTargets array) } const messageData = { content: rollButtonHtml, speaker: ChatMessage.getSpeaker({ actor: game.user }), style: (CONST as any).CHAT_MESSAGE_STYLES?.OTHER || 0, // Use style instead of deprecated type whisper: whisperTargets, flags: { [MODULE_ID]: { rollButtons: { [buttonId]: { rolled: false, rollFormula: rollFormula, rollLabel: buttonLabel, isPublic: data.isPublic, characterId: playerInfo.character?.id || '', targetUserId: playerInfo.user?.id || '' } } } } }; const chatMessage = await ChatMessage.create(messageData); // Store message ID for later updates this.saveRollButtonMessageId(buttonId, chatMessage.id); // Note: Click handlers are attached globally via renderChatMessageHTML hook in main.ts // This ensures all users get the handlers when they see the message return { success: true, message: `Roll request sent to ${playerInfo.targetName}. ${data.isPublic ? 'Public roll' : 'Private roll'} button created in chat.` }; } catch (error) { console.error(`[${MODULE_ID}] Error creating roll request:`, error); return { success: false, message: '', error: error instanceof Error ? error.message : 'Unknown error creating roll request' }; } } /** * Enhanced player resolution with offline/non-existent player detection * Supports partial matching and provides structured error messages for MCP */ private resolveTargetPlayer(targetPlayer: string): { found: boolean; user?: User; character?: Actor; targetName: string; errorType?: 'PLAYER_OFFLINE' | 'PLAYER_NOT_FOUND' | 'CHARACTER_NOT_FOUND'; errorMessage?: string; } { const searchTerm = targetPlayer.toLowerCase().trim(); // FIRST: Check all registered users (both active and inactive) for player name match const allUsers = Array.from(game.users?.values() || []); // Try exact player name match first (active and inactive users) let user = allUsers.find((u: User) => u.name?.toLowerCase() === searchTerm ); if (user) { const isActive = user.active; if (!isActive) { // Player exists but is offline return { found: false, user, targetName: user.name || 'Unknown Player', errorType: 'PLAYER_OFFLINE', errorMessage: `Player "${user.name}" is registered but not currently logged in. They need to be online to receive roll requests.` }; } // Find the player's character for roll calculations const playerCharacter = game.actors?.find((actor: Actor) => { if (!user) return false; return actor.testUserPermission(user, 'OWNER') && !user.isGM; }); return { found: true, user, ...(playerCharacter && { character: playerCharacter }), // Include character only if found targetName: user.name || 'Unknown Player' }; } // Try partial player name match (active and inactive users) if (!user) { user = allUsers.find((u: User) => { return Boolean(u.name && u.name.toLowerCase().includes(searchTerm)); }); if (user) { const isActive = user.active; if (!isActive) { // Player exists but is offline return { found: false, user, targetName: user.name || 'Unknown Player', errorType: 'PLAYER_OFFLINE', errorMessage: `Player "${user.name}" is registered but not currently logged in. They need to be online to receive roll requests.` }; } // Find the player's character for roll calculations const playerCharacter = game.actors?.find((actor: Actor) => { if (!user) return false; return actor.testUserPermission(user, 'OWNER') && !user.isGM; }); return { found: true, user, ...(playerCharacter && { character: playerCharacter }), // Include character only if found targetName: user.name || 'Unknown Player' }; } } // SECOND: Try to find by character name (exact match, then partial match) let character = game.actors?.find((actor: Actor) => actor.name?.toLowerCase() === searchTerm && actor.hasPlayerOwner ); if (character) { } // If no exact character match, try partial match if (!character) { character = game.actors?.find((actor: Actor) => { return Boolean(actor.name && actor.name.toLowerCase().includes(searchTerm) && actor.hasPlayerOwner); }); if (character) { } } if (character) { // Find the actual player owner (not GM) of this character const ownerUser = allUsers.find((u: User) => character.testUserPermission(u, 'OWNER') && !u.isGM ); if (ownerUser) { const isOwnerActive = ownerUser.active; if (!isOwnerActive) { // Character owner exists but is offline return { found: false, user: ownerUser, character, targetName: ownerUser.name || 'Unknown Player', errorType: 'PLAYER_OFFLINE', errorMessage: `Player "${ownerUser.name}" (owner of character "${character.name}") is registered but not currently logged in. They need to be online to receive roll requests.` }; } return { found: true, user: ownerUser, character, targetName: ownerUser.name || 'Unknown Player' }; } else { // No player owner found - character is GM-only controlled // Still return found=true but without user, GM can still roll for it return { found: true, character, targetName: character.name || 'Unknown Character' // user is omitted (undefined) for GM-only characters }; } } // THIRD: Check if the search term might be a character that exists but has no player owner const anyCharacter = game.actors?.find((actor: Actor) => { if (!actor.name) return false; return actor.name.toLowerCase() === searchTerm || actor.name.toLowerCase().includes(searchTerm); }); if (anyCharacter && !anyCharacter.hasPlayerOwner) { return { found: true, character: anyCharacter, targetName: anyCharacter.name || 'Unknown Character' // No user for GM-controlled characters }; } // No player or character found at all return { found: false, targetName: targetPlayer, errorType: 'PLAYER_NOT_FOUND', errorMessage: `No player or character named "${targetPlayer}" found. Available players: ${allUsers.filter(u => !u.isGM).map(u => u.name).join(', ') || 'none'}` }; } /** * Build roll formula based on roll type and target using Foundry's roll data system */ private buildRollFormula(rollType: string, rollTarget: string, rollModifier: string, character?: Actor): string { let baseFormula = '1d20'; if (character) { // Use Foundry's getRollData() to get calculated modifiers including active effects const rollData = character.getRollData() as any; // Type assertion for Foundry's dynamic roll data switch (rollType) { case 'ability': // Use calculated ability modifier from roll data const abilityMod = rollData.abilities?.[rollTarget]?.mod ?? 0; baseFormula = `1d20+${abilityMod}`; break; case 'skill': // Map skill name to skill code (D&D 5e uses 3-letter codes) const skillCode = this.getSkillCode(rollTarget); // Use calculated skill total from roll data (includes ability mod + proficiency + bonuses) const skillMod = rollData.skills?.[skillCode]?.total ?? 0; baseFormula = `1d20+${skillMod}`; break; case 'save': // Use saving throw modifier from roll data const saveMod = rollData.abilities?.[rollTarget]?.save ?? rollData.abilities?.[rollTarget]?.mod ?? 0; baseFormula = `1d20+${saveMod}`; break; case 'initiative': // Use initiative modifier from attributes or dex mod const initMod = rollData.attributes?.init?.mod ?? rollData.abilities?.dex?.mod ?? 0; baseFormula = `1d20+${initMod}`; break; case 'custom': baseFormula = rollTarget; // Use rollTarget as the formula directly break; default: baseFormula = '1d20'; } } else { console.warn(`[${MODULE_ID}] No character provided for roll formula, using base 1d20`); } // Add modifier if provided if (rollModifier && rollModifier.trim()) { const modifier = rollModifier.startsWith('+') || rollModifier.startsWith('-') ? rollModifier : `+${rollModifier}`; baseFormula += modifier; } return baseFormula; } /** * Map skill names to D&D 5e skill codes */ private getSkillCode(skillName: string): string { const skillMap: { [key: string]: string } = { 'acrobatics': 'acr', 'animal handling': 'ani', 'animalhandling': 'ani', 'arcana': 'arc', 'athletics': 'ath', 'deception': 'dec', 'history': 'his', 'insight': 'ins', 'intimidation': 'itm', 'investigation': 'inv', 'medicine': 'med', 'nature': 'nat', 'perception': 'prc', 'performance': 'prf', 'persuasion': 'per', 'religion': 'rel', 'sleight of hand': 'slt', 'sleightofhand': 'slt', 'stealth': 'ste', 'survival': 'sur' }; const normalizedName = skillName.toLowerCase().replace(/\s+/g, ''); const skillCode = skillMap[normalizedName] || skillMap[skillName.toLowerCase()] || skillName.toLowerCase(); return skillCode; } /** * Build roll button label */ private buildRollButtonLabel(rollType: string, rollTarget: string, isPublic: boolean): string { const visibility = isPublic ? 'Public' : 'Private'; switch (rollType) { case 'ability': return `${rollTarget.toUpperCase()} Ability Check (${visibility})`; case 'skill': return `${rollTarget.charAt(0).toUpperCase() + rollTarget.slice(1)} Skill Check (${visibility})`; case 'save': return `${rollTarget.toUpperCase()} Saving Throw (${visibility})`; case 'attack': return `${rollTarget} Attack (${visibility})`; case 'initiative': return `Initiative Roll (${visibility})`; case 'custom': return `Custom Roll (${visibility})`; default: return `Roll (${visibility})`; } } /** * Restore roll button states from persistent storage * Called when chat messages are rendered to maintain state across sessions */ /** * Attach click handlers to roll buttons and handle visibility * Called by global renderChatMessageHTML hook in main.ts */ public attachRollButtonHandlers(html: JQuery): void { const currentUserId = game.user?.id; const isGM = game.user?.isGM; // Note: Roll state restoration now handled by ChatMessage content, not DOM manipulation // Handle button visibility and styling based on permissions and public/private status // IMPORTANT: Skip styling for buttons that are already in rolled state html.find('.mcp-roll-button').each((_index, element) => { const button = $(element); const targetUserId = button.data('target-user-id'); const isPublicRollRaw = button.data('is-public'); const isPublicRoll = isPublicRollRaw === true || isPublicRollRaw === 'true'; // Note: No need to check for rolled state - ChatMessage.update() replaces buttons with completion status // Determine if user can interact with this button const canClickButton = isGM || (targetUserId && targetUserId === currentUserId); if (isPublicRoll) { // Public roll: show to all players, but style differently for non-clickable users if (canClickButton) { // Can click: normal active button button.css({ 'background': '#4CAF50', 'cursor': 'pointer', 'opacity': '1' }); } else { // Cannot click: disabled/informational style button.css({ 'background': '#9E9E9E', 'cursor': 'not-allowed', 'opacity': '0.7' }); button.prop('disabled', true); } } else { // Private roll: only show to target user and GM if (canClickButton) { button.show(); } else { button.hide(); } } }); // Attach click handlers to roll buttons html.find('.mcp-roll-button').on('click', async (event) => { const button = $(event.currentTarget); // Ignore clicks on disabled buttons if (button.prop('disabled')) { return; } // Prevent double-clicks by immediately disabling the button button.prop('disabled', true); const originalText = button.text(); button.text('🎲 Rolling...'); // Check if this button is already being processed by another user const buttonId = button.data('button-id'); if (buttonId && this.isRollButtonProcessing(buttonId)) { button.text('🎲 Processing...'); return; } // Mark this button as being processed if (buttonId) { this.setRollButtonProcessing(buttonId, true); } // Validate button has required data if (!buttonId) { console.warn(`[${MODULE_ID}] Button missing button-id data attribute`); button.prop('disabled', false); button.text(originalText); return; } const rollFormula = button.data('roll-formula'); const rollLabel = button.data('roll-label'); const isPublicRaw = button.data('is-public'); const isPublic = isPublicRaw === true || isPublicRaw === 'true'; // Convert to proper boolean const characterId = button.data('character-id'); const targetUserId = button.data('target-user-id'); const isGmRoll = game.user?.isGM || false; // Determine if this is a GM executing the roll // Check if user has permission to execute this roll // Allow GM to roll for any character, or allow character owner to roll for their character const canExecuteRoll = game.user?.isGM || (targetUserId && targetUserId === game.user?.id); if (!canExecuteRoll) { console.warn(`[${MODULE_ID}] Permission denied for roll execution`); ui.notifications?.warn('You do not have permission to execute this roll'); return; } try { // Create and evaluate the roll const roll = new Roll(rollFormula); await roll.evaluate(); // Get the character for speaker info const character = characterId ? game.actors?.get(characterId) : null; // Use the modern Foundry v13 approach with roll.toMessage() const rollMode = isPublic ? 'publicroll' : 'whisper'; const whisperTargets: string[] = []; if (!isPublic) { // For private rolls: whisper to target + GM if (targetUserId) { whisperTargets.push(targetUserId); } // Add all active GMs const gmUsers = game.users?.filter((u: User) => u.isGM && u.active); if (gmUsers) { for (const gm of gmUsers) { if (gm.id && !whisperTargets.includes(gm.id)) { whisperTargets.push(gm.id); } } } } const messageData: any = { speaker: ChatMessage.getSpeaker({ actor: character }), flavor: `${rollLabel} ${isGmRoll ? '(GM Override)' : ''}`, ...(whisperTargets.length > 0 ? { whisper: whisperTargets } : {}) }; // Use roll.toMessage() with proper rollMode await roll.toMessage(messageData, { create: true, rollMode: rollMode }); // Update the ChatMessage to reflect rolled state const buttonId = button.data('button-id'); if (buttonId && game.user?.id) { try { await this.updateRollButtonMessage(buttonId, game.user.id, rollLabel); } catch (updateError) { console.error(`[${MODULE_ID}] Failed to update chat message:`, updateError); console.error(`[${MODULE_ID}] Error details:`, updateError instanceof Error ? updateError.stack : updateError); // Fall back to DOM manipulation if message update fails button.prop('disabled', true).text('✓ Rolled'); } } else { console.warn(`[${MODULE_ID}] Cannot update ChatMessage - missing buttonId or userId:`, { buttonId, userId: game.user?.id }); } } catch (error) { console.error(`[${MODULE_ID}] Error executing roll:`, error); ui.notifications?.error('Failed to execute roll'); // Re-enable button on error so user can try again button.prop('disabled', false); button.text(originalText); } finally { // Clear processing state if (buttonId) { this.setRollButtonProcessing(buttonId, false); } } }); } /** * Get enhanced creature index for campaign analysis */ async getEnhancedCreatureIndex(): Promise<any[]> { this.validateFoundryState(); // Get the enhanced creature index (builds if needed) const enhancedCreatures = await this.persistentIndex.getEnhancedIndex(); return enhancedCreatures || []; } /** * Save roll button state to persistent storage */ async saveRollState(buttonId: string, userId: string): Promise<void> { // LEGACY METHOD - Redirecting to new ChatMessage.update() system try { // Use the new ChatMessage.update() approach instead const rollLabel = 'Legacy Roll'; // We don't have the label here, use generic await this.updateRollButtonMessage(buttonId, userId, rollLabel); } catch (error) { console.error(`[${MODULE_ID}] Legacy saveRollState redirect failed:`, error); // Don't throw - we don't want to break the old system completely } } /** * Get roll button state from persistent storage */ getRollState(buttonId: string): { rolled: boolean; rolledBy?: string; rolledByName?: string; timestamp?: number } | null { this.validateFoundryState(); try { const rollStates = game.settings.get(MODULE_ID, 'rollStates') || {}; return rollStates[buttonId] || null; } catch (error) { console.error(`[${MODULE_ID}] Error getting roll state:`, error); return null; } } /** * Save button ID to message ID mapping for ChatMessage updates */ saveRollButtonMessageId(buttonId: string, messageId: string): void { try { const buttonMessageMap = game.settings.get(MODULE_ID, 'buttonMessageMap') || {}; buttonMessageMap[buttonId] = messageId; game.settings.set(MODULE_ID, 'buttonMessageMap', buttonMessageMap); } catch (error) { console.error(`[${MODULE_ID}] Error saving button-message mapping:`, error); } } /** * Get message ID for a roll button */ getRollButtonMessageId(buttonId: string): string | null { try { const buttonMessageMap = game.settings.get(MODULE_ID, 'buttonMessageMap') || {}; return buttonMessageMap[buttonId] || null; } catch (error) { console.error(`[${MODULE_ID}] Error getting button-message mapping:`, error); return null; } } /** * Get roll button state from ChatMessage flags */ getRollStateFromMessage(chatMessage: any, buttonId: string): any { try { const rollButtons = chatMessage.getFlag(MODULE_ID, 'rollButtons'); return rollButtons?.[buttonId] || null; } catch (error) { console.error(`[${MODULE_ID}] Error getting roll state from message:`, error); return null; } } /** * Update the ChatMessage to replace button with rolled state */ async updateRollButtonMessage(buttonId: string, userId: string, rollLabel: string): Promise<void> { try { // Get the message ID for this button const messageId = this.getRollButtonMessageId(buttonId); if (!messageId) { throw new Error(`No message ID found for button ${buttonId}`); } // Get the chat message const chatMessage = game.messages?.get(messageId); if (!chatMessage) { throw new Error(`ChatMessage ${messageId} not found`); } const rolledByName = game.users?.get(userId)?.name || 'Unknown'; const timestamp = new Date().toLocaleString(); // Check permissions before attempting update const canUpdate = chatMessage.canUserModify(game.user, 'update'); if (!canUpdate && !game.user?.isGM) { // Non-GM user cannot update message - request GM to do it via socket // Find online GM const onlineGM = game.users?.find(u => u.isGM && u.active); if (!onlineGM) { throw new Error('No Game Master is online to update the chat message'); } // Send socket request to GM if (game.socket) { game.socket.emit('module.foundry-mcp-bridge', { type: 'requestMessageUpdate', buttonId: buttonId, userId: userId, rollLabel: rollLabel, messageId: messageId, fromUserId: game.user.id, targetGM: onlineGM.id }); return; // Exit early - GM will handle the update } else { throw new Error('Socket not available for GM communication'); } } // Update the message flags to mark button as rolled const currentFlags = chatMessage.flags || {}; const moduleFlags = currentFlags[MODULE_ID] || {}; const rollButtons = moduleFlags.rollButtons || {}; rollButtons[buttonId] = { ...rollButtons[buttonId], rolled: true, rolledBy: userId, rolledByName: rolledByName, timestamp: Date.now() }; // Create the rolled state HTML const rolledHtml = ` <div class="mcp-roll-request" style="margin: 10px 0; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background: #f9f9f9;"> <p><strong>Roll Request:</strong> ${rollLabel}</p> <p><strong>Status:</strong> ✅ <strong>Completed by ${rolledByName}</strong> at ${timestamp}</p> </div> `; // Update the message content and flags await chatMessage.update({ content: rolledHtml, flags: { ...currentFlags, [MODULE_ID]: { ...moduleFlags, rollButtons: rollButtons } } }); } catch (error) { console.error(`[${MODULE_ID}] Error updating roll button message:`, error); console.error(`[${MODULE_ID}] Error stack:`, error instanceof Error ? error.stack : error); throw error; } } /** * Request GM to save roll state (for non-GM users who can't write to world settings) */ requestRollStateSave(buttonId: string, userId: string): void { // LEGACY METHOD - Redirecting to new ChatMessage.update() system try { // Use the new ChatMessage.update() approach instead const rollLabel = 'Legacy Roll'; // We don't have the label here, use generic this.updateRollButtonMessage(buttonId, userId, rollLabel) .then(() => { }) .catch((error) => { console.error(`[${MODULE_ID}] Legacy requestRollStateSave redirect failed:`, error); // If the new system fails, just log it - don't use the old socket system }); } catch (error) { console.error(`[${MODULE_ID}] Error in legacy requestRollStateSave redirect:`, error); } } /** * Broadcast roll state change to all connected users for real-time sync */ broadcastRollState(_buttonId: string, _rollState: any): void { // LEGACY METHOD - No longer needed with ChatMessage.update() system // ChatMessage.update() automatically broadcasts to all clients, so this method is no longer needed } /** * Clean up old roll states (optional maintenance) * Removes roll states older than 30 days to prevent storage bloat */ async cleanOldRollStates(): Promise<number> { this.validateFoundryState(); try { const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); const rollStates = game.settings.get(MODULE_ID, 'rollStates') || {}; let cleanedCount = 0; // Remove old roll states for (const [buttonId, rollState] of Object.entries(rollStates)) { if (rollState && typeof rollState === 'object' && 'timestamp' in rollState) { const timestamp = (rollState as any).timestamp; if (typeof timestamp === 'number' && timestamp < thirtyDaysAgo) { delete rollStates[buttonId]; cleanedCount++; } } } if (cleanedCount > 0) { await game.settings.set(MODULE_ID, 'rollStates', rollStates); } return cleanedCount; } catch (error) { console.error(`[${MODULE_ID}] Error cleaning old roll states:`, error); return 0; } } /** * Set actor ownership permission for a user */ async setActorOwnership(data: { actorId: string; userId: string; permission: number }): Promise<{ success: boolean; message: string; error?: string }> { this.validateFoundryState(); try { const actor = game.actors?.get(data.actorId); if (!actor) { return { success: false, error: `Actor not found: ${data.actorId}`, message: '' }; } const user = game.users?.get(data.userId); if (!user) { return { success: false, error: `User not found: ${data.userId}`, message: '' }; } // Get current ownership const currentOwnership = (actor as any).ownership || {}; const newOwnership = { ...currentOwnership }; // Set the new permission level newOwnership[data.userId] = data.permission; // Update the actor await actor.update({ ownership: newOwnership }); const permissionNames = { 0: 'NONE', 1: 'LIMITED', 2: 'OBSERVER', 3: 'OWNER' }; const permissionName = permissionNames[data.permission as keyof typeof permissionNames] || data.permission.toString(); return { success: true, message: `Set ${actor.name} ownership to ${permissionName} for ${user.name}`, }; } catch (error) { console.error(`[${MODULE_ID}] Error setting actor ownership:`, error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', message: '', }; } } /** * Get actor ownership information */ async getActorOwnership(data: { actorIdentifier?: string; playerIdentifier?: string }): Promise<any> { this.validateFoundryState(); try { const actors = data.actorIdentifier ? (data.actorIdentifier === 'all' ? Array.from(game.actors || []) : [this.findActorByIdentifier(data.actorIdentifier)].filter(Boolean)) : Array.from(game.actors || []); const users = data.playerIdentifier ? [game.users?.getName(data.playerIdentifier) || game.users?.get(data.playerIdentifier)].filter(Boolean) : Array.from(game.users || []); const ownershipInfo = []; const permissionNames = { 0: 'NONE', 1: 'LIMITED', 2: 'OBSERVER', 3: 'OWNER' }; for (const actor of actors) { const actorInfo: any = { id: actor.id, name: actor.name, type: actor.type, ownership: [], }; for (const user of users.filter(u => u && !u.isGM)) { const permission = actor.testUserPermission(user, 'OWNER') ? 3 : actor.testUserPermission(user, 'OBSERVER') ? 2 : actor.testUserPermission(user, 'LIMITED') ? 1 : 0; actorInfo.ownership.push({ userId: user!.id, userName: user!.name, permission: permissionNames[permission as keyof typeof permissionNames], numericPermission: permission, }); } ownershipInfo.push(actorInfo); } return ownershipInfo; } catch (error) { console.error(`[${MODULE_ID}] Error getting actor ownership:`, error); throw error; } } /** * Find actor by name or ID */ private findActorByIdentifier(identifier: string): any { return game.actors?.get(identifier) || game.actors?.getName(identifier) || Array.from(game.actors || []).find(a => a.name?.toLowerCase().includes(identifier.toLowerCase()) ); } /** * Get friendly NPCs from current scene */ async getFriendlyNPCs(): Promise<Array<{id: string, name: string}>> { this.validateFoundryState(); try { const scene = game.scenes?.find(s => s.active); if (!scene) { return []; } const friendlyTokens = scene.tokens.filter((token: any) => token.disposition === 1 // FRIENDLY disposition ); return friendlyTokens.map((token: any) => ({ id: token.actor?.id || token.id || '', name: token.name || token.actor?.name || 'Unknown', })).filter(t => t.id); } catch (error) { console.error(`[${MODULE_ID}] Error getting friendly NPCs:`, error); return []; } } /** * Get party characters (player-owned actors) */ async getPartyCharacters(): Promise<Array<{id: string, name: string}>> { this.validateFoundryState(); try { const partyCharacters = Array.from(game.actors || []).filter(actor => actor.hasPlayerOwner && actor.type === 'character' ); return partyCharacters.map(actor => ({ id: actor.id || '', name: actor.name || 'Unknown', })).filter(c => c.id); } catch (error) { console.error(`[${MODULE_ID}] Error getting party characters:`, error); return []; } } /** * Get connected players (excluding GM) */ async getConnectedPlayers(): Promise<Array<{id: string, name: string}>> { this.validateFoundryState(); try { const connectedPlayers = Array.from(game.users || []).filter(user => user.active && !user.isGM ); return connectedPlayers.map(user => ({ id: user.id || '', name: user.name || 'Unknown', })).filter(u => u.id); } catch (error) { console.error(`[${MODULE_ID}] Error getting connected players:`, error); return []; } } /** * Find players by identifier with partial matching */ async findPlayers(data: { identifier: string; allowPartialMatch?: boolean; includeCharacterOwners?: boolean }): Promise<Array<{id: string, name: string}>> { this.validateFoundryState(); try { const { identifier, allowPartialMatch = true, includeCharacterOwners = true } = data; const searchTerm = identifier.toLowerCase(); const players = []; // Direct user name matching for (const user of game.users || []) { if (user.isGM) continue; const userName = user.name?.toLowerCase() || ''; if (userName === searchTerm || (allowPartialMatch && userName.includes(searchTerm))) { players.push({ id: user.id || '', name: user.name || 'Unknown' }); } } // Character name matching (find owner of character) if (includeCharacterOwners && players.length === 0) { for (const actor of game.actors || []) { if (actor.type !== 'character') continue; const actorName = actor.name?.toLowerCase() || ''; if (actorName === searchTerm || (allowPartialMatch && actorName.includes(searchTerm))) { // Find the player owner of this character const owner = game.users?.find(user => actor.testUserPermission(user, 'OWNER') && !user.isGM ); if (owner && !players.some(p => p.id === owner.id)) { players.push({ id: owner.id || '', name: owner.name || 'Unknown' }); } } } } return players.filter(p => p.id); } catch (error) { console.error(`[${MODULE_ID}] Error finding players:`, error); return []; } } /** * Find single actor by identifier */ async findActor(data: { identifier: string }): Promise<{id: string, name: string} | null> { this.validateFoundryState(); try { const actor = this.findActorByIdentifier(data.identifier); return actor ? { id: actor.id, name: actor.name } : null; } catch (error) { console.error(`[${MODULE_ID}] Error finding actor:`, error); return null; } } // Private storage for tracking roll button processing states private rollButtonProcessingStates: Map<string, boolean> = new Map(); /** * Check if a roll button is currently being processed */ private isRollButtonProcessing(buttonId: string): boolean { return this.rollButtonProcessingStates.get(buttonId) || false; } /** * Set roll button processing state */ private setRollButtonProcessing(buttonId: string, processing: boolean): void { if (processing) { this.rollButtonProcessingStates.set(buttonId, true); } else { this.rollButtonProcessingStates.delete(buttonId); } } /** * Get or create a folder for organizing MCP-generated content */ private async getOrCreateFolder(folderName: string, type: 'Actor' | 'JournalEntry'): Promise<string | null> { try { // Look for existing folder const existingFolder = game.folders?.find((f: any) => f.name === folderName && f.type === type ); if (existingFolder) { return existingFolder.id; } // Create appropriate descriptions let description = ''; if (type === 'Actor') { if (folderName === 'Foundry MCP Creatures') { description = 'Creatures and monsters created via Foundry MCP Bridge'; } else { description = `NPCs and creatures related to: ${folderName}`; } } else { description = `Quest and content for: ${folderName}`; } // Create new folder const folderData = { name: folderName, type: type, description: description, color: type === 'Actor' ? '#4a90e2' : '#f39c12', // Blue for actors, orange for journals sort: 0, parent: null, flags: { 'foundry-mcp-bridge': { mcpGenerated: true, createdAt: new Date().toISOString(), questContext: type === 'JournalEntry' ? folderName : undefined } } }; const folder = await Folder.create(folderData); return folder?.id || null; } catch (error) { console.warn(`[${this.moduleId}] Failed to create folder "${folderName}":`, error); // Return null so items are created without folders rather than failing return null; } } /** * List all scenes with filtering options */ async listScenes(options: { filter?: string; include_active_only?: boolean } = {}): Promise<any[]> { this.validateFoundryState(); try { let scenes = game.scenes?.contents || []; // Filter by active only if requested if (options.include_active_only) { scenes = scenes.filter((scene: any) => scene.active); } // Filter by name if provided if (options.filter) { const filterLower = options.filter.toLowerCase(); scenes = scenes.filter((scene: any) => scene.name.toLowerCase().includes(filterLower) ); } // Map to consistent format return scenes.map((scene: any) => ({ id: scene.id, name: scene.name, active: scene.active, dimensions: { width: scene.dimensions?.width || (scene as any).width || 0, height: scene.dimensions?.height || (scene as any).height || 0 }, gridSize: scene.grid?.size || 100, background: scene.background?.src || scene.img || '', walls: scene.walls?.size || 0, tokens: scene.tokens?.size || 0, lighting: scene.lights?.size || 0, sounds: scene.sounds?.size || 0, navigation: scene.navigation || false })); } catch (error) { throw new Error(`Failed to list scenes: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Switch to a different scene */ async switchScene(options: { scene_identifier: string; optimize_view?: boolean }): Promise<any> { this.validateFoundryState(); try { // Find the target scene by ID or name const scenes = game.scenes?.contents || []; const targetScene = scenes.find((scene: any) => scene.id === options.scene_identifier || scene.name.toLowerCase() === options.scene_identifier.toLowerCase() ); if (!targetScene) { throw new Error(`Scene not found: "${options.scene_identifier}"`); } // Activate the scene await targetScene.activate(); // Optimize view if requested (default true) if (options.optimize_view !== false && typeof canvas !== 'undefined' && canvas?.scene) { const dimensions = targetScene.dimensions || { width: (targetScene as any).width || 0, height: (targetScene as any).height || 0 }; const width = (dimensions as any).width || 0; const height = (dimensions as any).height || 0; if (width && height) { // Center the view on the scene await canvas.pan({ x: width / 2, y: height / 2, scale: Math.min( (canvas as any).screenDimensions?.[0] / width || 1, (canvas as any).screenDimensions?.[1] / height || 1, 1 ) }); } } return { success: true, sceneId: targetScene.id, sceneName: targetScene.name, dimensions: { width: (targetScene.dimensions as any)?.width || (targetScene as any).width || 0, height: (targetScene.dimensions as any)?.height || (targetScene as any).height || 0 } }; } catch (error) { throw new Error(`Failed to switch scene: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Move a token to a new position */ async moveToken(tokenId: string, x: number, y: number, animate: boolean = false): Promise<any> { this.validateFoundryState(); // Use permission system const permissionCheck = permissionManager.checkWritePermission('modifyScene', { targetIds: [tokenId], }); if (!permissionCheck.allowed) { throw new Error(`${ERROR_MESSAGES.ACCESS_DENIED}: ${permissionCheck.reason}`); } const scene = (game.scenes as any).current; if (!scene) { throw new Error('No active scene found'); } const token = scene.tokens.get(tokenId); if (!token) { throw new Error(`Token ${tokenId} not found in current scene`); } try { await token.update({ x, y }, { animate }); this.auditLog('moveToken', { tokenId, x, y, animate }, 'success'); return { success: true, tokenId, newPosition: { x, y }, }; } catch (error) { this.auditLog('moveToken', { tokenId, x, y }, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Update token properties */ async updateToken(tokenId: string, updates: any): Promise<any> { this.validateFoundryState(); // Use permission system const permissionCheck = permissionManager.checkWritePermission('modifyScene', { targetIds: [tokenId], }); if (!permissionCheck.allowed) { throw new Error(`${ERROR_MESSAGES.ACCESS_DENIED}: ${permissionCheck.reason}`); } const scene = (game.scenes as any).current; if (!scene) { throw new Error('No active scene found'); } const token = scene.tokens.get(tokenId); if (!token) { throw new Error(`Token ${tokenId} not found in current scene`); } try { // Filter out undefined values const cleanUpdates = Object.fromEntries( Object.entries(updates).filter(([_, v]) => v !== undefined) ); await token.update(cleanUpdates); this.auditLog('updateToken', { tokenId, updates: cleanUpdates }, 'success'); return { success: true, tokenId, updated: true, }; } catch (error) { this.auditLog('updateToken', { tokenId, updates }, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Delete one or more tokens from the current scene */ async deleteTokens(tokenIds: string[]): Promise<any> { this.validateFoundryState(); // Use permission system const permissionCheck = permissionManager.checkWritePermission('modifyScene', { targetIds: tokenIds, }); if (!permissionCheck.allowed) { throw new Error(`${ERROR_MESSAGES.ACCESS_DENIED}: ${permissionCheck.reason}`); } const scene = (game.scenes as any).current; if (!scene) { throw new Error('No active scene found'); } const errors: string[] = []; const deletedIds: string[] = []; try { for (const tokenId of tokenIds) { try { const token = scene.tokens.get(tokenId); if (!token) { errors.push(`Token ${tokenId} not found`); continue; } await token.delete(); deletedIds.push(tokenId); } catch (error) { errors.push(`Failed to delete token ${tokenId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } this.auditLog('deleteTokens', { tokenIds, deletedCount: deletedIds.length }, 'success'); return { success: deletedIds.length > 0, deletedCount: deletedIds.length, tokenIds: deletedIds, errors: errors.length > 0 ? errors : undefined, }; } catch (error) { this.auditLog('deleteTokens', { tokenIds }, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Get detailed information about a specific token */ async getTokenDetails(tokenId: string): Promise<any> { this.validateFoundryState(); const scene = (game.scenes as any).current; if (!scene) { throw new Error('No active scene found'); } const token = scene.tokens.get(tokenId); if (!token) { throw new Error(`Token ${tokenId} not found in current scene`); } try { const tokenData: any = { id: token.id, name: token.name, x: token.x, y: token.y, width: token.width, height: token.height, rotation: token.rotation || 0, elevation: token.elevation || 0, lockRotation: token.lockRotation || false, scale: token.texture?.scaleX || 1, alpha: token.alpha || 1, hidden: token.hidden, disposition: token.disposition, img: token.texture?.src || '', actorId: token.actorId, actorLink: token.actorLink || false, }; // Include actor data if linked to an actor if (token.actorId) { const actor = game.actors.get(token.actorId); if (actor) { tokenData.actorData = { name: actor.name, type: actor.type, img: actor.img, }; // Include active conditions/effects const activeConditions: any[] = []; const statusEffects = (CONFIG as any).statusEffects as any[]; for (const effect of actor.effects) { // Check if this is a status effect const effectData: any = { id: effect.id, name: (effect as any).name || (effect as any).label, icon: (effect as any).icon, disabled: (effect as any).disabled, }; // Try to match with known status effects if ((effect as any).statuses) { for (const statusId of (effect as any).statuses) { const matchedEffect = statusEffects.find(se => se.id === statusId); if (matchedEffect) { effectData.statusId = statusId; effectData.isStatusEffect = true; break; } } } activeConditions.push(effectData); } tokenData.conditions = activeConditions; } } this.auditLog('getTokenDetails', { tokenId }, 'success'); return tokenData; } catch (error) { this.auditLog('getTokenDetails', { tokenId }, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Toggle a status effect/condition on a token */ async toggleTokenCondition(tokenId: string, conditionId: string, active?: boolean): Promise<any> { this.validateFoundryState(); // Use permission system const permissionCheck = permissionManager.checkWritePermission('modifyScene', { targetIds: [tokenId], }); if (!permissionCheck.allowed) { throw new Error(`${ERROR_MESSAGES.ACCESS_DENIED}: ${permissionCheck.reason}`); } const scene = (game.scenes as any).current; if (!scene) { throw new Error('No active scene found'); } const token = scene.tokens.get(tokenId); if (!token) { throw new Error(`Token ${tokenId} not found in current scene`); } try { // Get the actor associated with the token const actor = token.actor; if (!actor) { throw new Error(`No actor associated with token ${tokenId}`); } // Find the status effect by ID const statusEffects = (CONFIG as any).statusEffects as any[]; const effect = statusEffects.find(e => e.id === conditionId || e._id === conditionId); if (!effect) { throw new Error(`Status effect '${conditionId}' not found. Use get-available-conditions to see available effects.`); } // Check current state const hasEffect = actor.effects.some((e: any) => { return e.statuses?.has(conditionId) || e.flags?.core?.statusId === conditionId || (e.icon === effect.icon && e.name === effect.name); }); let newState: boolean; if (active !== undefined) { // Explicit state requested if (active && !hasEffect) { // Add the effect await actor.toggleStatusEffect(conditionId, { active: true }); newState = true; } else if (!active && hasEffect) { // Remove the effect await actor.toggleStatusEffect(conditionId, { active: false }); newState = false; } else { // Already in desired state newState = active; } } else { // Toggle current state await actor.toggleStatusEffect(conditionId); newState = !hasEffect; } this.auditLog('toggleTokenCondition', { tokenId, conditionId, active, newState }, 'success'); return { success: true, tokenId, conditionId, isActive: newState, conditionName: effect.name || effect.label || conditionId, }; } catch (error) { this.auditLog('toggleTokenCondition', { tokenId, conditionId, active }, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } /** * Get available status effects/conditions for the current game system */ async getAvailableConditions(): Promise<any> { this.validateFoundryState(); try { const statusEffects = (CONFIG as any).statusEffects as any[]; const conditions = statusEffects.map(effect => ({ id: effect.id || effect._id, name: effect.name || effect.label || effect.id, icon: effect.icon || effect.img, description: effect.description || '', })); const gameSystem = (game.system as any).id; this.auditLog('getAvailableConditions', {}, 'success'); return { success: true, conditions, gameSystem, }; } catch (error) { this.auditLog('getAvailableConditions', {}, 'failure', error instanceof Error ? error.message : 'Unknown error'); throw error; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adambdooley/foundry-vtt-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server