Skip to main content
Glama
expanded-features.test.ts48.3 kB
/** * EXPANDED FEATURE TESTS * * Comprehensive edge case testing for: * - FAILED-001: NPC Memory System * - HIGH-006: Lair Actions * - HIGH-007: Legendary Creatures * - MED-003: Death Saving Throws * * Run: npm test -- tests/server/expanded-features.test.ts */ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import { v4 as uuid } from 'uuid'; import Database from 'better-sqlite3'; import { migrate } from '../../src/storage/migrations.js'; import { CombatEngine, CombatParticipant, DeathSaveResult } from '../../src/engine/combat/engine.js'; import { NpcMemoryRepository } from '../../src/storage/repos/npc-memory.repo.js'; import { CharacterRepository } from '../../src/storage/repos/character.repo.js'; import { EncounterRepository } from '../../src/storage/repos/encounter.repo.js'; let db: Database.Database; beforeEach(() => { db = new Database(':memory:'); migrate(db); }); afterEach(() => { db.close(); }); // ============================================================================ // SECTION 1: NPC MEMORY SYSTEM (FAILED-001) // ============================================================================ describe('NPC Memory System', () => { let memoryRepo: NpcMemoryRepository; const pcId = 'pc-hero-1'; const npcId = 'npc-merchant-1'; beforeEach(() => { memoryRepo = new NpcMemoryRepository(db); }); // --- Relationship Tests --- describe('Relationship Management', () => { test('1.1 - new relationship defaults to stranger/neutral', () => { const rel = memoryRepo.getRelationship(pcId, npcId); expect(rel).toBeNull(); // No relationship yet }); test('1.2 - can create relationship with all familiarity levels', () => { const levels = ['stranger', 'acquaintance', 'friend', 'close_friend', 'rival', 'enemy'] as const; for (const level of levels) { const testNpc = `npc-${level}`; memoryRepo.upsertRelationship({ characterId: pcId, npcId: testNpc, familiarity: level, disposition: 'neutral', notes: null }); const rel = memoryRepo.getRelationship(pcId, testNpc); expect(rel?.familiarity).toBe(level); } }); test('1.3 - can create relationship with all disposition levels', () => { const dispositions = ['hostile', 'unfriendly', 'neutral', 'friendly', 'helpful'] as const; for (const disp of dispositions) { const testNpc = `npc-${disp}`; memoryRepo.upsertRelationship({ characterId: pcId, npcId: testNpc, familiarity: 'acquaintance', disposition: disp, notes: null }); const rel = memoryRepo.getRelationship(pcId, testNpc); expect(rel?.disposition).toBe(disp); } }); test('1.4 - updating relationship increments interaction count', () => { memoryRepo.upsertRelationship({ characterId: pcId, npcId: npcId, familiarity: 'stranger', disposition: 'neutral', notes: null }); const rel1 = memoryRepo.getRelationship(pcId, npcId); expect(rel1?.interactionCount).toBe(1); memoryRepo.upsertRelationship({ characterId: pcId, npcId: npcId, familiarity: 'acquaintance', disposition: 'friendly', notes: 'Helped with quest' }); const rel2 = memoryRepo.getRelationship(pcId, npcId); expect(rel2?.interactionCount).toBe(2); expect(rel2?.familiarity).toBe('acquaintance'); }); test('1.5 - multiple PCs can have different relationships with same NPC', () => { const pc1 = 'pc-warrior'; const pc2 = 'pc-rogue'; memoryRepo.upsertRelationship({ characterId: pc1, npcId: npcId, familiarity: 'friend', disposition: 'friendly', notes: null }); memoryRepo.upsertRelationship({ characterId: pc2, npcId: npcId, familiarity: 'enemy', disposition: 'hostile', notes: 'Caught stealing' }); const rel1 = memoryRepo.getRelationship(pc1, npcId); const rel2 = memoryRepo.getRelationship(pc2, npcId); expect(rel1?.familiarity).toBe('friend'); expect(rel2?.familiarity).toBe('enemy'); }); test('1.6 - relationship notes can be updated', () => { memoryRepo.upsertRelationship({ characterId: pcId, npcId: npcId, familiarity: 'acquaintance', disposition: 'neutral', notes: 'First meeting' }); memoryRepo.upsertRelationship({ characterId: pcId, npcId: npcId, familiarity: 'acquaintance', disposition: 'neutral', notes: 'Updated: Helped find lost item' }); const rel = memoryRepo.getRelationship(pcId, npcId); expect(rel?.notes).toBe('Updated: Helped find lost item'); }); }); // --- Conversation Memory Tests --- describe('Conversation Memory', () => { test('1.7 - can record conversation memory', () => { const memory = memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Discussed the dragon threat', importance: 'high', topics: ['dragon', 'quest', 'danger'] }); expect(memory.id).toBeDefined(); expect(memory.summary).toBe('Discussed the dragon threat'); expect(memory.importance).toBe('high'); }); test('1.8 - conversation history returns most recent first', () => { memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'First conversation', importance: 'low', topics: [] }); memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Second conversation', importance: 'medium', topics: [] }); memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Third conversation', importance: 'high', topics: [] }); const history = memoryRepo.getConversationHistory(pcId, npcId, {}); expect(history.length).toBe(3); expect(history[0].summary).toBe('Third conversation'); }); test('1.9 - can filter by minimum importance', () => { memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Casual chat', importance: 'low', topics: [] }); memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Important info', importance: 'high', topics: [] }); memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Critical revelation', importance: 'critical', topics: [] }); const highAndUp = memoryRepo.getConversationHistory(pcId, npcId, { minImportance: 'high' }); expect(highAndUp.length).toBe(2); expect(highAndUp.every(m => m.importance === 'high' || m.importance === 'critical')).toBe(true); }); test('1.10 - can limit number of memories returned', () => { for (let i = 0; i < 10; i++) { memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: `Conversation ${i}`, importance: 'medium', topics: [] }); } const limited = memoryRepo.getConversationHistory(pcId, npcId, { limit: 3 }); expect(limited.length).toBe(3); }); test('1.11 - topics are stored and retrieved correctly', () => { memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Quest discussion', importance: 'high', topics: ['dragon', 'treasure', 'dungeon', 'reward'] }); const history = memoryRepo.getConversationHistory(pcId, npcId, {}); expect(history[0].topics).toContain('dragon'); expect(history[0].topics).toContain('treasure'); expect(history[0].topics.length).toBe(4); }); test('1.12 - recent interactions across all NPCs', () => { const npcs = ['npc-1', 'npc-2', 'npc-3']; for (const npc of npcs) { memoryRepo.recordMemory({ characterId: pcId, npcId: npc, summary: `Talked to ${npc}`, importance: 'medium', topics: [] }); } const recent = memoryRepo.getRecentInteractions(pcId, 10); expect(recent.length).toBe(3); }); }); }); // ============================================================================ // SECTION 2: LAIR ACTIONS (HIGH-006) // ============================================================================ describe('Lair Action System', () => { describe('Initiative 20 Turn Order', () => { test('2.1 - LAIR is inserted at initiative 20 when creature has hasLairActions', () => { const engine = new CombatEngine('lair-test-1'); const participants: CombatParticipant[] = [ { id: 'dragon', name: 'Red Dragon', initiativeBonus: 5, hp: 200, maxHp: 200, conditions: [], hasLairActions: true }, { id: 'hero1', name: 'Fighter', initiativeBonus: 2, hp: 50, maxHp: 50, conditions: [] }, { id: 'hero2', name: 'Wizard', initiativeBonus: 3, hp: 30, maxHp: 30, conditions: [] } ]; const state = engine.startEncounter(participants); expect(state.turnOrder).toContain('LAIR'); expect(state.hasLairActions).toBe(true); expect(state.lairOwnerId).toBe('dragon'); }); test('2.2 - LAIR not inserted when no creature has hasLairActions', () => { const engine = new CombatEngine('no-lair-test'); const participants: CombatParticipant[] = [ { id: 'goblin', name: 'Goblin Boss', initiativeBonus: 2, hp: 20, maxHp: 20, conditions: [] }, { id: 'hero', name: 'Fighter', initiativeBonus: 3, hp: 50, maxHp: 50, conditions: [] } ]; const state = engine.startEncounter(participants); expect(state.turnOrder).not.toContain('LAIR'); expect(state.hasLairActions).toBe(false); }); test('2.3 - isLairActionPending returns true only on LAIR turn', () => { const engine = new CombatEngine('lair-pending-test'); const participants: CombatParticipant[] = [ { id: 'dragon', name: 'Dragon', initiativeBonus: 25, hp: 200, maxHp: 200, conditions: [], hasLairActions: true }, { id: 'hero', name: 'Hero', initiativeBonus: 1, hp: 50, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); // Find LAIR in turn order and advance to it const state = engine.getState()!; while (state.turnOrder[state.currentTurnIndex] !== 'LAIR') { engine.nextTurn(); } expect(engine.isLairActionPending()).toBe(true); // Advance past LAIR engine.nextTurn(); expect(engine.isLairActionPending()).toBe(false); }); test('2.4 - getCurrentParticipant returns null on LAIR turn', () => { const engine = new CombatEngine('lair-null-test'); const participants: CombatParticipant[] = [ { id: 'dragon', name: 'Dragon', initiativeBonus: 25, hp: 200, maxHp: 200, conditions: [], hasLairActions: true }, { id: 'hero', name: 'Hero', initiativeBonus: 1, hp: 50, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); const state = engine.getState()!; // Advance to LAIR turn while (state.turnOrder[state.currentTurnIndex] !== 'LAIR') { engine.nextTurn(); } expect(engine.getCurrentParticipant()).toBeNull(); }); }); describe('Lair Action Effects', () => { test('2.5 - lair actions can deal damage to multiple targets', () => { const engine = new CombatEngine('lair-damage-test'); const participants: CombatParticipant[] = [ { id: 'dragon', name: 'Dragon', initiativeBonus: 25, hp: 200, maxHp: 200, conditions: [], hasLairActions: true }, { id: 'hero1', name: 'Fighter', initiativeBonus: 5, hp: 50, maxHp: 50, conditions: [] }, { id: 'hero2', name: 'Wizard', initiativeBonus: 3, hp: 30, maxHp: 30, conditions: [] } ]; engine.startEncounter(participants); // Apply lair action damage to both heroes engine.applyDamage('hero1', 10); engine.applyDamage('hero2', 10); const state = engine.getState()!; const hero1 = state.participants.find(p => p.id === 'hero1'); const hero2 = state.participants.find(p => p.id === 'hero2'); expect(hero1?.hp).toBe(40); expect(hero2?.hp).toBe(20); }); test('2.6 - lair action damage respects immunities', () => { const engine = new CombatEngine('lair-immunity-test'); const participants: CombatParticipant[] = [ { id: 'dragon', name: 'Fire Dragon', initiativeBonus: 25, hp: 200, maxHp: 200, conditions: [], hasLairActions: true }, { id: 'salamander', name: 'Fire Salamander', initiativeBonus: 5, hp: 50, maxHp: 50, conditions: [], immunities: ['fire'] } ]; engine.startEncounter(participants); // Salamander should be immune to fire lair actions // Note: This is validated in combat-tools, engine just tracks the state const state = engine.getState()!; const salamander = state.participants.find(p => p.id === 'salamander'); expect(salamander?.immunities).toContain('fire'); }); test('2.7 - legendary creature can use legendary actions after other creature turns', () => { const engine = new CombatEngine('legendary-action-test'); const participants: CombatParticipant[] = [ { id: 'dragon', name: 'Dragon', initiativeBonus: 25, hp: 200, maxHp: 200, conditions: [], legendaryActions: 3, legendaryActionsRemaining: 3 }, { id: 'hero', name: 'Fighter', initiativeBonus: 5, hp: 50, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); const state = engine.getState()!; // Advance to hero's turn while (state.turnOrder[state.currentTurnIndex] !== 'hero') { engine.nextTurn(); } // Dragon should be able to use legendary action now (after hero's turn) expect(engine.canUseLegendaryAction('dragon')).toBe(true); }); test('2.8 - cannot use legendary action on own turn', () => { const engine = new CombatEngine('legendary-own-turn-test'); const participants: CombatParticipant[] = [ { id: 'dragon', name: 'Dragon', initiativeBonus: 25, hp: 200, maxHp: 200, conditions: [], legendaryActions: 3, legendaryActionsRemaining: 3 }, { id: 'hero', name: 'Fighter', initiativeBonus: 5, hp: 50, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); const state = engine.getState()!; // Ensure it's dragon's turn while (state.turnOrder[state.currentTurnIndex] !== 'dragon') { engine.nextTurn(); } expect(engine.canUseLegendaryAction('dragon')).toBe(false); }); }); }); // ============================================================================ // SECTION 3: LEGENDARY CREATURES (HIGH-007) // ============================================================================ describe('Legendary Creature System', () => { let charRepo: CharacterRepository; beforeEach(() => { charRepo = new CharacterRepository(db); }); describe('Legendary Actions', () => { test('3.1 - legendary action fields persist in database', () => { const dragon = { id: uuid(), name: 'Ancient Red Dragon', stats: { str: 27, dex: 10, con: 25, int: 16, wis: 13, cha: 21 }, hp: 546, maxHp: 546, ac: 22, level: 20, legendaryActions: 3, legendaryActionsRemaining: 3, legendaryResistances: 3, legendaryResistancesRemaining: 3, hasLairActions: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(dragon); const retrieved = charRepo.findById(dragon.id); expect(retrieved?.legendaryActions).toBe(3); expect(retrieved?.legendaryActionsRemaining).toBe(3); expect(retrieved?.legendaryResistances).toBe(3); expect(retrieved?.legendaryResistancesRemaining).toBe(3); expect(retrieved?.hasLairActions).toBe(true); }); test('3.2 - legendary actions remaining can be updated', () => { const dragon = { id: uuid(), name: 'Dragon', stats: { str: 20, dex: 10, con: 20, int: 10, wis: 10, cha: 10 }, hp: 200, maxHp: 200, ac: 18, level: 15, legendaryActions: 3, legendaryActionsRemaining: 3, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(dragon); charRepo.update(dragon.id, { legendaryActionsRemaining: 1 }); const retrieved = charRepo.findById(dragon.id); expect(retrieved?.legendaryActionsRemaining).toBe(1); }); test('3.3 - non-legendary creature has undefined legendary fields', () => { const goblin = { id: uuid(), name: 'Goblin', stats: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 }, hp: 7, maxHp: 7, ac: 15, level: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(goblin); const retrieved = charRepo.findById(goblin.id); expect(retrieved?.legendaryActions).toBeUndefined(); expect(retrieved?.hasLairActions).toBe(false); }); }); describe('Damage Modifiers', () => { test('3.4 - resistances persist in database', () => { const demon = { id: uuid(), name: 'Balor', stats: { str: 26, dex: 15, con: 22, int: 20, wis: 16, cha: 22 }, hp: 262, maxHp: 262, ac: 19, level: 19, resistances: ['cold', 'lightning', 'bludgeoning', 'piercing', 'slashing'], immunities: ['fire', 'poison'], vulnerabilities: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(demon); const retrieved = charRepo.findById(demon.id); expect(retrieved?.resistances).toContain('cold'); expect(retrieved?.resistances).toContain('lightning'); expect(retrieved?.immunities).toContain('fire'); expect(retrieved?.immunities).toContain('poison'); }); test('3.5 - vulnerabilities persist in database', () => { const treant = { id: uuid(), name: 'Treant', stats: { str: 23, dex: 8, con: 21, int: 12, wis: 16, cha: 12 }, hp: 138, maxHp: 138, ac: 16, level: 9, resistances: ['bludgeoning', 'piercing'], vulnerabilities: ['fire'], immunities: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(treant); const retrieved = charRepo.findById(treant.id); expect(retrieved?.vulnerabilities).toContain('fire'); }); test('3.6 - empty damage modifier arrays are handled correctly', () => { const fighter = { id: uuid(), name: 'Fighter', stats: { str: 16, dex: 14, con: 14, int: 10, wis: 12, cha: 10 }, hp: 45, maxHp: 45, ac: 18, level: 5, resistances: [], vulnerabilities: [], immunities: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(fighter); const retrieved = charRepo.findById(fighter.id); expect(retrieved?.resistances).toEqual([]); expect(retrieved?.vulnerabilities).toEqual([]); expect(retrieved?.immunities).toEqual([]); }); }); describe('Legendary Resistances', () => { test('3.7 - legendary resistance can be consumed', () => { const dragon = { id: uuid(), name: 'Ancient Dragon', stats: { str: 27, dex: 10, con: 25, int: 16, wis: 13, cha: 21 }, hp: 500, maxHp: 500, ac: 22, level: 20, legendaryResistances: 3, legendaryResistancesRemaining: 3, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(dragon); // Use one legendary resistance charRepo.update(dragon.id, { legendaryResistancesRemaining: 2 }); const retrieved = charRepo.findById(dragon.id); expect(retrieved?.legendaryResistancesRemaining).toBe(2); expect(retrieved?.legendaryResistances).toBe(3); // Total unchanged }); test('3.8 - legendary resistance does not reset on short rest', () => { // This is a design principle test - legendary resistances are per-day const dragon = { id: uuid(), name: 'Dragon', stats: { str: 20, dex: 10, con: 20, int: 10, wis: 10, cha: 10 }, hp: 200, maxHp: 200, ac: 18, level: 15, legendaryResistances: 3, legendaryResistancesRemaining: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(dragon); // Simulate short rest - only HP changes charRepo.update(dragon.id, { hp: 200 }); const retrieved = charRepo.findById(dragon.id); expect(retrieved?.legendaryResistancesRemaining).toBe(1); // Not restored }); }); }); // ============================================================================ // SECTION 4: DEATH SAVING THROWS (MED-003) // ============================================================================ describe('Death Saving Throw System', () => { describe('Basic Death Save Mechanics', () => { test('4.1 - character at 0 HP can roll death save', () => { const engine = new CombatEngine('death-save-basic'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [] }, { id: 'goblin', name: 'Goblin', initiativeBonus: 1, hp: 7, maxHp: 7, conditions: [] } ]; engine.startEncounter(participants); const result = engine.rollDeathSave('hero'); expect(result).not.toBeNull(); expect(result!.roll).toBeGreaterThanOrEqual(1); expect(result!.roll).toBeLessThanOrEqual(20); }); test('4.2 - character with HP > 0 cannot roll death save', () => { const engine = new CombatEngine('death-save-not-zero'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 10, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); const result = engine.rollDeathSave('hero'); expect(result).toBeNull(); }); test('4.3 - roll of 10+ is a success', () => { const engine = new CombatEngine('death-save-success'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); // Roll until we get a success (10+) let result: DeathSaveResult | null = null; for (let i = 0; i < 100; i++) { // Reset the participant const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; hero.deathSaveSuccesses = 0; hero.deathSaveFailures = 0; hero.hp = 0; hero.isDead = false; hero.isStabilized = false; result = engine.rollDeathSave('hero'); if (result && result.roll >= 10 && !result.isNat20) break; } if (result && result.roll >= 10 && !result.isNat20) { expect(result.success).toBe(true); } }); test('4.4 - roll of 9 or less is a failure', () => { const engine = new CombatEngine('death-save-failure'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); // Roll until we get a failure (9 or less, not nat 1) let result: DeathSaveResult | null = null; for (let i = 0; i < 100; i++) { const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; hero.deathSaveSuccesses = 0; hero.deathSaveFailures = 0; hero.hp = 0; hero.isDead = false; hero.isStabilized = false; result = engine.rollDeathSave('hero'); if (result && result.roll <= 9 && !result.isNat1) break; } if (result && result.roll <= 9 && !result.isNat1) { expect(result.success).toBe(false); } }); }); describe('Natural 20 and Natural 1', () => { test('4.5 - natural 20 regains 1 HP', () => { const engine = new CombatEngine('death-save-nat20'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); // Keep rolling until we get a nat 20 (or skip after 1000 tries) let gotNat20 = false; for (let i = 0; i < 1000; i++) { const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; hero.deathSaveSuccesses = 0; hero.deathSaveFailures = 0; hero.hp = 0; hero.isDead = false; hero.isStabilized = false; const result = engine.rollDeathSave('hero'); if (result?.isNat20) { gotNat20 = true; expect(hero.hp).toBe(1); expect(result.regainedHp).toBe(true); break; } } // If we didn't get nat 20, skip this test (probability issue, not bug) if (!gotNat20) { console.log('Skipping nat 20 test - did not roll nat 20 in 1000 attempts'); } }); test('4.6 - natural 1 counts as 2 failures', () => { const engine = new CombatEngine('death-save-nat1'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [] } ]; engine.startEncounter(participants); // Keep rolling until we get a nat 1 let gotNat1 = false; for (let i = 0; i < 1000; i++) { const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; hero.deathSaveSuccesses = 0; hero.deathSaveFailures = 0; hero.hp = 0; hero.isDead = false; hero.isStabilized = false; const result = engine.rollDeathSave('hero'); if (result?.isNat1) { gotNat1 = true; expect(result.failures).toBe(2); break; } } if (!gotNat1) { console.log('Skipping nat 1 test - did not roll nat 1 in 1000 attempts'); } }); }); describe('Stabilization and Death', () => { test('4.7 - 3 successes stabilizes character', () => { const engine = new CombatEngine('death-save-stabilize'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 2, deathSaveFailures: 0 } ]; engine.startEncounter(participants); // Keep rolling until we get a success for (let i = 0; i < 100; i++) { const result = engine.rollDeathSave('hero'); if (result === null) break; // Stabilized or dead if (result.isStabilized) { expect(result.successes).toBe(3); break; } if (result.isDead) { // Reset and try again const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; hero.deathSaveSuccesses = 2; hero.deathSaveFailures = 0; hero.isDead = false; hero.isStabilized = false; } } }); test('4.8 - 3 failures kills character', () => { const engine = new CombatEngine('death-save-death'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 0, deathSaveFailures: 2 } ]; engine.startEncounter(participants); // Keep rolling until we get a failure (or nat 1 for 2 failures) for (let i = 0; i < 100; i++) { const result = engine.rollDeathSave('hero'); if (result === null) break; // Already dead if (result.isDead) { expect(result.failures).toBe(3); break; } if (result.isStabilized) { // Reset and try again const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; hero.deathSaveSuccesses = 0; hero.deathSaveFailures = 2; hero.isDead = false; hero.isStabilized = false; } } }); test('4.9 - cannot roll death save when stabilized', () => { const engine = new CombatEngine('death-save-already-stable'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], isStabilized: true } ]; engine.startEncounter(participants); const result = engine.rollDeathSave('hero'); expect(result).toBeNull(); }); test('4.10 - cannot roll death save when dead', () => { const engine = new CombatEngine('death-save-already-dead'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], isDead: true } ]; engine.startEncounter(participants); const result = engine.rollDeathSave('hero'); expect(result).toBeNull(); }); }); describe('Healing Interactions', () => { test('4.11 - healing from 0 HP resets death saves', () => { const engine = new CombatEngine('death-save-heal-reset'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 2, deathSaveFailures: 1 } ]; engine.startEncounter(participants); // Heal the character engine.heal('hero', 10); const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; expect(hero.hp).toBe(10); expect(hero.deathSaveSuccesses).toBe(0); expect(hero.deathSaveFailures).toBe(0); }); test('4.12 - healing resets stabilization flag', () => { const engine = new CombatEngine('death-save-heal-unstabilize'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], isStabilized: true } ]; engine.startEncounter(participants); engine.heal('hero', 5); const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; expect(hero.hp).toBe(5); expect(hero.isStabilized).toBe(false); }); }); describe('Damage at 0 HP', () => { test('4.13 - taking damage at 0 HP causes death save failure', () => { const engine = new CombatEngine('death-save-damage-failure'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 0, deathSaveFailures: 0 } ]; engine.startEncounter(participants); engine.applyDamageAtZeroHp('hero', false); const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; expect(hero.deathSaveFailures).toBe(1); }); test('4.14 - critical hit at 0 HP causes 2 death save failures', () => { const engine = new CombatEngine('death-save-crit-failure'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 0, deathSaveFailures: 0 } ]; engine.startEncounter(participants); engine.applyDamageAtZeroHp('hero', true); // Critical hit const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; expect(hero.deathSaveFailures).toBe(2); }); test('4.15 - damage at 0 HP breaks stabilization', () => { const engine = new CombatEngine('death-save-break-stable'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], isStabilized: true, deathSaveSuccesses: 3, deathSaveFailures: 0 } ]; engine.startEncounter(participants); engine.applyDamageAtZeroHp('hero', false); const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; expect(hero.isStabilized).toBe(false); expect(hero.deathSaveFailures).toBe(1); }); test('4.16 - damage at 0 HP with 2 failures already kills instantly', () => { const engine = new CombatEngine('death-save-instant-death'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 0, deathSaveFailures: 2 } ]; engine.startEncounter(participants); engine.applyDamageAtZeroHp('hero', false); const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; expect(hero.deathSaveFailures).toBe(3); expect(hero.isDead).toBe(true); }); test('4.17 - critical at 2 failures also kills', () => { const engine = new CombatEngine('death-save-crit-instant-death'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 0, deathSaveFailures: 1 } ]; engine.startEncounter(participants); engine.applyDamageAtZeroHp('hero', true); // Critical adds 2 failures const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; expect(hero.deathSaveFailures).toBe(3); expect(hero.isDead).toBe(true); }); test('4.18 - dead characters cannot take more damage', () => { const engine = new CombatEngine('death-save-dead-no-more-damage'); const participants: CombatParticipant[] = [ { id: 'hero', name: 'Fighter', initiativeBonus: 2, hp: 0, maxHp: 50, conditions: [], isDead: true, deathSaveFailures: 3 } ]; engine.startEncounter(participants); engine.applyDamageAtZeroHp('hero', false); const state = engine.getState()!; const hero = state.participants.find(p => p.id === 'hero')!; // Failures should not increase beyond 3 expect(hero.deathSaveFailures).toBe(3); }); }); }); // ============================================================================ // SECTION 5: INTEGRATION TESTS // ============================================================================ describe('Integration: Combined Systems', () => { let charRepo: CharacterRepository; let memoryRepo: NpcMemoryRepository; beforeEach(() => { charRepo = new CharacterRepository(db); memoryRepo = new NpcMemoryRepository(db); }); test('5.1 - legendary creature encounter with lair actions and death saves', () => { // Create the dragon const dragon = { id: uuid(), name: 'Ancient Red Dragon', stats: { str: 27, dex: 10, con: 25, int: 16, wis: 13, cha: 21 }, hp: 546, maxHp: 546, ac: 22, level: 20, legendaryActions: 3, legendaryActionsRemaining: 3, legendaryResistances: 3, legendaryResistancesRemaining: 3, hasLairActions: true, immunities: ['fire'], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(dragon); // Create heroes const hero = { id: uuid(), name: 'Brave Fighter', stats: { str: 18, dex: 14, con: 16, int: 10, wis: 12, cha: 10 }, hp: 85, maxHp: 85, ac: 20, level: 10, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; charRepo.create(hero); // Start combat const engine = new CombatEngine('dragon-battle'); const state = engine.startEncounter([ { id: dragon.id, name: dragon.name, initiativeBonus: 0, hp: dragon.hp, maxHp: dragon.maxHp, conditions: [], hasLairActions: true, legendaryActions: 3, legendaryActionsRemaining: 3, immunities: ['fire'] }, { id: hero.id, name: hero.name, initiativeBonus: 4, hp: hero.hp, maxHp: hero.maxHp, conditions: [] } ]); // Verify lair actions are set up expect(state.turnOrder).toContain('LAIR'); expect(state.hasLairActions).toBe(true); expect(state.lairOwnerId).toBe(dragon.id); // Hero takes massive damage, drops to 0 engine.applyDamage(hero.id, 100); const heroState = state.participants.find(p => p.id === hero.id)!; expect(heroState.hp).toBe(0); // Hero can now make death saves const deathSave = engine.rollDeathSave(hero.id); expect(deathSave).not.toBeNull(); }); test('5.2 - NPC memory persists through combat encounters', () => { const pcId = uuid(); const npcId = uuid(); // Create relationship before combat memoryRepo.upsertRelationship({ characterId: pcId, npcId: npcId, familiarity: 'enemy', disposition: 'hostile', notes: 'Sworn nemesis' }); // Record conversation memoryRepo.recordMemory({ characterId: pcId, npcId: npcId, summary: 'Exchanged threats before battle', importance: 'high', topics: ['combat', 'revenge'] }); // Simulate combat (memory should persist) const engine = new CombatEngine('nemesis-battle'); engine.startEncounter([ { id: pcId, name: 'Hero', initiativeBonus: 3, hp: 50, maxHp: 50, conditions: [] }, { id: npcId, name: 'Nemesis', initiativeBonus: 5, hp: 80, maxHp: 80, conditions: [] } ]); // Verify memory still exists after combat starts const relationship = memoryRepo.getRelationship(pcId, npcId); expect(relationship?.familiarity).toBe('enemy'); expect(relationship?.disposition).toBe('hostile'); const memories = memoryRepo.getConversationHistory(pcId, npcId, {}); expect(memories.length).toBe(1); expect(memories[0].topics).toContain('revenge'); }); test('5.3 - damage modifiers apply correctly in combat', () => { const engine = new CombatEngine('damage-mod-test'); // Fire elemental is immune to fire, vulnerable to cold const participants: CombatParticipant[] = [ { id: 'elemental', name: 'Fire Elemental', initiativeBonus: 5, hp: 100, maxHp: 100, conditions: [], immunities: ['fire'], vulnerabilities: ['cold'], resistances: [] }, { id: 'mage', name: 'Ice Mage', initiativeBonus: 3, hp: 40, maxHp: 40, conditions: [] } ]; engine.startEncounter(participants); const state = engine.getState()!; const elemental = state.participants.find(p => p.id === 'elemental')!; // Verify damage modifiers are tracked expect(elemental.immunities).toContain('fire'); expect(elemental.vulnerabilities).toContain('cold'); }); test('5.4 - multiple legendary creatures in same encounter', () => { const engine = new CombatEngine('double-dragon'); const participants: CombatParticipant[] = [ { id: 'dragon1', name: 'Red Dragon', initiativeBonus: 5, hp: 200, maxHp: 200, conditions: [], hasLairActions: true, legendaryActions: 3, legendaryActionsRemaining: 3 }, { id: 'dragon2', name: 'Blue Dragon', initiativeBonus: 5, hp: 225, maxHp: 225, conditions: [], legendaryActions: 3, legendaryActionsRemaining: 3 }, { id: 'hero', name: 'Hero', initiativeBonus: 3, hp: 100, maxHp: 100, conditions: [] } ]; const state = engine.startEncounter(participants); // Only one LAIR entry (first creature with hasLairActions) const lairCount = state.turnOrder.filter(id => id === 'LAIR').length; expect(lairCount).toBe(1); expect(state.lairOwnerId).toBe('dragon1'); // Both dragons should have legendary actions const dragon1 = state.participants.find(p => p.id === 'dragon1')!; const dragon2 = state.participants.find(p => p.id === 'dragon2')!; expect(dragon1.legendaryActions).toBe(3); expect(dragon2.legendaryActions).toBe(3); }); test('5.5 - death saves tracked separately per character', () => { const engine = new CombatEngine('multi-death-save'); const participants: CombatParticipant[] = [ { id: 'hero1', name: 'Fighter', initiativeBonus: 3, hp: 0, maxHp: 50, conditions: [], deathSaveSuccesses: 0, deathSaveFailures: 0 }, { id: 'hero2', name: 'Rogue', initiativeBonus: 5, hp: 0, maxHp: 40, conditions: [], deathSaveSuccesses: 0, deathSaveFailures: 0 }, { id: 'goblin', name: 'Goblin', initiativeBonus: 2, hp: 7, maxHp: 7, conditions: [] } ]; engine.startEncounter(participants); // Roll death saves for both heroes engine.rollDeathSave('hero1'); engine.rollDeathSave('hero2'); const state = engine.getState()!; const hero1 = state.participants.find(p => p.id === 'hero1')!; const hero2 = state.participants.find(p => p.id === 'hero2')!; // Each should have their own death save state const hero1Total = (hero1.deathSaveSuccesses || 0) + (hero1.deathSaveFailures || 0); const hero2Total = (hero2.deathSaveSuccesses || 0) + (hero2.deathSaveFailures || 0); // Check hero1 (handle Nat 20 case where hp becomes 1 and saves reset) const hero1Valid = hero1Total >= 1 || hero1.hp === 1; expect(hero1Valid, `Hero 1 should have rolled a death save (Total: ${hero1Total}, HP: ${hero1.hp})`).toBe(true); // Check hero2 const hero2Valid = hero2Total >= 1 || hero2.hp === 1; expect(hero2Valid, `Hero 2 should have rolled a death save (Total: ${hero2Total}, HP: ${hero2.hp})`).toBe(true); }); });

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