Skip to main content
Glama
poi-tools.ts35.4 kB
/** * POI & Map Visualization Tools * * MCP tools for: * - POI management (create, discover, link) * - Map visualization data export * - NodeNetwork CRUD operations * - Room discovery mechanics * * @module server/poi-tools */ import { z } from 'zod'; import { getDb } from '../storage/index.js'; import { POIRepository } from '../storage/repos/poi.repo.js'; import { SpatialRepository } from '../storage/repos/spatial.repo.js'; import { StructureRepository } from '../storage/repos/structure.repo.js'; import { CharacterRepository } from '../storage/repos/character.repo.js'; import { WorldRepository } from '../storage/repos/world.repo.js'; import { RegionRepository } from '../storage/repos/region.repo.js'; import { POI, POICategory, MapLayer, getIconForStructureType, getCategoryForStructureType } from '../schema/poi.js'; import { NodeNetwork, Exit } from '../schema/spatial.js'; import { SessionContext } from './types.js'; // ============================================================ // TOOL DEFINITIONS // ============================================================ export const POITools = { // POI Management CREATE_POI: { name: 'create_poi', description: 'Create a Point of Interest on the world map. Can be linked to structures and room networks.', inputSchema: z.object({ worldId: z.string().describe('World ID'), x: z.number().int().min(0).describe('X coordinate on world map'), y: z.number().int().min(0).describe('Y coordinate on world map'), name: z.string().min(1).max(100).describe('POI name'), description: z.string().max(500).optional().describe('Brief description for map tooltip'), category: z.enum(['settlement', 'fortification', 'dungeon', 'landmark', 'religious', 'commercial', 'natural', 'hidden']) .describe('POI category'), icon: z.enum(['city', 'town', 'village', 'castle', 'fort', 'tower', 'dungeon', 'cave', 'ruins', 'temple', 'shrine', 'inn', 'market', 'mine', 'farm', 'camp', 'portal', 'monument', 'tree', 'mountain', 'lake', 'waterfall', 'bridge', 'crossroads', 'unknown']) .describe('Map icon'), discoveryState: z.enum(['unknown', 'rumored', 'discovered', 'explored', 'mapped']).default('unknown'), discoveryDC: z.number().int().min(0).max(30).optional() .describe('Perception DC to discover if hidden'), population: z.number().int().min(0).default(0), level: z.number().int().min(1).max(20).optional() .describe('Suggested level for dungeons'), tags: z.array(z.string()).default([]) }) }, GET_POI: { name: 'get_poi', description: 'Get a POI by ID or coordinates', inputSchema: z.object({ poiId: z.string().uuid().optional(), worldId: z.string().optional(), x: z.number().int().optional(), y: z.number().int().optional() }) }, DISCOVER_POI: { name: 'discover_poi', description: 'Mark a POI as discovered by a character. Rolls perception if discovery DC is set.', inputSchema: z.object({ poiId: z.string().uuid().describe('POI to discover'), characterId: z.string().uuid().describe('Character discovering the POI'), autoSuccess: z.boolean().default(false) .describe('If true, skip perception check') }) }, LINK_POI_TO_NETWORK: { name: 'link_poi_to_network', description: 'Link a POI to a NodeNetwork (room graph) for navigation', inputSchema: z.object({ poiId: z.string().uuid(), networkId: z.string().uuid(), entranceRoomId: z.string().uuid().optional() .describe('Entry room when visiting this POI') }) }, SYNC_STRUCTURES_TO_POIS: { name: 'sync_structures_to_pois', description: 'Create POIs from all world structures that don\'t have POI entries yet', inputSchema: z.object({ worldId: z.string().describe('World to sync') }) }, // Map Visualization GET_MAP_VISUALIZATION: { name: 'get_map_visualization', description: 'Get complete map data for frontend rendering including terrain, regions, and POIs', inputSchema: z.object({ worldId: z.string(), characterId: z.string().uuid().optional() .describe('If provided, filters POIs by discovery and shows player position'), includeHidden: z.boolean().default(false) .describe('If true, include hidden POIs (for DM view)') }) }, GET_POI_LAYERS: { name: 'get_poi_layers', description: 'Get POIs organized into layers by category for map rendering', inputSchema: z.object({ worldId: z.string(), characterId: z.string().uuid().optional() .describe('If provided, only show discovered POIs'), categories: z.array(z.enum(['settlement', 'fortification', 'dungeon', 'landmark', 'religious', 'commercial', 'natural', 'hidden'])) .optional() .describe('Filter to specific categories') }) }, // NodeNetwork Management CREATE_NETWORK: { name: 'create_node_network', description: 'Create a NodeNetwork (collection of rooms) at a world map location', inputSchema: z.object({ worldId: z.string(), name: z.string().min(1).max(100), type: z.enum(['cluster', 'linear']).describe('cluster=town/dungeon, linear=road'), centerX: z.number().int().min(0).describe('World map X coordinate'), centerY: z.number().int().min(0).describe('World map Y coordinate'), boundingBox: z.object({ minX: z.number().int(), maxX: z.number().int(), minY: z.number().int(), maxY: z.number().int() }).optional().describe('For large networks spanning multiple tiles') }) }, GET_NETWORK: { name: 'get_node_network', description: 'Get a NodeNetwork with all its rooms', inputSchema: z.object({ networkId: z.string().uuid() }) }, LIST_NETWORKS: { name: 'list_node_networks', description: 'List all NodeNetworks in a world or region', inputSchema: z.object({ worldId: z.string(), minX: z.number().int().optional(), maxX: z.number().int().optional(), minY: z.number().int().optional(), maxY: z.number().int().optional() }) }, // Room Discovery EXPLORE_ROOM: { name: 'explore_room', description: 'Character explores a room, potentially discovering hidden exits and secrets', inputSchema: z.object({ characterId: z.string().uuid(), roomId: z.string().uuid(), searchType: z.enum(['quick', 'thorough']).default('quick') .describe('quick=passive perception, thorough=active investigation (takes time)') }) }, GET_ROOM_GRAPH: { name: 'get_room_graph', description: 'Get the complete room graph for a network, showing connections', inputSchema: z.object({ networkId: z.string().uuid(), characterId: z.string().uuid().optional() .describe('If provided, only show discovered rooms/exits') }) }, LINK_ROOMS: { name: 'link_rooms', description: 'Create bidirectional exits between two rooms', inputSchema: z.object({ room1Id: z.string().uuid(), room2Id: z.string().uuid(), direction1to2: z.enum(['north', 'south', 'east', 'west', 'up', 'down', 'northeast', 'northwest', 'southeast', 'southwest']), exitType: z.enum(['OPEN', 'LOCKED', 'HIDDEN']).default('OPEN'), dc: z.number().int().min(5).max(30).optional(), description1to2: z.string().optional(), description2to1: z.string().optional(), travelTime: z.number().int().min(0).optional() }) } } as const; // ============================================================ // HELPERS // ============================================================ function getRepos() { const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db'); return { poi: new POIRepository(db), spatial: new SpatialRepository(db), structure: new StructureRepository(db), character: new CharacterRepository(db), world: new WorldRepository(db), region: new RegionRepository(db) }; } function rollD20(): number { return Math.floor(Math.random() * 20) + 1; } function getOppositeDirection(dir: string): string { const opposites: Record<string, string> = { 'north': 'south', 'south': 'north', 'east': 'west', 'west': 'east', 'up': 'down', 'down': 'up', 'northeast': 'southwest', 'southwest': 'northeast', 'northwest': 'southeast', 'southeast': 'northwest' }; return opposites[dir] || 'south'; } // ============================================================ // HANDLERS // ============================================================ export async function handleCreatePOI(args: unknown, _ctx: SessionContext) { const parsed = POITools.CREATE_POI.inputSchema.parse(args); const { poi: poiRepo, region: regionRepo } = getRepos(); // Check if POI already exists at these coordinates const existing = poiRepo.findByCoordinates(parsed.worldId, parsed.x, parsed.y); if (existing) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: `POI already exists at (${parsed.x}, ${parsed.y}): ${existing.name}` }, null, 2) }] }; } // Find region if any const regions = regionRepo.findByWorldId(parsed.worldId); const region = regions.find(r => Math.abs(r.centerX - parsed.x) < 20 && Math.abs(r.centerY - parsed.y) < 20 ); const newPOI: POI = { id: crypto.randomUUID(), worldId: parsed.worldId, regionId: region?.id, x: parsed.x, y: parsed.y, name: parsed.name, description: parsed.description, category: parsed.category, icon: parsed.icon, discoveryState: parsed.discoveryState, discoveredBy: [], discoveryDC: parsed.discoveryDC, childPOIIds: [], population: parsed.population, level: parsed.level, tags: parsed.tags, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; poiRepo.create(newPOI); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, poi: { id: newPOI.id, name: newPOI.name, x: newPOI.x, y: newPOI.y, category: newPOI.category, icon: newPOI.icon, discoveryState: newPOI.discoveryState } }, null, 2) }] }; } export async function handleGetPOI(args: unknown, _ctx: SessionContext) { const parsed = POITools.GET_POI.inputSchema.parse(args); const { poi: poiRepo } = getRepos(); let poi: POI | null = null; if (parsed.poiId) { poi = poiRepo.findById(parsed.poiId); } else if (parsed.worldId && parsed.x !== undefined && parsed.y !== undefined) { poi = poiRepo.findByCoordinates(parsed.worldId, parsed.x, parsed.y); } if (!poi) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'POI not found' }, null, 2) }] }; } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, poi }, null, 2) }] }; } export async function handleDiscoverPOI(args: unknown, _ctx: SessionContext) { const parsed = POITools.DISCOVER_POI.inputSchema.parse(args); const { poi: poiRepo, character: charRepo } = getRepos(); const poi = poiRepo.findById(parsed.poiId); if (!poi) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'POI not found' }, null, 2) }] }; } const character = charRepo.findById(parsed.characterId); if (!character) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Character not found' }, null, 2) }] }; } // Check if already discovered if (poi.discoveredBy.includes(parsed.characterId)) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, alreadyDiscovered: true, poi: { id: poi.id, name: poi.name } }, null, 2) }] }; } // Roll perception if needed if (!parsed.autoSuccess && poi.discoveryDC) { const wisModifier = Math.floor((character.stats.wis - 10) / 2); const perceptionBonus = (character as any).perceptionBonus || 0; const roll = rollD20(); const total = roll + wisModifier + perceptionBonus; if (total < poi.discoveryDC) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, discovered: false, roll: { d20: roll, modifier: wisModifier + perceptionBonus, total, dc: poi.discoveryDC }, message: `${character.name} fails to notice anything unusual.` }, null, 2) }] }; } } // Discover the POI const updated = poiRepo.discoverPOI(parsed.poiId, parsed.characterId); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, discovered: true, poi: { id: updated?.id, name: updated?.name, description: updated?.description, category: updated?.category }, message: `${character.name} discovers ${poi.name}!` }, null, 2) }] }; } export async function handleLinkPOIToNetwork(args: unknown, _ctx: SessionContext) { const parsed = POITools.LINK_POI_TO_NETWORK.inputSchema.parse(args); const { poi: poiRepo, spatial: spatialRepo } = getRepos(); const poi = poiRepo.findById(parsed.poiId); if (!poi) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'POI not found' }, null, 2) }] }; } const network = spatialRepo.findNetworkById(parsed.networkId); if (!network) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Network not found' }, null, 2) }] }; } // Verify entrance room if specified if (parsed.entranceRoomId) { const room = spatialRepo.findById(parsed.entranceRoomId); if (!room) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Entrance room not found' }, null, 2) }] }; } } const updated = poiRepo.linkToNetwork(parsed.poiId, parsed.networkId, parsed.entranceRoomId); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, poi: { id: updated?.id, name: updated?.name, networkId: updated?.networkId, entranceRoomId: updated?.entranceRoomId } }, null, 2) }] }; } export async function handleSyncStructuresToPOIs(args: unknown, _ctx: SessionContext) { const parsed = POITools.SYNC_STRUCTURES_TO_POIS.inputSchema.parse(args); const { poi: poiRepo, structure: structureRepo } = getRepos(); const structures = structureRepo.findByWorldId(parsed.worldId); const existingPOIs = poiRepo.findByWorldId(parsed.worldId); const existingStructureIds = new Set(existingPOIs.map(p => p.structureId).filter(Boolean)); const created: Array<{ id: string; name: string; x: number; y: number }> = []; for (const structure of structures) { if (existingStructureIds.has(structure.id)) continue; // Check if POI exists at same coordinates const existingAtCoords = poiRepo.findByCoordinates(parsed.worldId, structure.x, structure.y); if (existingAtCoords) { // Link existing POI to structure poiRepo.linkToStructure(existingAtCoords.id, structure.id); continue; } // Create new POI from structure const newPOI: POI = { id: crypto.randomUUID(), worldId: parsed.worldId, regionId: structure.regionId, x: structure.x, y: structure.y, name: structure.name, category: getCategoryForStructureType(structure.type), icon: getIconForStructureType(structure.type), structureId: structure.id, discoveryState: 'discovered', // Structures are visible on map discoveredBy: [], childPOIIds: [], population: structure.population, tags: [structure.type], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; poiRepo.create(newPOI); created.push({ id: newPOI.id, name: newPOI.name, x: newPOI.x, y: newPOI.y }); } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, synced: created.length, created }, null, 2) }] }; } export async function handleGetMapVisualization(args: unknown, _ctx: SessionContext) { const parsed = POITools.GET_MAP_VISUALIZATION.inputSchema.parse(args); const { poi: poiRepo, world: worldRepo, region: regionRepo, character: charRepo } = getRepos(); const world = worldRepo.findById(parsed.worldId); if (!world) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'World not found' }, null, 2) }] }; } // Get POIs filtered by discovery state let pois = poiRepo.findByWorldId(parsed.worldId); if (parsed.characterId && !parsed.includeHidden) { // Filter to discovered POIs pois = pois.filter(p => p.discoveryState !== 'unknown' || p.discoveredBy.includes(parsed.characterId!) ); } // Get regions with colors const regions = regionRepo.findByWorldId(parsed.worldId); // Build POI layers by category const layerMap = new Map<POICategory, MapLayer>(); for (const poi of pois) { if (!layerMap.has(poi.category)) { layerMap.set(poi.category, { layerId: poi.category, layerName: poi.category.charAt(0).toUpperCase() + poi.category.slice(1) + 's', visible: true, opacity: 1, pois: [] }); } layerMap.get(poi.category)!.pois.push({ id: poi.id, x: poi.x, y: poi.y, name: poi.discoveryState === 'unknown' ? '?' : poi.name, icon: poi.discoveryState === 'unknown' ? 'unknown' : poi.icon, category: poi.category, discoveryState: poi.discoveryState, hasNetwork: !!poi.networkId, population: poi.population || undefined }); } // Get player position if character provided let playerPosition; if (parsed.characterId) { const character = charRepo.findById(parsed.characterId); if (character) { // Try to find character's current network/room position const currentRoomId = (character as any).currentRoomId; if (currentRoomId) { const { spatial: spatialRepo } = getRepos(); const room = spatialRepo.findById(currentRoomId); if (room?.networkId) { const network = spatialRepo.findNetworkById(room.networkId); if (network) { playerPosition = { characterId: parsed.characterId, x: network.centerX, y: network.centerY, roomId: currentRoomId }; } } } } } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, worldId: world.id, worldName: world.name, width: world.width, height: world.height, regions: regions.map(r => ({ id: r.id, name: r.name, type: r.type, centerX: r.centerX, centerY: r.centerY, color: r.color })), poiLayers: Array.from(layerMap.values()), playerPosition, totalPOIs: pois.length }, null, 2) }] }; } export async function handleGetPOILayers(args: unknown, _ctx: SessionContext) { const parsed = POITools.GET_POI_LAYERS.inputSchema.parse(args); const { poi: poiRepo } = getRepos(); let pois = poiRepo.findByWorldId(parsed.worldId); // Filter by discovery if (parsed.characterId) { pois = pois.filter(p => p.discoveryState !== 'unknown' || p.discoveredBy.includes(parsed.characterId!) ); } // Filter by categories if (parsed.categories) { const categorySet = new Set(parsed.categories); pois = pois.filter(p => categorySet.has(p.category)); } // Group by category const layers: MapLayer[] = []; const byCategory = new Map<POICategory, POI[]>(); for (const poi of pois) { if (!byCategory.has(poi.category)) { byCategory.set(poi.category, []); } byCategory.get(poi.category)!.push(poi); } for (const [category, categoryPOIs] of byCategory) { layers.push({ layerId: category, layerName: category.charAt(0).toUpperCase() + category.slice(1) + 's', visible: true, opacity: 1, pois: categoryPOIs.map(p => ({ id: p.id, x: p.x, y: p.y, name: p.discoveryState === 'unknown' ? '?' : p.name, icon: p.discoveryState === 'unknown' ? 'unknown' : p.icon, category: p.category, discoveryState: p.discoveryState, hasNetwork: !!p.networkId, population: p.population || undefined })) }); } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, layers }, null, 2) }] }; } export async function handleCreateNetwork(args: unknown, _ctx: SessionContext) { const parsed = POITools.CREATE_NETWORK.inputSchema.parse(args); const { spatial: spatialRepo } = getRepos(); const network: NodeNetwork = { id: crypto.randomUUID(), name: parsed.name, type: parsed.type, worldId: parsed.worldId, centerX: parsed.centerX, centerY: parsed.centerY, boundingBox: parsed.boundingBox, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; spatialRepo.createNetwork(network); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, network: { id: network.id, name: network.name, type: network.type, centerX: network.centerX, centerY: network.centerY } }, null, 2) }] }; } export async function handleGetNetwork(args: unknown, _ctx: SessionContext) { const parsed = POITools.GET_NETWORK.inputSchema.parse(args); const { spatial: spatialRepo, poi: poiRepo } = getRepos(); const network = spatialRepo.findNetworkById(parsed.networkId); if (!network) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Network not found' }, null, 2) }] }; } const rooms = spatialRepo.findRoomsByNetwork(parsed.networkId); const linkedPOI = poiRepo.findByNetworkId(parsed.networkId); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, network, linkedPOI: linkedPOI ? { id: linkedPOI.id, name: linkedPOI.name } : null, rooms: rooms.map(r => ({ id: r.id, name: r.name, biomeContext: r.biomeContext, localX: r.localX, localY: r.localY, exitCount: r.exits.length, entityCount: r.entityIds.length, visitedCount: r.visitedCount })), roomCount: rooms.length }, null, 2) }] }; } export async function handleListNetworks(args: unknown, _ctx: SessionContext) { const parsed = POITools.LIST_NETWORKS.inputSchema.parse(args); const { spatial: spatialRepo } = getRepos(); let networks: NodeNetwork[]; if (parsed.minX !== undefined && parsed.maxX !== undefined && parsed.minY !== undefined && parsed.maxY !== undefined) { networks = spatialRepo.findNetworksInBoundingBox( parsed.minX, parsed.maxX, parsed.minY, parsed.maxY ); } else { // Get all networks in world via bounding box query with world size networks = spatialRepo.findNetworksInBoundingBox(0, 10000, 0, 10000); networks = networks.filter(n => n.worldId === parsed.worldId); } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, networks: networks.map(n => ({ id: n.id, name: n.name, type: n.type, centerX: n.centerX, centerY: n.centerY, boundingBox: n.boundingBox })), count: networks.length }, null, 2) }] }; } export async function handleExploreRoom(args: unknown, _ctx: SessionContext) { const parsed = POITools.EXPLORE_ROOM.inputSchema.parse(args); const { spatial: spatialRepo, character: charRepo } = getRepos(); const character = charRepo.findById(parsed.characterId); if (!character) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Character not found' }, null, 2) }] }; } const room = spatialRepo.findById(parsed.roomId); if (!room) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Room not found' }, null, 2) }] }; } // Calculate perception modifier const wisModifier = Math.floor((character.stats.wis - 10) / 2); const perceptionBonus = (character as any).perceptionBonus || 0; const totalPerception = wisModifier + perceptionBonus; // For thorough search, add Investigation bonus and advantage const investigationBonus = parsed.searchType === 'thorough' ? Math.floor((character.stats.int - 10) / 2) : 0; const roll1 = rollD20(); const roll2 = parsed.searchType === 'thorough' ? rollD20() : roll1; const bestRoll = Math.max(roll1, roll2); const total = bestRoll + totalPerception + investigationBonus; // Check for hidden exits const hiddenExitsFound: Exit[] = []; for (const exit of room.exits) { if (exit.type === 'HIDDEN' && exit.dc) { if (total >= exit.dc) { hiddenExitsFound.push(exit); } } } // Check for locked exits (reveal their existence) const lockedExits = room.exits.filter(e => e.type === 'LOCKED'); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, roomId: room.id, roomName: room.name, searchType: parsed.searchType, roll: { d20: bestRoll, perceptionMod: totalPerception, investigationMod: investigationBonus, total }, discoveries: { hiddenExits: hiddenExitsFound.map(e => ({ direction: e.direction, description: e.description || `A hidden passage leads ${e.direction}`, dc: e.dc })), lockedExits: lockedExits.map(e => ({ direction: e.direction, description: e.description || `A locked passage leads ${e.direction}` })) }, timeTaken: parsed.searchType === 'thorough' ? '10 minutes' : 'instant' }, null, 2) }] }; } export async function handleGetRoomGraph(args: unknown, _ctx: SessionContext) { const parsed = POITools.GET_ROOM_GRAPH.inputSchema.parse(args); const { spatial: spatialRepo } = getRepos(); const network = spatialRepo.findNetworkById(parsed.networkId); if (!network) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Network not found' }, null, 2) }] }; } const rooms = spatialRepo.findRoomsByNetwork(parsed.networkId); // Build adjacency list const nodes = rooms.map(r => ({ id: r.id, name: r.name, biome: r.biomeContext, localX: r.localX, localY: r.localY, atmospherics: r.atmospherics, entityCount: r.entityIds.length, visitedCount: r.visitedCount })); const edges: Array<{ from: string; to: string; direction: string; type: string; bidirectional: boolean; }> = []; const edgeSet = new Set<string>(); for (const room of rooms) { for (const exit of room.exits) { // Skip hidden exits if filtering by character (and not discovered) // For now, show all exits in graph view const edgeKey = [room.id, exit.targetNodeId].sort().join('|'); if (!edgeSet.has(edgeKey)) { edgeSet.add(edgeKey); // Check if bidirectional const targetRoom = rooms.find(r => r.id === exit.targetNodeId); const isBidirectional = targetRoom?.exits.some(e => e.targetNodeId === room.id) || false; edges.push({ from: room.id, to: exit.targetNodeId, direction: exit.direction, type: exit.type, bidirectional: isBidirectional }); } } } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, network: { id: network.id, name: network.name, type: network.type }, graph: { nodes, edges }, stats: { nodeCount: nodes.length, edgeCount: edges.length } }, null, 2) }] }; } export async function handleLinkRooms(args: unknown, _ctx: SessionContext) { const parsed = POITools.LINK_ROOMS.inputSchema.parse(args); const { spatial: spatialRepo } = getRepos(); const room1 = spatialRepo.findById(parsed.room1Id); const room2 = spatialRepo.findById(parsed.room2Id); if (!room1) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Room 1 not found' }, null, 2) }] }; } if (!room2) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Room 2 not found' }, null, 2) }] }; } // Create exit from room1 to room2 const exit1to2: Exit = { direction: parsed.direction1to2 as Exit['direction'], targetNodeId: parsed.room2Id, type: parsed.exitType, dc: parsed.dc, description: parsed.description1to2, travelTime: parsed.travelTime }; // Create exit from room2 to room1 (opposite direction) const exit2to1: Exit = { direction: getOppositeDirection(parsed.direction1to2) as Exit['direction'], targetNodeId: parsed.room1Id, type: parsed.exitType, dc: parsed.dc, description: parsed.description2to1, travelTime: parsed.travelTime }; spatialRepo.addExit(parsed.room1Id, exit1to2); spatialRepo.addExit(parsed.room2Id, exit2to1); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, link: { room1: { id: room1.id, name: room1.name }, room2: { id: room2.id, name: room2.name }, direction: parsed.direction1to2, reverseDirection: getOppositeDirection(parsed.direction1to2), exitType: parsed.exitType } }, null, 2) }] }; }

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/Mnehmos/rpg-mcp'

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