Skip to main content
Glama
spatial-graph.test.ts24.6 kB
import { describe, it, expect, beforeEach } from 'vitest'; import { closeDb, getDb } from '../../src/storage/index.js'; import { SpatialRepository } from '../../src/storage/repos/spatial.repo.js'; import { CharacterRepository } from '../../src/storage/repos/character.repo.js'; import { RoomNode } from '../../src/schema/spatial.js'; import { Character } from '../../src/schema/character.js'; const mockCtx = { sessionId: 'test-session' }; /** * PHASE-1: Spatial Graph System Tests * * Tests for room/location persistence and spatial awareness: * - RoomNode schema validation * - Room persistence (CRUD) * - Exit navigation and linking * - Perception-based visibility * - Room generation * - Entity tracking in rooms */ describe('PHASE-1: Spatial Graph System', () => { let spatialRepo: SpatialRepository; let characterRepo: CharacterRepository; beforeEach(() => { closeDb(); const db = getDb(':memory:'); spatialRepo = new SpatialRepository(db); characterRepo = new CharacterRepository(db); }); describe('RoomNode Schema Validation', () => { it('creates room with valid data', () => { const room = createTestRoom(); expect(() => spatialRepo.create(room)).not.toThrow(); }); it('rejects room with empty name', () => { const room = createTestRoom({ name: '' }); expect(() => spatialRepo.create(room)).toThrow(); }); it('rejects room with whitespace-only name', () => { const room = createTestRoom({ name: ' ' }); expect(() => spatialRepo.create(room)).toThrow(); }); it('rejects room with name > 100 characters', () => { const room = createTestRoom({ name: 'A'.repeat(101) }); expect(() => spatialRepo.create(room)).toThrow(); }); it('rejects room with description < 10 chars', () => { const room = createTestRoom({ baseDescription: 'short' }); expect(() => spatialRepo.create(room)).toThrow(); }); it('rejects room with whitespace-only description', () => { const room = createTestRoom({ baseDescription: ' ' }); expect(() => spatialRepo.create(room)).toThrow(); }); it('rejects room with description > 2000 characters', () => { const room = createTestRoom({ baseDescription: 'A'.repeat(2001) }); expect(() => spatialRepo.create(room)).toThrow(); }); it('rejects invalid biome', () => { const room = createTestRoom({ biomeContext: 'invalid_biome' as any }); expect(() => spatialRepo.create(room)).toThrow(); }); it('accepts all valid biomes', () => { const biomes = ['forest', 'mountain', 'urban', 'dungeon', 'coastal', 'cavern', 'divine', 'arcane']; biomes.forEach(biome => { const room = createTestRoom({ id: crypto.randomUUID(), biomeContext: biome as any }); expect(() => spatialRepo.create(room)).not.toThrow(); }); }); }); describe('Room Persistence', () => { it('room persists after creation', () => { const room = createTestRoom(); spatialRepo.create(room); const retrieved = spatialRepo.findById(room.id); expect(retrieved).toBeDefined(); expect(retrieved!.name).toBe(room.name); expect(retrieved!.baseDescription).toBe(room.baseDescription); }); it('updates room metadata', () => { const room = createTestRoom(); spatialRepo.create(room); const updated = spatialRepo.update(room.id, { atmospherics: ['DARKNESS', 'FOG'] }); expect(updated).toBeDefined(); expect(updated!.atmospherics).toContain('DARKNESS'); expect(updated!.atmospherics).toContain('FOG'); }); it('preserves immutable baseDescription', () => { const room = createTestRoom(); spatialRepo.create(room); const originalDescription = room.baseDescription; // Attempt to update description (should succeed since we allow it) spatialRepo.update(room.id, { baseDescription: 'A completely different place that should not happen.' }); const retrieved = spatialRepo.findById(room.id); // The description CAN change, but in practice the LLM should not do this // This test documents that we allow it at the DB level expect(retrieved!.baseDescription).toBe('A completely different place that should not happen.'); }); it('deletes room', () => { const room = createTestRoom(); spatialRepo.create(room); const deleted = spatialRepo.delete(room.id); expect(deleted).toBe(true); const retrieved = spatialRepo.findById(room.id); expect(retrieved).toBeNull(); }); it('returns false when deleting non-existent room', () => { const deleted = spatialRepo.delete('non-existent-id'); expect(deleted).toBe(false); }); it('findAll returns all rooms', () => { const room1 = createTestRoom({ id: crypto.randomUUID(), name: 'Room A' }); const room2 = createTestRoom({ id: crypto.randomUUID(), name: 'Room B' }); const room3 = createTestRoom({ id: crypto.randomUUID(), name: 'Room C' }); spatialRepo.create(room1); spatialRepo.create(room2); spatialRepo.create(room3); const all = spatialRepo.findAll(); expect(all.length).toBe(3); }); it('findByBiome filters rooms correctly', () => { const forest1 = createTestRoom({ id: crypto.randomUUID(), biomeContext: 'forest' }); const forest2 = createTestRoom({ id: crypto.randomUUID(), biomeContext: 'forest' }); const dungeon = createTestRoom({ id: crypto.randomUUID(), biomeContext: 'dungeon' }); spatialRepo.create(forest1); spatialRepo.create(forest2); spatialRepo.create(dungeon); const forestRooms = spatialRepo.findByBiome('forest'); expect(forestRooms.length).toBe(2); expect(forestRooms.every(r => r.biomeContext === 'forest')).toBe(true); }); }); describe('Exits and Navigation', () => { it('room can have multiple exits', () => { const room2Id = crypto.randomUUID(); const room3Id = crypto.randomUUID(); const room4Id = crypto.randomUUID(); const room1Id = crypto.randomUUID(); const room2 = createTestRoom({ id: room2Id, name: 'North Room' }); const room3 = createTestRoom({ id: room3Id, name: 'East Room' }); const room4 = createTestRoom({ id: room4Id, name: 'Below' }); spatialRepo.create(room2); spatialRepo.create(room3); spatialRepo.create(room4); const room1 = createTestRoom({ id: room1Id, exits: [ { direction: 'north', targetNodeId: room2Id, type: 'OPEN' }, { direction: 'east', targetNodeId: room3Id, type: 'LOCKED' }, { direction: 'down', targetNodeId: room4Id, type: 'HIDDEN', dc: 15 } ] }); spatialRepo.create(room1); const retrieved = spatialRepo.findById(room1.id); expect(retrieved!.exits).toHaveLength(3); expect(retrieved!.exits[2].dc).toBe(15); }); it('get_room_exits returns all exits', async () => { const { handleGetRoomExits } = await import('../../src/server/spatial-tools.js'); const room = createTestRoom({ exits: [ { direction: 'north', targetNodeId: crypto.randomUUID(), type: 'OPEN' } ] }); spatialRepo.create(room); const result = await handleGetRoomExits({ roomId: room.id }, mockCtx); const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(true); expect(parsed.exits).toHaveLength(1); expect(parsed.exits[0].direction).toBe('north'); }); it('findConnectedRooms returns linked rooms', () => { const room1Id = crypto.randomUUID(); const room2Id = crypto.randomUUID(); const room2 = createTestRoom({ id: room2Id, name: 'Connected Room' }); spatialRepo.create(room2); const room1 = createTestRoom({ id: room1Id, exits: [{ direction: 'north', targetNodeId: room2Id, type: 'OPEN' }] }); spatialRepo.create(room1); const connected = spatialRepo.findConnectedRooms(room1Id); expect(connected).toHaveLength(1); expect(connected[0].id).toBe(room2Id); }); it('addExit dynamically adds exit to room', () => { const room1Id = crypto.randomUUID(); const room2Id = crypto.randomUUID(); const room1 = createTestRoom({ id: room1Id }); const room2 = createTestRoom({ id: room2Id }); spatialRepo.create(room1); spatialRepo.create(room2); spatialRepo.addExit(room1Id, { direction: 'south', targetNodeId: room2Id, type: 'OPEN' }); const updated = spatialRepo.findById(room1Id); expect(updated!.exits).toHaveLength(1); expect(updated!.exits[0].direction).toBe('south'); }); }); describe('Perception and Visibility', () => { it('DARKNESS blocks vision without darkvision or light', async () => { const { handleLookAtSurroundings, handleMoveCharacterToRoom } = await import('../../src/server/spatial-tools.js'); const darkRoomId = crypto.randomUUID(); const observerId = crypto.randomUUID(); const darkRoom = createTestRoom({ id: darkRoomId, atmospherics: ['DARKNESS'] }); spatialRepo.create(darkRoom); const observer = createTestCharacter({ id: observerId, conditions: [] }); characterRepo.create(observer); // Move observer to dark room await handleMoveCharacterToRoom({ characterId: observerId, roomId: darkRoomId }, mockCtx); const result = await handleLookAtSurroundings({ observerId: observerId }, mockCtx); const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(true); expect(parsed.description).toContain("can't see"); expect(parsed.exits).toHaveLength(0); }); it('DARKVISION allows vision in darkness', async () => { const { handleLookAtSurroundings, handleMoveCharacterToRoom } = await import('../../src/server/spatial-tools.js'); const darkRoomId = crypto.randomUUID(); const observerId = crypto.randomUUID(); const darkRoom = createTestRoom({ id: darkRoomId, name: 'Dark Cave', atmospherics: ['DARKNESS'] }); spatialRepo.create(darkRoom); const observer = createTestCharacter({ id: observerId, conditions: [{ name: 'DARKVISION' }] }); characterRepo.create(observer); await handleMoveCharacterToRoom({ characterId: observerId, roomId: darkRoomId }, mockCtx); const result = await handleLookAtSurroundings({ observerId: observerId }, mockCtx); const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(true); expect(parsed.description).not.toContain("can't see"); expect(parsed.roomName).toBe('Dark Cave'); }); it('LOCKED exits are not visible', async () => { const { handleLookAtSurroundings, handleMoveCharacterToRoom } = await import('../../src/server/spatial-tools.js'); const roomId = crypto.randomUUID(); const observerId = crypto.randomUUID(); const room = createTestRoom({ id: roomId, exits: [ { direction: 'north', targetNodeId: crypto.randomUUID(), type: 'LOCKED' } ] }); spatialRepo.create(room); const observer = createTestCharacter({ id: observerId }); characterRepo.create(observer); await handleMoveCharacterToRoom({ characterId: observerId, roomId: roomId }, mockCtx); const result = await handleLookAtSurroundings({ observerId: observerId }, mockCtx); const parsed = JSON.parse(result.content[0].text); expect(parsed.exits).toHaveLength(0); // Locked door not visible }); it('HIDDEN exits require Perception check', async () => { const { handleLookAtSurroundings, handleMoveCharacterToRoom } = await import('../../src/server/spatial-tools.js'); const roomId = crypto.randomUUID(); const charId = crypto.randomUUID(); const room = createTestRoom({ id: roomId, exits: [ { direction: 'north', targetNodeId: crypto.randomUUID(), type: 'HIDDEN', dc: 15 } ] }); spatialRepo.create(room); // Low WIS character (modifier = -1) const lowWisdomChar = createTestCharacter({ id: charId, stats: { str: 10, dex: 10, con: 10, int: 10, wis: 8, cha: 10 } }); characterRepo.create(lowWisdomChar); await handleMoveCharacterToRoom({ characterId: charId, roomId: roomId }, mockCtx); // Run multiple times to test randomness let timesFound = 0; for (let i = 0; i < 100; i++) { const result = await handleLookAtSurroundings({ observerId: charId }, mockCtx); const parsed = JSON.parse(result.content[0].text); if (parsed.exits.some((e: any) => e.direction === 'north')) { timesFound++; } } // With WIS 8 (modifier -1), rolling 1d20-1 vs DC 15 should succeed rarely // Expected: ~25% success rate (need 16+ on d20) expect(timesFound).toBeLessThan(40); // Should find it less than 40% of the time }); }); describe('Room Generation', () => { it('generate_room_node creates room in database', async () => { const { handleGenerateRoomNode } = await import('../../src/server/spatial-tools.js'); const result = await handleGenerateRoomNode({ name: 'The Mossy Glade', baseDescription: 'A peaceful forest clearing with soft moss covering the ground.', biomeContext: 'forest', atmospherics: [] }, mockCtx); const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(true); expect(parsed.roomId).toBeDefined(); expect(parsed.name).toBe('The Mossy Glade'); const retrieved = spatialRepo.findById(parsed.roomId); expect(retrieved).toBeDefined(); expect(retrieved!.biomeContext).toBe('forest'); }); it('generate_room_node links to previous room', async () => { const { handleGenerateRoomNode } = await import('../../src/server/spatial-tools.js'); const startRoomId = crypto.randomUUID(); const startRoom = createTestRoom({ id: startRoomId }); spatialRepo.create(startRoom); const result = await handleGenerateRoomNode({ name: 'Northern Chamber', baseDescription: 'A cold stone chamber to the north of the entrance.', biomeContext: 'dungeon', atmospherics: [], previousNodeId: startRoomId, direction: 'north' }, mockCtx); const parsed = JSON.parse(result.content[0].text); expect(parsed.linkedToPrevious).toBe(true); const updatedStart = spatialRepo.findById(startRoomId); expect(updatedStart!.exits).toHaveLength(1); expect(updatedStart!.exits[0].direction).toBe('north'); expect(updatedStart!.exits[0].targetNodeId).toBe(parsed.roomId); }); it('atmospheric effects vary by specification', async () => { const { handleGenerateRoomNode } = await import('../../src/server/spatial-tools.js'); const brightRoom = await handleGenerateRoomNode({ name: 'Sunlit Plaza', baseDescription: 'A bright open plaza filled with sunlight.', biomeContext: 'urban', atmospherics: ['BRIGHT'] }, mockCtx); const darkCave = await handleGenerateRoomNode({ name: 'Dark Cave', baseDescription: 'A pitch black cave devoid of light.', biomeContext: 'cavern', atmospherics: ['DARKNESS'] }, mockCtx); const parsedBright = JSON.parse(brightRoom.content[0].text); const parsedDark = JSON.parse(darkCave.content[0].text); expect(parsedBright.atmospherics).toContain('BRIGHT'); expect(parsedDark.atmospherics).toContain('DARKNESS'); }); }); describe('Entity Management', () => { it('room can track entities', () => { const entity1 = crypto.randomUUID(); const entity2 = crypto.randomUUID(); const room = createTestRoom({ entityIds: [entity1, entity2] }); spatialRepo.create(room); const retrieved = spatialRepo.findById(room.id); expect(retrieved!.entityIds).toContain(entity1); expect(retrieved!.entityIds).toContain(entity2); }); it('addEntityToRoom adds entity', () => { const room = createTestRoom({ entityIds: [] }); spatialRepo.create(room); const entityId = crypto.randomUUID(); spatialRepo.addEntityToRoom(room.id, entityId); const retrieved = spatialRepo.findById(room.id); expect(retrieved!.entityIds).toContain(entityId); }); it('removeEntityFromRoom removes entity', () => { const entity1 = crypto.randomUUID(); const entity2 = crypto.randomUUID(); const room = createTestRoom({ entityIds: [entity1, entity2] }); spatialRepo.create(room); spatialRepo.removeEntityFromRoom(room.id, entity1); const retrieved = spatialRepo.findById(room.id); expect(retrieved!.entityIds).not.toContain(entity1); expect(retrieved!.entityIds).toContain(entity2); }); it('getEntitiesInRoom returns entity list', () => { const e1 = crypto.randomUUID(); const e2 = crypto.randomUUID(); const e3 = crypto.randomUUID(); const room = createTestRoom({ entityIds: [e1, e2, e3] }); spatialRepo.create(room); const entities = spatialRepo.getEntitiesInRoom(room.id); expect(entities).toHaveLength(3); }); }); describe('Character Movement', () => { it('move_character_to_room updates character location', async () => { const { handleMoveCharacterToRoom } = await import('../../src/server/spatial-tools.js'); const roomId = crypto.randomUUID(); const charId = crypto.randomUUID(); const room = createTestRoom({ id: roomId }); spatialRepo.create(room); const character = createTestCharacter({ id: charId }); characterRepo.create(character); const result = await handleMoveCharacterToRoom({ characterId: charId, roomId: roomId }, mockCtx); const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(true); expect(parsed.newRoomId).toBe(roomId); // Verify character is in room const updatedRoom = spatialRepo.findById(roomId); expect(updatedRoom!.entityIds).toContain(charId); }); it('incrementVisitCount tracks visits', () => { const room = createTestRoom({ visitedCount: 0 }); spatialRepo.create(room); spatialRepo.incrementVisitCount(room.id); spatialRepo.incrementVisitCount(room.id); spatialRepo.incrementVisitCount(room.id); const updated = spatialRepo.findById(room.id); expect(updated!.visitedCount).toBe(3); expect(updated!.lastVisitedAt).toBeDefined(); }); }); describe('Integration', () => { it('full room traversal workflow', async () => { const { handleGenerateRoomNode, handleLookAtSurroundings, handleMoveCharacterToRoom } = await import('../../src/server/spatial-tools.js'); // Create starting room const tavernResult = await handleGenerateRoomNode({ name: 'The Prancing Pony', baseDescription: 'A cozy tavern with a roaring fireplace and the smell of fresh bread.', biomeContext: 'urban', atmospherics: [] }, mockCtx); const tavern = JSON.parse(tavernResult.content[0].text); // Create connected room const alleyResult = await handleGenerateRoomNode({ name: 'Dark Alley', baseDescription: 'A narrow, dimly lit alley behind the tavern.', biomeContext: 'urban', atmospherics: ['DARKNESS'], previousNodeId: tavern.roomId, direction: 'south' }, mockCtx); const alley = JSON.parse(alleyResult.content[0].text); expect(alley.linkedToPrevious).toBe(true); // Create character in first room const playerId = crypto.randomUUID(); const player = createTestCharacter({ id: playerId }); characterRepo.create(player); await handleMoveCharacterToRoom({ characterId: playerId, roomId: tavern.roomId }, mockCtx); // Look around tavern const tavernView = await handleLookAtSurroundings({ observerId: playerId }, mockCtx); const view = JSON.parse(tavernView.content[0].text); expect(view.success).toBe(true); expect(view.roomName).toBe('The Prancing Pony'); expect(view.exits.some((e: any) => e.direction === 'south')).toBe(true); }); }); }); // ===== HELPER FUNCTIONS ===== function createTestRoom(overrides?: Partial<RoomNode>): RoomNode { return { id: crypto.randomUUID(), name: 'Test Room', baseDescription: 'A generic test room with wooden floors and stone walls.', biomeContext: 'urban', atmospherics: [], exits: [], entityIds: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), visitedCount: 0, lastVisitedAt: undefined, ...overrides }; } function createTestCharacter(overrides?: Partial<Character>): Character { return { id: crypto.randomUUID(), name: 'Test Character', stats: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, hp: 50, maxHp: 50, ac: 10, level: 1, characterType: 'pc', characterClass: 'fighter', conditions: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides }; }

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