Skip to main content
Glama
combat-grid.ts28 kB
/** * Combat Grid System - Spatial Combat with Grid Positions * * This module implements the 5-phase spatial combat system: * - Phase 1: Position Persistence (handled by EncounterRepository) * - Phase 2: Boundary Validation (BUG-001 fix) * - Phase 3: Collision Enforcement * - Phase 4: Movement Economy (speed, terrain costs, dash) * - Phase 5: AoE Integration * * Design Principles: * - "LLM describes, engine validates" - Database is source of truth * - All positions validated against grid bounds * - Movement follows pathfinding strictly * - D&D 5e rules for movement (30ft = 6 squares, 1.5x diagonal cost) * * @module spatial/combat-grid */ import { SpatialEngine, Point, PathfindingOptions, TerrainCostMap } from './engine.js'; import { Position, GridBounds, DEFAULT_GRID_BOUNDS, SizeCategory, getSizeFootprint } from '../../schema/encounter.js'; import { CombatParticipant, CombatState } from '../combat/engine.js'; // ============================================================ // CONSTANTS // ============================================================ /** Feet per grid square (D&D 5e standard) */ export const FEET_PER_SQUARE = 5; /** Default movement speed in feet (D&D 5e standard for medium humanoids) */ export const DEFAULT_MOVEMENT_SPEED = 30; /** Squares of movement for default speed (30ft / 5ft = 6 squares) */ export const DEFAULT_MOVEMENT_SQUARES = DEFAULT_MOVEMENT_SPEED / FEET_PER_SQUARE; /** Diagonal movement cost multiplier (D&D 5e strict 5-10-5 rule, avg 1.5) */ export const DIAGONAL_COST = 1.5; /** Difficult terrain cost multiplier */ export const DIFFICULT_TERRAIN_COST = 2; // ============================================================ // TYPES // ============================================================ /** * Extended combat participant with spatial properties */ export interface SpatialParticipant extends CombatParticipant { position?: Position; movementSpeed: number; // Base speed in feet movementRemaining?: number; // Remaining movement this turn (in feet) size: SizeCategory; hasDashed?: boolean; // Whether dash action was used this turn } /** * Extended combat state with spatial properties */ export interface SpatialCombatState extends CombatState { participants: SpatialParticipant[]; gridBounds: GridBounds; terrain?: { obstacles: string[]; // "x,y" format, impassable difficultTerrain?: string[]; // "x,y" format, 2x movement cost }; } /** * Result of a movement validation */ export interface MovementValidation { valid: boolean; error?: string; path?: Point[]; pathCost?: number; // Total movement cost in feet triggersOpportunityAttacks?: string[]; // IDs of participants who can make OA } /** * Result of an AoE calculation */ export interface AoEResult { affectedTiles: Point[]; affectedParticipants: SpatialParticipant[]; blockedByLOS?: Point[]; // Tiles blocked by line of sight } // ============================================================ // PHASE 2: BOUNDARY VALIDATION (BUG-001 FIX) // ============================================================ /** * Validates that a position is within grid bounds. * * @param position The position to validate * @param bounds The grid bounds to check against * @returns true if position is within bounds * * @example * ```typescript * const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 }; * isPositionInBounds({ x: 50, y: 50 }, bounds); // true * isPositionInBounds({ x: -1, y: 50 }, bounds); // false * ``` * * Complexity: O(1) */ export function isPositionInBounds(position: Position, bounds: GridBounds): boolean { if (position.x < bounds.minX || position.x > bounds.maxX) { return false; } if (position.y < bounds.minY || position.y > bounds.maxY) { return false; } if (position.z !== undefined) { if (bounds.minZ !== undefined && position.z < bounds.minZ) { return false; } if (bounds.maxZ !== undefined && position.z > bounds.maxZ) { return false; } } return true; } /** * Validates a position and returns a detailed error message if invalid. * * @param position The position to validate * @param bounds The grid bounds * @param context Optional context for error message (e.g., "move destination") * @returns null if valid, error message string if invalid * * Complexity: O(1) */ export function validatePosition( position: Position, bounds: GridBounds, context: string = 'position' ): string | null { if (!Number.isFinite(position.x) || !Number.isFinite(position.y)) { return `Invalid ${context}: coordinates must be finite numbers`; } if (position.x < bounds.minX) { return `Invalid ${context}: x=${position.x} is below minimum (${bounds.minX})`; } if (position.x > bounds.maxX) { return `Invalid ${context}: x=${position.x} exceeds maximum (${bounds.maxX})`; } if (position.y < bounds.minY) { return `Invalid ${context}: y=${position.y} is below minimum (${bounds.minY})`; } if (position.y > bounds.maxY) { return `Invalid ${context}: y=${position.y} exceeds maximum (${bounds.maxY})`; } if (position.z !== undefined) { if (!Number.isFinite(position.z)) { return `Invalid ${context}: z-coordinate must be a finite number`; } if (bounds.minZ !== undefined && position.z < bounds.minZ) { return `Invalid ${context}: z=${position.z} is below minimum (${bounds.minZ})`; } if (bounds.maxZ !== undefined && position.z > bounds.maxZ) { return `Invalid ${context}: z=${position.z} exceeds maximum (${bounds.maxZ})`; } } return null; } // ============================================================ // PHASE 3: COLLISION ENFORCEMENT // ============================================================ /** * Get all tiles occupied by a creature based on its position and size. * * @param position Top-left corner of creature's space * @param size Creature's size category * @returns Array of all occupied tile keys ("x,y" format) * * @example * ```typescript * getOccupiedTiles({ x: 5, y: 5 }, 'medium'); // ['5,5'] * getOccupiedTiles({ x: 5, y: 5 }, 'large'); // ['5,5', '6,5', '5,6', '6,6'] * ``` * * Complexity: O(n²) where n is the footprint size (max 4 for gargantuan) */ export function getOccupiedTiles(position: Position, size: SizeCategory): string[] { const footprint = getSizeFootprint(size); const tiles: string[] = []; for (let dx = 0; dx < footprint; dx++) { for (let dy = 0; dy < footprint; dy++) { tiles.push(`${position.x + dx},${position.y + dy}`); } } return tiles; } /** * Build obstacle set from combat state (participants + terrain). * * @param state The combat state * @param excludeParticipantId Optional participant to exclude (for self-movement) * @returns Set of blocked tile keys ("x,y" format) * * Complexity: O(p * s² + t) where p=participants, s=max size footprint, t=terrain tiles */ export function buildObstacleSet( state: SpatialCombatState, excludeParticipantId?: string ): Set<string> { const obstacles = new Set<string>(); // Add participant positions for (const p of state.participants) { if (p.id === excludeParticipantId) continue; if (!p.position) continue; if (p.hp <= 0) continue; // Defeated creatures don't block (they're prone/dead) const occupied = getOccupiedTiles(p.position, p.size || 'medium'); for (const tile of occupied) { obstacles.add(tile); } } // Add terrain obstacles if (state.terrain?.obstacles) { for (const obs of state.terrain.obstacles) { obstacles.add(obs); } } return obstacles; } /** * Build difficult terrain set from combat state. * * @param state The combat state * @returns Set of difficult terrain tile keys ("x,y" format) * * Complexity: O(d) where d=difficult terrain tiles */ export function buildDifficultTerrainSet(state: SpatialCombatState): Set<string> { const difficult = new Set<string>(); if (state.terrain?.difficultTerrain) { for (const tile of state.terrain.difficultTerrain) { difficult.add(tile); } } return difficult; } /** * Check if a destination tile is blocked. * * @param destination Target position * @param size Creature's size * @param obstacles Set of blocked tiles * @returns true if destination is blocked * * Complexity: O(s²) where s=size footprint */ export function isDestinationBlocked( destination: Position, size: SizeCategory, obstacles: Set<string> ): boolean { const neededTiles = getOccupiedTiles(destination, size); return neededTiles.some(tile => obstacles.has(tile)); } // ============================================================ // PHASE 4: MOVEMENT ECONOMY // ============================================================ /** * Create a terrain cost map for pathfinding. * * @param difficultTerrain Set of difficult terrain tiles * @returns TerrainCostMap for SpatialEngine * * Complexity: O(1) per tile lookup */ export function createTerrainCostMap(difficultTerrain: Set<string>): TerrainCostMap { return { getTileCost(point: Point): number { const key = `${point.x},${point.y}`; return difficultTerrain.has(key) ? DIFFICULT_TERRAIN_COST : 1; } }; } /** * Calculate movement cost along a path. * * @param path Array of points in the path * @param difficultTerrain Set of difficult terrain tiles * @returns Total movement cost in feet * * @example * ```typescript * // Straight line 3 squares = 15 feet * calculatePathCost([{x:0,y:0}, {x:1,y:0}, {x:2,y:0}, {x:3,y:0}], new Set()); * // Returns 15 (3 moves × 5 feet) * * // Diagonal movement = 1.5x cost * calculatePathCost([{x:0,y:0}, {x:1,y:1}], new Set()); * // Returns 7.5 (1 diagonal × 1.5 × 5 feet) * ``` * * Complexity: O(n) where n=path length */ export function calculatePathCost(path: Point[], difficultTerrain: Set<string>): number { if (path.length <= 1) return 0; let totalCost = 0; for (let i = 1; i < path.length; i++) { const from = path[i - 1]; const to = path[i]; // Calculate base cost (1 for orthogonal, 1.5 for diagonal) const dx = Math.abs(to.x - from.x); const dy = Math.abs(to.y - from.y); const isDiagonal = dx > 0 && dy > 0; let baseCost = isDiagonal ? DIAGONAL_COST : 1; // Apply difficult terrain multiplier for destination tile const destKey = `${to.x},${to.y}`; if (difficultTerrain.has(destKey)) { baseCost *= DIFFICULT_TERRAIN_COST; } // Convert squares to feet totalCost += baseCost * FEET_PER_SQUARE; } return totalCost; } /** * Convert feet to squares (rounding down). * * @param feet Distance in feet * @returns Distance in grid squares */ export function feetToSquares(feet: number): number { return Math.floor(feet / FEET_PER_SQUARE); } /** * Convert squares to feet. * * @param squares Distance in grid squares * @returns Distance in feet */ export function squaresToFeet(squares: number): number { return squares * FEET_PER_SQUARE; } /** * Initialize movement for start of turn. * * @param participant The participant starting their turn * @returns Updated participant with reset movement */ export function initializeMovement(participant: SpatialParticipant): SpatialParticipant { return { ...participant, movementRemaining: participant.movementSpeed || DEFAULT_MOVEMENT_SPEED, hasDashed: false }; } /** * Apply dash action (doubles remaining movement). * * @param participant The participant dashing * @returns Updated participant with doubled movement */ export function applyDash(participant: SpatialParticipant): SpatialParticipant { const baseSpeed = participant.movementSpeed || DEFAULT_MOVEMENT_SPEED; const currentRemaining = participant.movementRemaining ?? baseSpeed; return { ...participant, movementRemaining: currentRemaining + baseSpeed, hasDashed: true }; } // ============================================================ // PHASE 3 & 4: MOVEMENT VALIDATION // ============================================================ /** * Validate a movement from current position to destination. * Enforces: * - Boundary validation (Phase 2) * - Collision detection (Phase 3) * - Movement economy (Phase 4) * * @param state The combat state * @param participantId ID of the moving participant * @param destination Target position * @param spatialEngine Optional SpatialEngine instance (creates one if not provided) * @returns MovementValidation result * * @example * ```typescript * const result = validateMovement(state, 'hero-1', { x: 5, y: 3 }); * if (result.valid) { * console.log(`Path found: ${result.path?.length} tiles, cost: ${result.pathCost}ft`); * } else { * console.log(`Movement blocked: ${result.error}`); * } * ``` * * Complexity: O(V log V + E) for A* pathfinding, where V=grid tiles, E=edges */ export function validateMovement( state: SpatialCombatState, participantId: string, destination: Position, spatialEngine?: SpatialEngine ): MovementValidation { const participant = state.participants.find(p => p.id === participantId) as SpatialParticipant | undefined; if (!participant) { return { valid: false, error: `Participant ${participantId} not found` }; } const bounds = state.gridBounds || DEFAULT_GRID_BOUNDS; // Phase 2: Validate destination is within bounds const boundsError = validatePosition(destination, bounds, 'destination'); if (boundsError) { return { valid: false, error: boundsError }; } // Get current position const currentPos = participant.position; if (!currentPos) { // No current position - allow setting initial position without pathfinding // But still validate bounds return { valid: true, path: [destination], pathCost: 0 }; } // Phase 2: Validate current position is within bounds (sanity check) const currentBoundsError = validatePosition(currentPos, bounds, 'current position'); if (currentBoundsError) { return { valid: false, error: `Invalid starting state: ${currentBoundsError}` }; } // Build obstacles (excluding self) const obstacles = buildObstacleSet(state, participantId); const difficultTerrain = buildDifficultTerrainSet(state); // Phase 3: Check if destination is blocked const size = participant.size || 'medium'; if (isDestinationBlocked(destination, size, obstacles)) { return { valid: false, error: 'Destination is blocked by obstacle or creature' }; } // Phase 3 & 4: Find path using A* with terrain costs const engine = spatialEngine || new SpatialEngine(); const terrainCostMap = createTerrainCostMap(difficultTerrain); const pathOptions: PathfindingOptions = { diagonalCost: 'alternating', // D&D 5e 5-10-5 rule terrainCosts: terrainCostMap, bounds: { min: { x: bounds.minX, y: bounds.minY }, max: { x: bounds.maxX, y: bounds.maxY } } }; const path = engine.findPath( { x: currentPos.x, y: currentPos.y }, { x: destination.x, y: destination.y }, obstacles, pathOptions ); if (!path) { return { valid: false, error: 'No valid path - blocked by obstacles' }; } // Phase 4: Calculate path cost const pathCost = calculatePathCost(path, difficultTerrain); const movementRemaining = participant.movementRemaining ?? (participant.movementSpeed || DEFAULT_MOVEMENT_SPEED); if (pathCost > movementRemaining) { return { valid: false, error: `Insufficient movement: path costs ${pathCost}ft, have ${movementRemaining}ft remaining`, path, pathCost }; } return { valid: true, path, pathCost }; } // ============================================================ // PHASE 5: AOE INTEGRATION // ============================================================ /** * Get all participants within a circular area. * * @param state The combat state * @param center Center point of the circle * @param radiusFeet Radius in feet * @param excludeIds Optional IDs to exclude (e.g., caster) * @returns AoEResult with affected tiles and participants * * @example * ```typescript * // 20ft radius Fireball centered at (10, 10) * const result = getParticipantsInCircle(state, { x: 10, y: 10 }, 20); * console.log(`Fireball hits ${result.affectedParticipants.length} creatures`); * ``` * * Complexity: O(r² + p) where r=radius in squares, p=participants */ export function getParticipantsInCircle( state: SpatialCombatState, center: Position, radiusFeet: number, excludeIds: string[] = [] ): AoEResult { const engine = new SpatialEngine(); const radiusSquares = radiusFeet / FEET_PER_SQUARE; const affectedTiles = engine.getCircleTiles( { x: center.x, y: center.y }, radiusSquares ); const tileSet = new Set(affectedTiles.map(p => `${p.x},${p.y}`)); const affectedParticipants = state.participants.filter(p => { if (excludeIds.includes(p.id)) return false; if (!p.position) return false; // Check if any of the creature's occupied tiles are in the area const occupied = getOccupiedTiles(p.position, p.size || 'medium'); return occupied.some(tile => tileSet.has(tile)); }) as SpatialParticipant[]; return { affectedTiles, affectedParticipants }; } /** * Get all participants within a cone area. * * @param state The combat state * @param origin Origin point of the cone * @param direction Direction vector (e.g., {x: 1, y: 0} for East) * @param lengthFeet Length in feet * @param angleDegrees Cone angle in degrees * @param excludeIds Optional IDs to exclude * @returns AoEResult with affected tiles and participants * * @example * ```typescript * // 15ft cone of cold facing North * const result = getParticipantsInCone(state, { x: 5, y: 5 }, { x: 0, y: -1 }, 15, 90); * ``` * * Complexity: O(l² + p) where l=length in squares, p=participants */ export function getParticipantsInCone( state: SpatialCombatState, origin: Position, direction: Position, lengthFeet: number, angleDegrees: number, excludeIds: string[] = [] ): AoEResult { const engine = new SpatialEngine(); const lengthSquares = lengthFeet / FEET_PER_SQUARE; const affectedTiles = engine.getConeTiles( { x: origin.x, y: origin.y }, { x: direction.x, y: direction.y }, lengthSquares, angleDegrees ); const tileSet = new Set(affectedTiles.map(p => `${p.x},${p.y}`)); const affectedParticipants = state.participants.filter(p => { if (excludeIds.includes(p.id)) return false; if (!p.position) return false; const occupied = getOccupiedTiles(p.position, p.size || 'medium'); return occupied.some(tile => tileSet.has(tile)); }) as SpatialParticipant[]; return { affectedTiles, affectedParticipants }; } /** * Get all participants along a line (e.g., Lightning Bolt). * * @param state The combat state * @param start Start point of the line * @param end End point of the line * @param excludeIds Optional IDs to exclude * @returns AoEResult with affected tiles and participants * * @example * ```typescript * // 100ft Lightning Bolt from (0,0) to (20,0) * const result = getParticipantsInLine(state, { x: 0, y: 0 }, { x: 20, y: 0 }); * ``` * * Complexity: O(d + p) where d=distance in squares, p=participants */ export function getParticipantsInLine( state: SpatialCombatState, start: Position, end: Position, excludeIds: string[] = [] ): AoEResult { const engine = new SpatialEngine(); const affectedTiles = engine.getLineTiles( { x: start.x, y: start.y }, { x: end.x, y: end.y } ); const tileSet = new Set(affectedTiles.map(p => `${p.x},${p.y}`)); const affectedParticipants = state.participants.filter(p => { if (excludeIds.includes(p.id)) return false; if (!p.position) return false; const occupied = getOccupiedTiles(p.position, p.size || 'medium'); return occupied.some(tile => tileSet.has(tile)); }) as SpatialParticipant[]; return { affectedTiles, affectedParticipants }; } /** * Check line of sight from caster to target. * * @param state The combat state * @param from Origin point * @param to Target point * @returns true if clear line of sight exists * * Complexity: O(d + o) where d=distance, o=obstacles */ export function hasLineOfSight( state: SpatialCombatState, from: Position, to: Position ): boolean { const engine = new SpatialEngine(); // Build obstacles (only terrain blocks LOS, not creatures) const obstacles = new Set<string>(); if (state.terrain?.obstacles) { for (const obs of state.terrain.obstacles) { obstacles.add(obs); } } return engine.hasLineOfSight( { x: from.x, y: from.y }, { x: to.x, y: to.y }, obstacles ); } // ============================================================ // COMBAT GRID MANAGER CLASS // ============================================================ /** * CombatGridManager - High-level API for spatial combat operations. * * Provides a unified interface for all spatial combat operations, * integrating all 5 phases into a cohesive system. * * @example * ```typescript * const grid = new CombatGridManager(combatState); * * // Start of turn * grid.startTurn('hero-1'); * * // Validate and execute movement * const moveResult = grid.validateMove('hero-1', { x: 5, y: 3 }); * if (moveResult.valid) { * grid.executeMove('hero-1', { x: 5, y: 3 }); * } * * // Use dash action * grid.dash('hero-1'); * * // Get fireball targets * const targets = grid.getCircleTargets({ x: 10, y: 10 }, 20); * ``` */ export class CombatGridManager { private state: SpatialCombatState; private spatialEngine: SpatialEngine; constructor(state: SpatialCombatState) { this.state = state; this.spatialEngine = new SpatialEngine(); // Ensure grid bounds exist if (!this.state.gridBounds) { this.state.gridBounds = { ...DEFAULT_GRID_BOUNDS }; } } /** * Get the current combat state. */ getState(): SpatialCombatState { return this.state; } /** * Get grid bounds. */ getBounds(): GridBounds { return this.state.gridBounds; } /** * Initialize movement for a participant's turn. * * @param participantId The participant starting their turn */ startTurn(participantId: string): void { const index = this.state.participants.findIndex(p => p.id === participantId); if (index === -1) return; this.state.participants[index] = initializeMovement(this.state.participants[index]); } /** * Validate a movement without executing it. * * @param participantId ID of the moving participant * @param destination Target position * @returns MovementValidation result */ validateMove(participantId: string, destination: Position): MovementValidation { return validateMovement(this.state, participantId, destination, this.spatialEngine); } /** * Execute a validated movement. * Call validateMove first to ensure movement is valid. * * @param participantId ID of the moving participant * @param destination Target position * @param pathCost Cost in feet (from validation result) * @returns true if movement was executed */ executeMove(participantId: string, destination: Position, pathCost: number): boolean { const index = this.state.participants.findIndex(p => p.id === participantId); if (index === -1) return false; const participant = this.state.participants[index]; const currentRemaining = participant.movementRemaining ?? (participant.movementSpeed || DEFAULT_MOVEMENT_SPEED); // Update position and deduct movement this.state.participants[index] = { ...participant, position: destination, movementRemaining: currentRemaining - pathCost }; return true; } /** * Apply dash action to double movement. * * @param participantId ID of the participant * @returns true if dash was applied */ dash(participantId: string): boolean { const index = this.state.participants.findIndex(p => p.id === participantId); if (index === -1) return false; if (this.state.participants[index].hasDashed) { return false; // Already dashed this turn } this.state.participants[index] = applyDash(this.state.participants[index]); return true; } /** * Set initial position for a participant. * Used when placing tokens at encounter start. * * @param participantId ID of the participant * @param position Initial position * @returns null if successful, error message if invalid */ setPosition(participantId: string, position: Position): string | null { const error = validatePosition(position, this.state.gridBounds, 'initial position'); if (error) return error; const index = this.state.participants.findIndex(p => p.id === participantId); if (index === -1) return `Participant ${participantId} not found`; // Check collision const obstacles = buildObstacleSet(this.state, participantId); const size = this.state.participants[index].size || 'medium'; if (isDestinationBlocked(position, size, obstacles)) { return 'Position is blocked by obstacle or creature'; } this.state.participants[index] = { ...this.state.participants[index], position }; return null; } /** * Get participants in a circular area (e.g., Fireball). */ getCircleTargets(center: Position, radiusFeet: number, excludeIds: string[] = []): AoEResult { return getParticipantsInCircle(this.state, center, radiusFeet, excludeIds); } /** * Get participants in a cone area (e.g., Burning Hands). */ getConeTargets( origin: Position, direction: Position, lengthFeet: number, angleDegrees: number, excludeIds: string[] = [] ): AoEResult { return getParticipantsInCone(this.state, origin, direction, lengthFeet, angleDegrees, excludeIds); } /** * Get participants along a line (e.g., Lightning Bolt). */ getLineTargets(start: Position, end: Position, excludeIds: string[] = []): AoEResult { return getParticipantsInLine(this.state, start, end, excludeIds); } /** * Check line of sight between two points. */ hasLineOfSight(from: Position, to: Position): boolean { return hasLineOfSight(this.state, from, to); } /** * Get remaining movement for a participant. */ getRemainingMovement(participantId: string): number { const p = this.state.participants.find(p => p.id === participantId); if (!p) return 0; return p.movementRemaining ?? (p.movementSpeed || DEFAULT_MOVEMENT_SPEED); } }

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