Skip to main content
Glama
campaign-management.ts20.5 kB
// Campaign Management Tools - Multipart Campaign System // Provides journal-based campaign creation, dashboard generation, and progress tracking import { z } from 'zod'; import { FoundryClient } from '../foundry-client.js'; import { ErrorHandler } from '../utils/error-handler.js'; import { Logger } from '../logger.js'; import { CampaignStructureSchema, CampaignPartSchema, CampaignTemplateSchema, CampaignPartTypeSchema } from '@foundry-mcp/shared'; import type { CampaignStructure, CampaignPart, CampaignTemplate } from '@foundry-mcp/shared'; export class CampaignManagementTools { private foundryClient: FoundryClient; private errorHandler: ErrorHandler; private logger: Logger; constructor(foundryClient: FoundryClient, logger: Logger) { this.foundryClient = foundryClient; this.logger = logger; this.errorHandler = new ErrorHandler(this.logger); } getToolDefinitions() { return [ { name: 'create-campaign-dashboard', description: 'Create a comprehensive campaign dashboard journal with navigation, progress tracking, and part management', inputSchema: { type: 'object', properties: { campaignTitle: { type: 'string', description: 'Title of the campaign (e.g., "The Whisperstone Conspiracy")' }, campaignDescription: { type: 'string', description: 'Brief description of the campaign theme and scope' }, template: { type: 'string', enum: ['five-part-adventure', 'dungeon-crawl', 'investigation', 'sandbox', 'custom'], description: 'Campaign structure template to use' }, customParts: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' }, type: { type: 'string', enum: ['main_part', 'sub_part', 'chapter', 'session', 'optional'] }, levelStart: { type: 'number', minimum: 1, maximum: 20 }, levelEnd: { type: 'number', minimum: 1, maximum: 20 }, subParts: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' } }, required: ['title', 'description'] } } }, required: ['title', 'description', 'type', 'levelStart', 'levelEnd'] }, description: 'Custom campaign parts when template is "custom"' }, defaultQuestGiver: { type: 'string', description: 'Default NPC name for quest giving (optional)' }, defaultLocation: { type: 'string', description: 'Default campaign location/setting (optional)' } }, required: ['campaignTitle', 'campaignDescription', 'template'] } } ]; } /** * Handle create campaign dashboard request */ async handleCreateCampaignDashboard(args: any): Promise<any> { try { const requestSchema = z.object({ campaignTitle: z.string().min(1, 'Campaign title is required'), campaignDescription: z.string().min(1, 'Campaign description is required'), template: z.enum(['five-part-adventure', 'dungeon-crawl', 'investigation', 'sandbox', 'custom']), customParts: z.array(z.object({ title: z.string().min(1), description: z.string().min(1), type: CampaignPartTypeSchema, levelStart: z.number().min(1).max(20), levelEnd: z.number().min(1).max(20), subParts: z.array(z.object({ title: z.string().min(1), description: z.string().min(1) })).optional() })).optional(), defaultQuestGiver: z.string().optional(), defaultLocation: z.string().optional() }); const request = requestSchema.parse(args); // Generate campaign structure based on template const campaignStructure = this.generateCampaignStructure(request); // Create dashboard journal entry const dashboardContent = this.generateDashboardHTML(campaignStructure); // Create the journal entry in Foundry (organized in campaign-specific folder) const journalResult = await this.foundryClient.query('foundry-mcp-bridge.createJournalEntry', { name: `${request.campaignTitle} - Campaign Dashboard`, content: dashboardContent, folderName: request.campaignTitle // Organize in campaign-named folder }); if (!journalResult || journalResult.error) { throw new Error(journalResult?.error || 'Failed to create campaign dashboard journal'); } // Update campaign structure with dashboard journal ID campaignStructure.dashboardJournalId = journalResult.id; // Store campaign structure (would typically go to a world flag or journal) await this.storeCampaignStructure(campaignStructure); return { success: true, campaignId: campaignStructure.id, dashboardJournalId: journalResult.id, dashboardName: journalResult.name, campaignStructure: campaignStructure, message: `Campaign dashboard "${request.campaignTitle}" created successfully with ${campaignStructure.parts.length} parts` }; } catch (error) { return this.errorHandler.handleToolError(error, 'create-campaign-dashboard', 'campaign dashboard creation'); } } /** * Generate campaign structure from template */ private generateCampaignStructure(request: any): CampaignStructure { const campaignId = `campaign-${Date.now()}`; const timestamp = Date.now(); let parts: CampaignPart[] = []; if (request.template === 'custom' && request.customParts) { parts = request.customParts.map((part: any, index: number) => ({ id: `${campaignId}-part-${index + 1}`, title: part.title, description: part.description, type: part.type, status: 'not_started' as const, dependencies: index > 0 ? [`${campaignId}-part-${index}`] : [], subParts: part.subParts?.map((subPart: any, subIndex: number) => ({ id: `${campaignId}-part-${index + 1}-sub-${subIndex + 1}`, title: subPart.title, description: subPart.description, type: 'sub_part' as const, status: 'not_started' as const, createdAt: timestamp })), ...(request.defaultQuestGiver && { questGiver: { id: `npc-${request.defaultQuestGiver.toLowerCase().replace(/\s+/g, '-')}`, name: request.defaultQuestGiver } }), levelRecommendation: { start: part.levelStart, end: part.levelEnd }, gmNotes: '', playerContent: '', scaling: { adjustForPartySize: true, adjustForLevel: true, difficultyModifier: 0 }, createdAt: timestamp })); } else { parts = this.getTemplateParts(request.template, campaignId, timestamp, request.defaultQuestGiver); } return { id: campaignId, title: request.campaignTitle, description: request.campaignDescription, parts, metadata: { ...(request.defaultQuestGiver && { defaultQuestGiver: { id: `npc-${request.defaultQuestGiver.toLowerCase().replace(/\s+/g, '-')}`, name: request.defaultQuestGiver } }), ...(request.defaultLocation && { defaultLocation: request.defaultLocation }), ...(request.template && { theme: request.template }), tags: [request.template] }, createdAt: timestamp, updatedAt: timestamp }; } /** * Get template-based campaign parts */ private getTemplateParts(template: string, campaignId: string, timestamp: number, defaultQuestGiver?: string): CampaignPart[] { const templates: Record<string, any[]> = { 'five-part-adventure': [ { title: 'Hook & Introduction', description: 'Draw the party into the adventure with compelling hooks and initial encounters', levels: [1, 2] }, { title: 'Investigation & Clues', description: 'Gather information, explore leads, and uncover the scope of the threat', levels: [2, 4] }, { title: 'Midpoint Revelation', description: 'Major discovery or plot twist that changes the stakes and direction', levels: [4, 6] }, { title: 'Climactic Confrontation', description: 'Face the primary antagonist or overcome the central challenge', levels: [6, 8] }, { title: 'Resolution & Rewards', description: 'Wrap up loose ends, distribute rewards, and set up future adventures', levels: [8, 9] } ], 'dungeon-crawl': [ { title: 'Approach & Entry', description: 'Navigate to the dungeon and overcome entrance challenges', levels: [1, 2] }, { title: 'Upper Levels', description: 'Explore the first floors, encounter guardians and traps', levels: [2, 4], subParts: [{ title: 'Rooms 1-3', description: 'Initial chambers and encounters' }, { title: 'Rooms 4-6', description: 'Mid-level challenges and treasures' }] }, { title: 'Lower Levels', description: 'Delve deeper into more dangerous areas', levels: [4, 6], subParts: [{ title: 'Rooms 7-9', description: 'Advanced traps and stronger enemies' }, { title: 'Rooms 10-12', description: 'Elite encounters and hidden secrets' }] }, { title: 'Final Boss & Treasure', description: 'Confront the dungeon\'s master and claim the ultimate prize', levels: [6, 8] } ], 'investigation': [ { title: 'Crime Scene', description: 'Initial investigation of the incident and evidence gathering', levels: [1, 2] }, { title: 'Witness Interviews', description: 'Question involved parties and gather testimonies', levels: [2, 3], subParts: [{ title: 'Primary Witnesses', description: 'Key individuals with direct knowledge' }, { title: 'Secondary Sources', description: 'Additional contacts and informants' }] }, { title: 'Following Leads', description: 'Pursue clues to multiple locations and uncover connections', levels: [3, 5], subParts: [{ title: 'Location A', description: 'First lead destination' }, { title: 'Location B', description: 'Second investigation site' }, { title: 'Location C', description: 'Final clue location' }] }, { title: 'Confrontation', description: 'Face the culprit with evidence and resolve the case', levels: [5, 6] }, { title: 'Resolution', description: 'Tie up loose ends and deliver justice or closure', levels: [6, 7] } ], 'sandbox': [ { title: 'World Introduction', description: 'Establish the setting, key NPCs, and available opportunities', levels: [1, 3] }, { title: 'Exploration Phase', description: 'Players choose their path and explore available content', levels: [3, 8] }, { title: 'Consequences & Reactions', description: 'World responds to player actions with new challenges', levels: [8, 12] }, { title: 'Player-Driven Climax', description: 'Major storyline chosen and pursued by players', levels: [12, 15] } ] }; const templateParts = templates[template] || templates['five-part-adventure']; return templateParts.map((part, index) => ({ id: `${campaignId}-part-${index + 1}`, title: part.title, description: part.description, type: 'main_part' as const, status: 'not_started' as const, dependencies: index > 0 ? [`${campaignId}-part-${index}`] : [], subParts: part.subParts?.map((subPart: any, subIndex: number) => ({ id: `${campaignId}-part-${index + 1}-sub-${subIndex + 1}`, title: subPart.title, description: subPart.description, type: 'sub_part' as const, status: 'not_started' as const, createdAt: timestamp })), ...(defaultQuestGiver && { questGiver: { id: `npc-${defaultQuestGiver.toLowerCase().replace(/\s+/g, '-')}`, name: defaultQuestGiver } }), levelRecommendation: { start: part.levels[0], end: part.levels[1] }, gmNotes: '', playerContent: '', scaling: { adjustForPartySize: true, adjustForLevel: true, difficultyModifier: 0 }, createdAt: timestamp })); } /** * Generate HTML content for campaign dashboard journal */ private generateDashboardHTML(campaign: CampaignStructure): string { const progress = this.calculateProgress(campaign); const currentPart = campaign.parts.find((part: CampaignPart) => part.status === 'in_progress'); return `<style> .campaign-status-toggle { cursor: pointer; border-radius: 1em; padding: 0.3em 0.6em; margin: 0 0.2em; font-size: 0.9em; font-weight: bold; color: #fff; background: #777; border: 1px solid #555; transition: all 0.2s ease; display: inline-block; user-select: none; } .campaign-status-toggle:hover { transform: scale(1.05); box-shadow: 0 2px 4px rgba(0,0,0,0.3); } .campaign-status-toggle.not-started { background: #6c757d; border-color: #495057; } .campaign-status-toggle.in-progress { background: #007bff; border-color: #0056b3; } .campaign-status-toggle.completed { background: #28a745; border-color: #1e7e34; } .campaign-status-toggle.skipped { background: #ffc107; border-color: #e0a800; color: #212529; } .campaign-part { margin-bottom: 1.5em; padding: 1em; border-left: 4px solid #ddd; } .campaign-part.in-progress { border-left-color: #007bff; background: rgba(0, 123, 255, 0.05); } .campaign-part.completed { border-left-color: #28a745; background: rgba(40, 167, 69, 0.05); } </style> <div class="campaign-dashboard spaced"> <h1>${campaign.title}</h1> <div class="campaign-overview readaloud"> <p><strong>Campaign Progress:</strong> ${progress.completed} of ${progress.total} parts completed (${progress.percentage}%)</p> <p><strong>Current Focus:</strong> ${currentPart ? currentPart.title : 'Ready to begin'}</p> ${campaign.metadata.defaultLocation ? `<p><strong>Primary Setting:</strong> ${campaign.metadata.defaultLocation}</p>` : ''} ${campaign.metadata.defaultQuestGiver ? `<p><strong>Primary Quest Giver:</strong> ${campaign.metadata.defaultQuestGiver.name}</p>` : ''} </div> <h2>Campaign Parts</h2> <p><em>Click status indicators to update progress. Changes are saved automatically.</em></p> ${campaign.parts.map((part: CampaignPart, index: number) => this.generatePartHTML(part, index + 1, campaign)).join('\n ')} <div class="campaign-notes gmnote"> <h3>GM Notes</h3> <p><em>Campaign created: ${new Date(campaign.createdAt).toLocaleDateString()}</em></p> <p><em>Last updated: ${new Date(campaign.updatedAt).toLocaleDateString()}</em></p> ${campaign.description ? `<p><strong>Description:</strong> ${campaign.description}</p>` : ''} <p><em><strong>Campaign ID:</strong> ${campaign.id}</em></p> </div> </div>`; } /** * Generate HTML for individual campaign part */ private generatePartHTML(part: CampaignPart, partNumber: number, campaign: CampaignStructure): string { const statusIcon = this.getStatusIcon(part.status); const isLocked = this.isPartLocked(part, campaign); const lockIcon = isLocked ? '[LOCKED] ' : ''; // Generate simple status display const statusTracker = this.generateStatusTracker(part, campaign.id); let html = `<div class="campaign-part ${part.status} spaced"> <h3>${lockIcon}Part ${partNumber}: ${part.title}</h3> <p><strong>Status:</strong> ${statusTracker}</p> <p><strong>Levels:</strong> ${part.levelRecommendation.start}-${part.levelRecommendation.end}</p>`; if (part.journalId) { html += `\n <p><strong>@JournalEntry[${part.journalId}]{📖 View Details}</strong></p>`; } html += `\n <p>${part.description}</p>`; // Add dependencies info if locked if (isLocked && part.dependencies.length > 0) { const depNames = part.dependencies.map((depId: string) => { const depPart = campaign.parts.find((p: CampaignPart) => p.id === depId); return depPart ? depPart.title : depId; }).join(', '); html += `\n <p class="dependencies"><small><em>Requires completion of:</em> ${depNames}</small></p>`; } // Add sub-parts if they exist if (part.subParts && part.subParts.length > 0) { html += `\n <div class="sub-parts">`; html += `\n <h4>Sub-Parts:</h4>`; part.subParts.forEach((subPart: any, subIndex: number) => { const subStatusTracker = this.generateStatusTracker(subPart, campaign.id); html += `\n <p><strong>${partNumber}.${subIndex + 1}: ${subPart.title}</strong> - Status: ${subStatusTracker}</p>`; if (subPart.journalId) { html += `\n <p><strong>@JournalEntry[${subPart.journalId}]{📖 View Details}</strong></p>`; } html += `\n <hr style="margin: 10px 0; border: 1px solid #ccc;">`; }); html += `\n </div>`; } html += `\n </div>`; return html; } /** * Get status icon for visual indication */ private getStatusIcon(status: string): string { const icons = { 'not_started': '⚪', 'in_progress': '🔄', 'completed': '✅', 'skipped': '⏭️' }; return icons[status as keyof typeof icons] || '❓'; } /** * Generate interactive status toggle element for Foundry hook system */ private generateStatusTracker(part: CampaignPart | any, campaignId: string): string { const statusIcon = this.getStatusIcon(part.status); const statusDisplay = this.formatStatus(part.status); const statusClass = part.status.replace('_', '-'); // Convert to CSS class format // Interactive span that will be handled by Foundry hook system return `<span class="campaign-status-toggle ${statusClass}" data-campaign-id="${campaignId}" data-part-id="${part.id}" title="Click to change status: ${statusDisplay}"> ${statusIcon} ${statusDisplay} </span>`; } /** * Format status for display */ private formatStatus(status: string): string { return status.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()); } /** * Check if part is locked by dependencies */ private isPartLocked(part: CampaignPart, campaign: CampaignStructure): boolean { if (part.dependencies.length === 0) return false; return part.dependencies.some((depId: string) => { const depPart = campaign.parts.find((p: CampaignPart) => p.id === depId); return !depPart || depPart.status !== 'completed'; }); } /** * Calculate overall campaign progress */ private calculateProgress(campaign: CampaignStructure) { let total = 0; let completed = 0; campaign.parts.forEach((part: any) => { if (part.subParts && part.subParts.length > 0) { total += part.subParts.length; completed += part.subParts.filter((sp: any) => sp.status === 'completed').length; } else { total += 1; if (part.status === 'completed') completed += 1; } }); const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; return { total, completed, percentage }; } /** * Store campaign structure (simplified for create-only workflow) */ private async storeCampaignStructure(campaign: CampaignStructure): Promise<void> { try { this.logger.info(`Campaign structure created: ${campaign.id} (GMs will track progress manually)`); // Note: Campaign structure is stored in the dashboard journal itself // GMs will manually edit the journal to track progress } catch (error) { this.logger.error(`Error with campaign ${campaign.id}:`, 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