Skip to main content
Glama
spatial.ts6.07 kB
/** * Spatial Module - Distance & Position Calculations * Supports D&D 5e grid mechanics */ import { z } from 'zod'; import { PositionSchema } from '../types.js'; import { createBox, centerText, drawPath, BOX } from './ascii-art.js'; import { getEncounterParticipant } from './combat.js'; // ============================================================ // MEASURE DISTANCE // ============================================================ export const measureDistanceSchema = z.object({ encounterId: z.string().optional(), from: z.union([z.string(), PositionSchema]), to: z.union([z.string(), PositionSchema]), mode: z.enum(['euclidean', 'grid_5e', 'grid_alt']).default('euclidean'), includeElevation: z.boolean().default(true), }); export type MeasureDistanceInput = z.infer<typeof measureDistanceSchema>; export interface MeasureDistanceResult { distanceFeet: number; distanceSquares: number; markdown: string; fromName?: string; toName?: string; } /** * Calculate distance between two positions * Supports multiple distance calculation modes * When using character/creature IDs (strings), encounterId must be provided */ export function measureDistance(input: MeasureDistanceInput): MeasureDistanceResult { let fromPos: any = input.from; let toPos: any = input.to; let fromName: string | undefined; let toName: string | undefined; // Handle character/creature ID lookups if (typeof input.from === 'string') { if (!input.encounterId) { throw new Error('encounterId is required for character position lookup'); } const participant = getEncounterParticipant(input.encounterId, input.from); if (!participant) { throw new Error(`Character/creature not found in encounter: ${input.from}`); } fromPos = participant.position; fromName = participant.name; } if (typeof input.to === 'string') { if (!input.encounterId) { throw new Error('encounterId is required for character position lookup'); } const participant = getEncounterParticipant(input.encounterId, input.to); if (!participant) { throw new Error(`Character/creature not found in encounter: ${input.to}`); } toPos = participant.position; toName = participant.name; } // Calculate deltas const dx = Math.abs(toPos.x - fromPos.x); const dy = Math.abs(toPos.y - fromPos.y); const dz = input.includeElevation ? Math.abs(toPos.z - fromPos.z) : 0; let distanceSquares: number; let distanceFeet: number; switch (input.mode) { case 'euclidean': // True geometric distance: √(dx² + dy² + dz²) × 5 distanceSquares = Math.sqrt(dx * dx + dy * dy + dz * dz); distanceFeet = Math.round(distanceSquares * 5); distanceSquares = Math.round(distanceSquares); break; case 'grid_5e': // Simplified D&D: diagonals count as 5ft // Distance = max(dx, dy, dz) × 5 distanceSquares = Math.max(dx, dy, dz); distanceFeet = distanceSquares * 5; break; case 'grid_alt': // Variant rule: diagonals alternate 5ft/10ft // Calculate diagonal and straight movement const minDim = Math.min(dx, dy); const maxDim = Math.max(dx, dy); const straight = maxDim - minDim; // Diagonal movement: alternate 5/10/5/10 let diagonalCost = 0; for (let i = 0; i < minDim; i++) { diagonalCost += (i % 2 === 0) ? 5 : 10; } // Add straight movement and elevation distanceFeet = diagonalCost + (straight * 5) + (dz * 5); distanceSquares = Math.max(dx, dy, dz); break; default: throw new Error(`Unknown distance mode: ${input.mode}`); } // Format markdown output const markdown = formatDistanceResult({ from: fromPos, to: toPos, fromName, toName, mode: input.mode, distanceFeet, distanceSquares, includeElevation: input.includeElevation, dx, dy, dz, }); return { distanceFeet, distanceSquares, markdown, fromName, toName, }; } /** * Format distance result as ASCII diagram */ function formatDistanceResult(params: { from: { x: number; y: number; z: number }; to: { x: number; y: number; z: number }; fromName?: string; toName?: string; mode: string; distanceFeet: number; distanceSquares: number; includeElevation: boolean; dx: number; dy: number; dz: number; }): string { const content: string[] = []; const box = BOX.LIGHT; // Title const modeNames = { 'euclidean': 'Euclidean (True Distance)', 'grid_5e': 'Grid 5e (Simplified)', 'grid_alt': 'Grid Alternate (5/10 Diagonal)', }; content.push('DISTANCE MEASUREMENT'); content.push(modeNames[params.mode as keyof typeof modeNames]); content.push(''); content.push('─'.repeat(40)); content.push(''); // Positions content.push('FROM → TO'); let positionDisplay = `(${params.from.x}, ${params.from.y}, ${params.from.z}) → (${params.to.x}, ${params.to.y}, ${params.to.z})`; if (params.fromName || params.toName) { const fromLabel = params.fromName || `(${params.from.x}, ${params.from.y}, ${params.from.z})`; const toLabel = params.toName || `(${params.to.x}, ${params.to.y}, ${params.to.z})`; positionDisplay = `${fromLabel} → ${toLabel}`; } content.push(positionDisplay); content.push(''); // Visual path representation const arrow = drawPath(params.from, params.to); content.push(`Path: ${arrow}`); content.push(''); // Movement breakdown if (params.dx > 0 || params.dy > 0 || params.dz > 0) { content.push(`Movement: ${params.dx}x, ${params.dy}y, ${params.dz}z`); content.push(''); } content.push('─'.repeat(40)); content.push(''); // Result content.push(`DISTANCE: ${params.distanceFeet} feet`); content.push(`(${params.distanceSquares} squares @ 5ft each)`); // Notes if (!params.includeElevation && params.to.z !== params.from.z) { content.push(''); content.push('* Elevation difference ignored *'); } return createBox('DISTANCE', content, undefined, 'HEAVY'); }

Implementation Reference

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/ChatRPG'

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