Skip to main content
Glama
social-hearing.test.ts30.3 kB
/** * PHASE-2: Social Hearing Mechanics Tests * * Tests the spatial-aware social interaction system including: * - Hearing range calculations based on volume and environment * - Stealth vs Perception opposed rolls for eavesdropping * - Conversation memory recording for listeners */ import { describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; import { v4 as uuidv4 } from 'uuid'; import { CharacterRepository } from '../src/storage/repos/character.repo.js'; import { SpatialRepository } from '../src/storage/repos/spatial.repo.js'; import { NpcMemoryRepository } from '../src/storage/repos/npc-memory.repo.js'; import { handleInteractSocially } from '../src/server/npc-memory-tools.js'; import { calculateHearingRadius } from '../src/engine/social/hearing.js'; import { rollStealthVsPerception, getModifier } from '../src/engine/social/stealth-perception.js'; import { Character } from '../src/schema/character.js'; import { RoomNode } from '../src/schema/spatial.js'; import { closeDb, getDb } from '../src/storage/index.js'; const mockCtx = { sessionId: 'test-session' }; describe('PHASE-2: Social Hearing Mechanics', () => { let db: Database.Database; let charRepo: CharacterRepository; let spatialRepo: SpatialRepository; let memoryRepo: NpcMemoryRepository; beforeEach(() => { closeDb(); db = getDb(':memory:'); charRepo = new CharacterRepository(db); spatialRepo = new SpatialRepository(db); memoryRepo = new NpcMemoryRepository(db); }); // Helper: Create a test character function createChar(overrides: Partial<Character> = {}): Character { const now = new Date().toISOString(); const char: Character = { id: uuidv4(), name: overrides.name || 'Test Character', stats: overrides.stats || { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, hp: 20, maxHp: 20, ac: 10, level: 1, characterType: 'pc', perceptionBonus: overrides.perceptionBonus || 0, stealthBonus: overrides.stealthBonus || 0, characterClass: 'fighter', knownSpells: [], preparedSpells: [], cantripsKnown: [], maxSpellLevel: 0, concentratingOn: null, activeSpells: [], conditions: overrides.conditions || [], currentRoomId: overrides.currentRoomId, createdAt: now, updatedAt: now, ...overrides }; charRepo.create(char); return char; } // Helper: Create a test room function createRoom(overrides: Partial<RoomNode> = {}): RoomNode { const now = new Date().toISOString(); const room: RoomNode = { id: uuidv4(), name: overrides.name || 'Test Room', baseDescription: overrides.baseDescription || 'A simple test room for testing purposes.', biomeContext: overrides.biomeContext || 'urban', atmospherics: overrides.atmospherics || [], exits: [], entityIds: overrides.entityIds || [], createdAt: now, updatedAt: now, visitedCount: 0, ...overrides }; spatialRepo.create(room); return room; } describe('Category 1: Hearing Radius Calculations', () => { it('1.1: Whisper has short range in urban environment (5 feet)', () => { const radius = calculateHearingRadius({ volume: 'WHISPER', biomeContext: 'urban', atmospherics: [] }); expect(radius).toBe(5); }); it('1.2: Talk has moderate range in urban environment (15 feet)', () => { const radius = calculateHearingRadius({ volume: 'TALK', biomeContext: 'urban', atmospherics: [] }); expect(radius).toBe(15); }); it('1.3: Shout has long range in urban environment (40 feet)', () => { const radius = calculateHearingRadius({ volume: 'SHOUT', biomeContext: 'urban', atmospherics: [] }); expect(radius).toBe(40); }); it('1.4: Forest environment increases hearing ranges', () => { const whisper = calculateHearingRadius({ volume: 'WHISPER', biomeContext: 'forest', atmospherics: [] }); const talk = calculateHearingRadius({ volume: 'TALK', biomeContext: 'forest', atmospherics: [] }); const shout = calculateHearingRadius({ volume: 'SHOUT', biomeContext: 'forest', atmospherics: [] }); expect(whisper).toBe(10); // Quiet forest expect(talk).toBe(60); // Sound carries well expect(shout).toBe(300); // Echo through trees }); it('1.5: Mountain environment has longest ranges', () => { const whisper = calculateHearingRadius({ volume: 'WHISPER', biomeContext: 'mountain', atmospherics: [] }); const talk = calculateHearingRadius({ volume: 'TALK', biomeContext: 'mountain', atmospherics: [] }); const shout = calculateHearingRadius({ volume: 'SHOUT', biomeContext: 'mountain', atmospherics: [] }); expect(whisper).toBe(15); // Thin air expect(talk).toBe(100); // Wide open expect(shout).toBe(500); // Mountain echo }); it('1.6: SILENCE atmosphere reduces hearing range by 50%', () => { const normalTalk = calculateHearingRadius({ volume: 'TALK', biomeContext: 'urban', atmospherics: [] }); const silencedTalk = calculateHearingRadius({ volume: 'TALK', biomeContext: 'urban', atmospherics: ['SILENCE'] }); expect(silencedTalk).toBe(Math.floor(normalTalk * 0.5)); }); it('1.7: DARKNESS does not affect hearing', () => { const normalTalk = calculateHearingRadius({ volume: 'TALK', biomeContext: 'urban', atmospherics: [] }); const darkTalk = calculateHearingRadius({ volume: 'TALK', biomeContext: 'urban', atmospherics: ['DARKNESS'] }); expect(darkTalk).toBe(normalTalk); }); }); describe('Category 2: Stealth vs Perception Opposed Rolls', () => { it('2.1: Ability modifier calculation follows D&D 5e formula', () => { expect(getModifier(10)).toBe(0); // 10 = +0 expect(getModifier(12)).toBe(1); // 12 = +1 expect(getModifier(14)).toBe(2); // 14 = +2 expect(getModifier(8)).toBe(-1); // 8 = -1 expect(getModifier(20)).toBe(5); // 20 = +5 }); it('2.2: Opposed roll includes ability modifiers and bonuses', () => { const speaker = createChar({ stats: { str: 10, dex: 16, con: 10, int: 10, wis: 10, cha: 10 }, stealthBonus: 2 }); const listener = createChar({ stats: { str: 10, dex: 10, con: 10, int: 10, wis: 14, cha: 10 }, perceptionBonus: 3 }); const result = rollStealthVsPerception(speaker, listener, 0); // Speaker: DEX 16 (+3) + stealthBonus 2 = +5 expect(result.speakerModifier).toBe(5); // Listener: WIS 14 (+2) + perceptionBonus 3 = +5 expect(result.listenerModifier).toBe(5); }); it('2.3: High perception beats low stealth (statistical)', () => { const lowStealthSpeaker = createChar({ name: 'Clumsy Speaker', stats: { str: 10, dex: 8, con: 10, int: 10, wis: 10, cha: 10 }, stealthBonus: 0 }); const highPerceptionListener = createChar({ name: 'Alert Listener', stats: { str: 10, dex: 10, con: 10, int: 10, wis: 18, cha: 10 }, perceptionBonus: 5 }); let successCount = 0; const trials = 100; for (let i = 0; i < trials; i++) { const result = rollStealthVsPerception(lowStealthSpeaker, highPerceptionListener, 0); if (result.success) successCount++; } // High perception should overhear most of the time (>60%) expect(successCount).toBeGreaterThan(60); }); it('2.4: Low perception fails against average stealth (statistical)', () => { const avgStealthSpeaker = createChar({ name: 'Average Speaker', stats: { str: 10, dex: 14, con: 10, int: 10, wis: 10, cha: 10 }, stealthBonus: 2 }); const lowPerceptionListener = createChar({ name: 'Oblivious Listener', stats: { str: 10, dex: 10, con: 10, int: 10, wis: 8, cha: 10 }, perceptionBonus: 0 }); let successCount = 0; const trials = 100; for (let i = 0; i < trials; i++) { const result = rollStealthVsPerception(avgStealthSpeaker, lowPerceptionListener, 0); if (result.success) successCount++; } // Low perception should fail to overhear most of the time (<40%) expect(successCount).toBeLessThan(40); }); it('2.5: Environment modifier affects listener perception', () => { const speaker = createChar({ stats: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, stealthBonus: 0 }); const listener = createChar({ stats: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, perceptionBonus: 0 }); const normalResult = rollStealthVsPerception(speaker, listener, 0); const bonusResult = rollStealthVsPerception(speaker, listener, 5); // +5 from SILENCE expect(bonusResult.listenerModifier).toBe(normalResult.listenerModifier + 5); }); }); describe('Category 3: interact_socially Tool - Basic Functionality', () => { it('3.1: Target always hears full conversation', async () => { const room = createRoom({ name: 'Tavern', biomeContext: 'urban' }); const speaker = createChar({ name: 'Rogue', currentRoomId: room.id }); const target = createChar({ name: 'Wizard', currentRoomId: room.id }); // Add characters to room spatialRepo.update(room.id, { entityIds: [speaker.id, target.id] }); const result = await handleInteractSocially({ speakerId: speaker.id, targetId: target.id, content: 'I found the secret passage', volume: 'WHISPER', intent: 'sharing information' }, mockCtx); const data = JSON.parse(result.content[0].text); expect(data.success).toBe(true); expect(data.target.id).toBe(target.id); expect(data.target.heard).toBe(true); // Check memory was recorded for target const targetMemories = memoryRepo.getConversationHistory(target.id, speaker.id); expect(targetMemories.length).toBe(1); expect(targetMemories[0].summary).toContain('I found the secret passage'); }); it('3.2: Eavesdropper gets partial "overheard" memory (successful perception)', async () => { const room = createRoom({ name: 'Tavern', biomeContext: 'urban' }); const speaker = createChar({ name: 'Rogue', currentRoomId: room.id, stats: { str: 10, dex: 8, con: 10, int: 10, wis: 10, cha: 10 }, // Low DEX stealthBonus: 0 }); const target = createChar({ name: 'Wizard', currentRoomId: room.id }); const eavesdropper = createChar({ name: 'Bard', currentRoomId: room.id, stats: { str: 10, dex: 10, con: 10, int: 10, wis: 18, cha: 10 }, // High WIS perceptionBonus: 5 }); spatialRepo.update(room.id, { entityIds: [speaker.id, target.id, eavesdropper.id] }); // Run multiple times to ensure we get at least one successful eavesdrop let successfulEavesdrop = false; for (let i = 0; i < 20; i++) { const result = await handleInteractSocially({ speakerId: speaker.id, targetId: target.id, content: 'The password is swordfish', volume: 'WHISPER', intent: 'sharing secret' }, mockCtx); const data = JSON.parse(result.content[0].text); const eavesdropperResult = data.listeners.find((l: any) => l.listenerId === eavesdropper.id); if (eavesdropperResult?.opposedRoll?.success) { successfulEavesdrop = true; // Check eavesdropper memory const memories = memoryRepo.getConversationHistory(eavesdropper.id, speaker.id); const latestMemory = memories[memories.length - 1]; expect(latestMemory.summary).toContain('Overheard'); expect(latestMemory.summary).not.toContain('swordfish'); // Should NOT contain actual password expect(latestMemory.topics).toContain('eavesdropped'); break; } // Clean up memories for next attempt db.prepare('DELETE FROM conversation_memories').run(); } expect(successfulEavesdrop).toBe(true); // Should have succeeded at least once }); it('3.3: Failed perception check = no memory recorded', async () => { const room = createRoom({ name: 'Tavern', biomeContext: 'urban' }); const speaker = createChar({ name: 'Rogue', currentRoomId: room.id, stats: { str: 10, dex: 18, con: 10, int: 10, wis: 10, cha: 10 }, // High DEX stealthBonus: 5 }); const target = createChar({ name: 'Wizard', currentRoomId: room.id }); const eavesdropper = createChar({ name: 'Barbarian', currentRoomId: room.id, stats: { str: 10, dex: 10, con: 10, int: 10, wis: 8, cha: 10 }, // Low WIS perceptionBonus: 0 }); spatialRepo.update(room.id, { entityIds: [speaker.id, target.id, eavesdropper.id] }); // Run multiple times to check for failures let failedAttempts = 0; for (let i = 0; i < 20; i++) { const result = await handleInteractSocially({ speakerId: speaker.id, targetId: target.id, content: 'Secret plans', volume: 'WHISPER', intent: 'conspiracy' }, mockCtx); const data = JSON.parse(result.content[0].text); const eavesdropperResult = data.listeners.find((l: any) => l.listenerId === eavesdropper.id); if (!eavesdropperResult?.opposedRoll?.success) { failedAttempts++; } } // With high stealth vs low perception, most attempts should fail expect(failedAttempts).toBeGreaterThan(10); // Check that barbarian has fewer memories than the number of whispers const barbarianMemories = memoryRepo.getConversationHistory(eavesdropper.id, speaker.id); expect(barbarianMemories.length).toBeLessThan(20); }); it('3.4: Shout broadcasts to everyone in room', async () => { const room = createRoom({ name: 'Town Square', biomeContext: 'urban' }); const speaker = createChar({ name: 'Herald', currentRoomId: room.id }); const listener1 = createChar({ name: 'Citizen 1', currentRoomId: room.id }); const listener2 = createChar({ name: 'Citizen 2', currentRoomId: room.id }); const listener3 = createChar({ name: 'Citizen 3', currentRoomId: room.id }); spatialRepo.update(room.id, { entityIds: [speaker.id, listener1.id, listener2.id, listener3.id] }); const result = await handleInteractSocially({ speakerId: speaker.id, content: 'Hear ye, hear ye!', volume: 'SHOUT' }, mockCtx); const data = JSON.parse(result.content[0].text); expect(data.success).toBe(true); expect(data.totalListeners).toBe(3); // All 3 citizens }); it('3.5: Deafened character cannot hear', async () => { const room = createRoom({ name: 'Tavern', biomeContext: 'urban' }); const speaker = createChar({ name: 'Bard', currentRoomId: room.id }); const deafListener = createChar({ name: 'Deafened Guard', currentRoomId: room.id, conditions: [{ name: 'DEAFENED' }] }); spatialRepo.update(room.id, { entityIds: [speaker.id, deafListener.id] }); const result = await handleInteractSocially({ speakerId: speaker.id, content: 'Can you hear me?', volume: 'TALK' }, mockCtx); const data = JSON.parse(result.content[0].text); // Deafened character should not be in listeners list const deafResult = data.listeners.find((l: any) => l.listenerId === deafListener.id); expect(deafResult).toBeUndefined(); }); it('3.6: Broadcast (no target) still performs opposed rolls', async () => { const room = createRoom({ name: 'Marketplace', biomeContext: 'urban' }); const speaker = createChar({ name: 'Merchant', currentRoomId: room.id, stats: { str: 10, dex: 12, con: 10, int: 10, wis: 10, cha: 10 } }); const listener = createChar({ name: 'Customer', currentRoomId: room.id, stats: { str: 10, dex: 10, con: 10, int: 10, wis: 12, cha: 10 } }); spatialRepo.update(room.id, { entityIds: [speaker.id, listener.id] }); const result = await handleInteractSocially({ speakerId: speaker.id, content: 'Fresh fish for sale!', volume: 'TALK' }, mockCtx); const data = JSON.parse(result.content[0].text); expect(data.target).toBeNull(); // No specific target expect(data.listeners.length).toBe(1); expect(data.listeners[0].opposedRoll).toBeDefined(); // Opposed roll still happened }); it('3.7: SILENCE atmosphere increases perception chances', async () => { const silentRoom = createRoom({ name: 'Silent Temple', biomeContext: 'divine', atmospherics: ['SILENCE'] }); const speaker = createChar({ name: 'Priest', currentRoomId: silentRoom.id, stats: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, stealthBonus: 0 }); const listener = createChar({ name: 'Monk', currentRoomId: silentRoom.id, stats: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, perceptionBonus: 0 }); spatialRepo.update(silentRoom.id, { entityIds: [speaker.id, listener.id] }); let successCount = 0; const trials = 20; for (let i = 0; i < trials; i++) { const result = await handleInteractSocially({ speakerId: speaker.id, content: 'Whispered prayer', volume: 'WHISPER' }, mockCtx); const data = JSON.parse(result.content[0].text); const listenerResult = data.listeners.find((l: any) => l.listenerId === listener.id); if (listenerResult?.opposedRoll?.success) { successCount++; } // Clean up db.prepare('DELETE FROM conversation_memories').run(); } // SILENCE gives +5 modifier, so success rate should be higher than 50% expect(successCount).toBeGreaterThan(10); }); }); describe('Category 4: Error Handling', () => { it('4.1: Throws error if speaker not found', async () => { await expect(handleInteractSocially({ speakerId: uuidv4(), content: 'Hello', volume: 'TALK' }, {} as any)).rejects.toThrow('Speaker with ID'); }); it('4.2: Throws error if speaker not in a room', async () => { const speaker = createChar({ name: 'Lost Soul' }); // No currentRoomId await expect(handleInteractSocially({ speakerId: speaker.id, content: 'Hello', volume: 'TALK' }, {} as any)).rejects.toThrow('not in any room'); }); it('4.3: Throws error if target not found', async () => { const room = createRoom({ name: 'Tavern' }); const speaker = createChar({ name: 'Bard', currentRoomId: room.id }); await expect(handleInteractSocially({ speakerId: speaker.id, targetId: uuidv4(), content: 'Hello', volume: 'TALK' }, {} as any)).rejects.toThrow('Target with ID'); }); it('4.4: Throws error if room not found (corrupted data)', async () => { // Create character, then manually update with invalid roomId to bypass FK constraint const speaker = createChar({ name: 'Glitched' }); const fakeRoomId = uuidv4(); // Temporarily disable foreign keys to simulate corrupted data db.prepare('PRAGMA foreign_keys = OFF').run(); db.prepare('UPDATE characters SET current_room_id = ? WHERE id = ?') .run(fakeRoomId, speaker.id); db.prepare('PRAGMA foreign_keys = ON').run(); await expect(handleInteractSocially({ speakerId: speaker.id, content: 'Hello', volume: 'TALK' }, mockCtx)).rejects.toThrow('Room'); }); }); describe('Category 5: Integration with Phase 1 Spatial System', () => { it('5.1: Uses room biome for hearing range calculation', async () => { const forestRoom = createRoom({ name: 'Forest Clearing', biomeContext: 'forest', atmospherics: [] }); const speaker = createChar({ name: 'Ranger', currentRoomId: forestRoom.id }); spatialRepo.update(forestRoom.id, { entityIds: [speaker.id] }); const result = await handleInteractSocially({ speakerId: speaker.id, content: 'Testing', volume: 'TALK' }, mockCtx); const data = JSON.parse(result.content[0].text); expect(data.room.biome).toBe('forest'); expect(data.hearingRadius).toBe(60); // Forest TALK range }); it('5.2: Uses room atmospherics for environment modifiers', async () => { const silentCavern = createRoom({ name: 'Silent Cavern', biomeContext: 'cavern', atmospherics: ['SILENCE'] }); const speaker = createChar({ name: 'Explorer', currentRoomId: silentCavern.id }); spatialRepo.update(silentCavern.id, { entityIds: [speaker.id] }); const result = await handleInteractSocially({ speakerId: speaker.id, content: 'Echo test', volume: 'SHOUT' }, mockCtx); const data = JSON.parse(result.content[0].text); expect(data.room.atmospherics).toContain('SILENCE'); // SILENCE reduces hearing by 50%: cavern SHOUT = 400, with SILENCE = 200 expect(data.hearingRadius).toBe(200); }); it('5.3: Only processes characters in same room', async () => { const tavern = createRoom({ name: 'Tavern', biomeContext: 'urban' }); const street = createRoom({ name: 'Street', biomeContext: 'urban' }); const speaker = createChar({ name: 'Bartender', currentRoomId: tavern.id }); const inRoom = createChar({ name: 'Patron', currentRoomId: tavern.id }); const outsideRoom = createChar({ name: 'Passerby', currentRoomId: street.id }); spatialRepo.update(tavern.id, { entityIds: [speaker.id, inRoom.id] }); spatialRepo.update(street.id, { entityIds: [outsideRoom.id] }); const result = await handleInteractSocially({ speakerId: speaker.id, content: 'Last call!', volume: 'SHOUT' }, mockCtx); const data = JSON.parse(result.content[0].text); // Only the patron in the same room should be processed expect(data.totalListeners).toBe(1); expect(data.listeners[0].listenerId).toBe(inRoom.id); }); }); describe('Category 6: Memory Content Verification', () => { it('6.1: Target memory contains full content', async () => { const room = createRoom({ name: 'Library', biomeContext: 'urban' }); const speaker = createChar({ name: 'Scholar', currentRoomId: room.id }); const target = createChar({ name: 'Student', currentRoomId: room.id }); spatialRepo.update(room.id, { entityIds: [speaker.id, target.id] }); await handleInteractSocially({ speakerId: speaker.id, targetId: target.id, content: 'The ancient text mentions a dragon', volume: 'TALK', intent: 'teaching' }, mockCtx); const memories = memoryRepo.getConversationHistory(target.id, speaker.id); expect(memories.length).toBe(1); expect(memories[0].summary).toContain('The ancient text mentions a dragon'); expect(memories[0].summary).toContain('Scholar'); expect(memories[0].topics).toContain('teaching'); }); it('6.2: Eavesdropper memory is generic (no exact content)', async () => { const room = createRoom({ name: 'Tavern', biomeContext: 'urban' }); const speaker = createChar({ name: 'Rogue', currentRoomId: room.id, stats: { str: 10, dex: 8, con: 10, int: 10, wis: 10, cha: 10 }, stealthBonus: 0 }); const target = createChar({ name: 'Fence', currentRoomId: room.id }); const eavesdropper = createChar({ name: 'Guard', currentRoomId: room.id, stats: { str: 10, dex: 10, con: 10, int: 10, wis: 16, cha: 10 }, perceptionBonus: 3 }); spatialRepo.update(room.id, { entityIds: [speaker.id, target.id, eavesdropper.id] }); // Try multiple times to get a successful eavesdrop for (let i = 0; i < 20; i++) { await handleInteractSocially({ speakerId: speaker.id, targetId: target.id, content: 'I can get you the royal jewels for 500 gold', volume: 'WHISPER', intent: 'negotiating theft' }, mockCtx); } const guardMemories = memoryRepo.getConversationHistory(eavesdropper.id, speaker.id); if (guardMemories.length > 0) { const memory = guardMemories[0]; expect(memory.summary).toContain('Overheard'); expect(memory.summary).not.toContain('royal jewels'); expect(memory.summary).not.toContain('500 gold'); expect(memory.topics).toContain('eavesdropped'); } }); it('6.3: Shout creates high importance memory for target', async () => { const room = createRoom({ name: 'Battlefield', biomeContext: 'dungeon' }); const speaker = createChar({ name: 'Commander', currentRoomId: room.id }); const target = createChar({ name: 'Soldier', currentRoomId: room.id }); spatialRepo.update(room.id, { entityIds: [speaker.id, target.id] }); await handleInteractSocially({ speakerId: speaker.id, targetId: target.id, content: 'Retreat immediately!', volume: 'SHOUT', intent: 'command' }, mockCtx); const memories = memoryRepo.getConversationHistory(target.id, speaker.id); expect(memories.length).toBe(1); expect(memories[0].importance).toBe('high'); // SHOUT = high importance }); it('6.4: Whisper creates medium importance memory for target', async () => { const room = createRoom({ name: 'Palace', biomeContext: 'urban' }); const speaker = createChar({ name: 'Spy', currentRoomId: room.id }); const target = createChar({ name: 'Assassin', currentRoomId: room.id }); spatialRepo.update(room.id, { entityIds: [speaker.id, target.id] }); await handleInteractSocially({ speakerId: speaker.id, targetId: target.id, content: 'The target sleeps at midnight', volume: 'WHISPER', intent: 'conspiracy' }, mockCtx); const memories = memoryRepo.getConversationHistory(target.id, speaker.id); expect(memories.length).toBe(1); expect(memories[0].importance).toBe('medium'); // WHISPER = medium importance }); }); });

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