Skip to main content
Glama
spatial-tools.ts12.1 kB
import { z } from 'zod'; import { getDb } from '../storage/index.js'; import { SpatialRepository } from '../storage/repos/spatial.repo.js'; import { CharacterRepository } from '../storage/repos/character.repo.js'; import { RoomNode, Exit } from '../schema/spatial.js'; import { SessionContext } from './types.js'; /** * PHASE-1: Spatial Graph System Tools * Tools for room/location persistence and spatial awareness */ // ============================================================ // TOOL DEFINITIONS // ============================================================ export const SpatialTools = { LOOK_AT_SURROUNDINGS: { name: 'look_at_surroundings', description: 'Get filtered room description accounting for darkness, fog, and perception. Returns exits and entities.', inputSchema: z.object({ observerId: z.string().uuid().describe('ID of the character observing their surroundings') }) }, GENERATE_ROOM_NODE: { name: 'generate_room_node', description: 'Create a persistent room with immutable description. Optionally links to previous room.', inputSchema: z.object({ name: z.string().min(1).max(100).describe('Name of the room (e.g., "The Dragon\'s Rest Tavern")'), baseDescription: z.string().min(10).max(2000).describe('Detailed description of the room (immutable once created)'), biomeContext: z.enum(['forest', 'mountain', 'urban', 'dungeon', 'coastal', 'cavern', 'divine', 'arcane']) .describe('Biome/environment type'), atmospherics: z.array(z.enum(['DARKNESS', 'FOG', 'ANTIMAGIC', 'SILENCE', 'BRIGHT', 'MAGICAL'])) .default([]) .describe('Environmental effects in this room'), previousNodeId: z.string().uuid().optional() .describe('If specified, creates an exit FROM this room TO the new room'), direction: z.enum(['north', 'south', 'east', 'west', 'up', 'down', 'northeast', 'northwest', 'southeast', 'southwest']) .optional() .describe('Direction of the exit from previousNodeId to the new room') }) }, GET_ROOM_EXITS: { name: 'get_room_exits', description: 'Get all exits from a room. Use look_at_surroundings for perception-filtered exits.', inputSchema: z.object({ roomId: z.string().uuid().describe('ID of the room to get exits for') }) }, MOVE_CHARACTER_TO_ROOM: { name: 'move_character_to_room', description: 'Move a character to a room and increment its visit count.', inputSchema: z.object({ characterId: z.string().uuid().describe('ID of the character to move'), roomId: z.string().uuid().describe('ID of the destination room') }) } } as const; // ============================================================ // TOOL HANDLERS // ============================================================ function getSpatialRepo(): SpatialRepository { const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db'); return new SpatialRepository(db); } function getCharacterRepo(): CharacterRepository { const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db'); return new CharacterRepository(db); } /** * Simulate a d20 roll */ function rollD20(): number { return Math.floor(Math.random() * 20) + 1; } /** * Calculate ability modifier from ability score (D&D 5e formula) */ function getModifier(abilityScore: number): number { return Math.floor((abilityScore - 10) / 2); } export async function handleLookAtSurroundings(args: unknown, _ctx: SessionContext) { const parsed = SpatialTools.LOOK_AT_SURROUNDINGS.inputSchema.parse(args); const spatialRepo = getSpatialRepo(); const characterRepo = getCharacterRepo(); const observer = characterRepo.findById(parsed.observerId); if (!observer) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Observer not found' }, null, 2) }] }; } // Get observer's current room (requires current_room_id field) const currentRoomId = (observer as unknown as { currentRoomId?: string }).currentRoomId; if (!currentRoomId) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Observer is not in any room. Use move_character_to_room first.' }, null, 2) }] }; } const currentRoom = spatialRepo.findById(currentRoomId); if (!currentRoom) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Observer\'s current room not found in database' }, null, 2) }] }; } // Check for darkness const isInDarkness = currentRoom.atmospherics.includes('DARKNESS'); const hasLight = observer.conditions?.some(c => c.name === 'HAS_LIGHT') || observer.conditions?.some(c => c.name === 'DARKVISION'); if (isInDarkness && !hasLight) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, description: "It's pitch black. You can't see anything.", exits: [], entities: [], atmospherics: currentRoom.atmospherics, roomId: currentRoom.id, roomName: currentRoom.name }, null, 2) }] }; } // Filter visible exits based on perception const perceptionModifier = getModifier(observer.stats.wis); const visibleExits = currentRoom.exits.filter(exit => { if (exit.type === 'OPEN') return true; if (exit.type === 'LOCKED') return false; // Locked exits are not visible if (exit.type === 'HIDDEN') { // Perception check: 1d20 + WIS modifier vs DC const perceptionRoll = rollD20() + perceptionModifier; return perceptionRoll >= (exit.dc || 15); } return false; }); // Format exit descriptions const formattedExits = visibleExits.map(e => ({ direction: e.direction, type: e.type, description: e.description || `A ${e.type.toLowerCase()} passage leads ${e.direction}`, targetNodeId: e.targetNodeId })); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, roomId: currentRoom.id, roomName: currentRoom.name, description: currentRoom.baseDescription, exits: formattedExits, entities: currentRoom.entityIds, atmospherics: currentRoom.atmospherics, biomeContext: currentRoom.biomeContext, visitedCount: currentRoom.visitedCount }, null, 2) }] }; } export async function handleGenerateRoomNode(args: unknown, _ctx: SessionContext) { const parsed = SpatialTools.GENERATE_ROOM_NODE.inputSchema.parse(args); const spatialRepo = getSpatialRepo(); const newRoom: RoomNode = { id: crypto.randomUUID(), name: parsed.name, baseDescription: parsed.baseDescription, biomeContext: parsed.biomeContext, atmospherics: parsed.atmospherics, exits: [], entityIds: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), visitedCount: 0, lastVisitedAt: undefined }; // Save to database spatialRepo.create(newRoom); // Link from previous room if specified if (parsed.previousNodeId && parsed.direction) { const prevRoom = spatialRepo.findById(parsed.previousNodeId); if (prevRoom) { const exit: Exit = { direction: parsed.direction, targetNodeId: newRoom.id, type: 'OPEN' }; spatialRepo.addExit(parsed.previousNodeId, exit); } } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, roomId: newRoom.id, name: newRoom.name, description: newRoom.baseDescription, biomeContext: newRoom.biomeContext, atmospherics: newRoom.atmospherics, linkedToPrevious: !!(parsed.previousNodeId && parsed.direction) }, null, 2) }] }; } export async function handleGetRoomExits(args: unknown, _ctx: SessionContext) { const parsed = SpatialTools.GET_ROOM_EXITS.inputSchema.parse(args); const spatialRepo = getSpatialRepo(); 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) }] }; } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, roomId: room.id, roomName: room.name, exits: room.exits.map(e => ({ direction: e.direction, targetNodeId: e.targetNodeId, type: e.type, dc: e.dc, description: e.description })) }, null, 2) }] }; } export async function handleMoveCharacterToRoom(args: unknown, _ctx: SessionContext) { const parsed = SpatialTools.MOVE_CHARACTER_TO_ROOM.inputSchema.parse(args); const spatialRepo = getSpatialRepo(); const characterRepo = getCharacterRepo(); const character = characterRepo.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) }] }; } // Remove character from old room if present const oldRoomId = (character as unknown as { currentRoomId?: string }).currentRoomId; if (oldRoomId) { try { spatialRepo.removeEntityFromRoom(oldRoomId, parsed.characterId); } catch (e) { // Old room may not exist anymore, that's okay } } // Update character's current room (using unknown to bypass TypeScript checks for current_room_id) const updatedChar = { ...(character as any), currentRoomId: parsed.roomId }; characterRepo.update(parsed.characterId, updatedChar as any); // Add character to new room spatialRepo.addEntityToRoom(parsed.roomId, parsed.characterId); // Increment visit count spatialRepo.incrementVisitCount(parsed.roomId); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, characterId: parsed.characterId, characterName: character.name, newRoomId: parsed.roomId, newRoomName: room.name, visitedCount: room.visitedCount + 1 }, 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