Skip to main content
Glama
concentration.test.ts16.9 kB
import { describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; import { CharacterRepository } from '../../src/storage/repos/character.repo.js'; import { ConcentrationRepository } from '../../src/storage/repos/concentration.repo.js'; import { migrate } from '../../src/storage/migrations.js'; import { calculateConcentrationDC, rollConcentrationSave, checkConcentration, breakConcentration, startConcentration, checkConcentrationDuration, checkAutomaticConcentrationBreak, getConcentration, } from '../../src/engine/magic/concentration.js'; import type { Character } from '../../src/schema/character.js'; describe('Concentration System', () => { let db: Database.Database; let characterRepo: CharacterRepository; let concentrationRepo: ConcentrationRepository; let testCharacter: Character; beforeEach(() => { // Create in-memory database for tests db = new Database(':memory:'); migrate(db); characterRepo = new CharacterRepository(db); concentrationRepo = new ConcentrationRepository(db); // Create test character testCharacter = { id: 'test-wizard', name: 'Test Wizard', stats: { str: 10, dex: 14, con: 16, // +3 modifier int: 18, wis: 12, cha: 10, }, hp: 30, maxHp: 30, ac: 12, level: 5, characterType: 'pc', characterClass: 'wizard', knownSpells: ['haste', 'fireball', 'shield'], preparedSpells: ['haste', 'fireball', 'shield'], cantripsKnown: ['fire bolt'], maxSpellLevel: 3, spellSlots: { level1: { current: 4, max: 4 }, level2: { current: 3, max: 3 }, level3: { current: 2, max: 2 }, level4: { current: 0, max: 0 }, level5: { current: 0, max: 0 }, level6: { current: 0, max: 0 }, level7: { current: 0, max: 0 }, level8: { current: 0, max: 0 }, level9: { current: 0, max: 0 }, }, conditions: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; characterRepo.create(testCharacter); }); describe('Concentration DC Calculation', () => { it('should use DC 10 for damage less than 20', () => { expect(calculateConcentrationDC(5)).toBe(10); expect(calculateConcentrationDC(10)).toBe(10); expect(calculateConcentrationDC(19)).toBe(10); }); it('should use half damage for damage 20 or more', () => { expect(calculateConcentrationDC(20)).toBe(10); expect(calculateConcentrationDC(22)).toBe(11); expect(calculateConcentrationDC(30)).toBe(15); expect(calculateConcentrationDC(50)).toBe(25); }); }); describe('Starting Concentration', () => { it('should create concentration record', () => { startConcentration( testCharacter.id, 'Haste', 3, 1, // Round 1 10, // 10 rounds (1 minute) ['target-1'], concentrationRepo, characterRepo ); const concentration = concentrationRepo.findByCharacterId(testCharacter.id); expect(concentration).toBeTruthy(); expect(concentration?.activeSpell).toBe('Haste'); expect(concentration?.spellLevel).toBe(3); expect(concentration?.startedAt).toBe(1); expect(concentration?.maxDuration).toBe(10); expect(concentration?.targetIds).toEqual(['target-1']); }); it('should update character concentrating_on field', () => { startConcentration( testCharacter.id, 'Haste', 3, 1, 10, undefined, concentrationRepo, characterRepo ); const character = characterRepo.findById(testCharacter.id); expect(character?.concentratingOn).toBe('Haste'); }); it('should break existing concentration when starting new spell', () => { // Start first concentration startConcentration( testCharacter.id, 'Haste', 3, 1, 10, ['target-1'], concentrationRepo, characterRepo ); // Start second concentration startConcentration( testCharacter.id, 'Bless', 1, 2, 10, ['target-2'], concentrationRepo, characterRepo ); const concentration = concentrationRepo.findByCharacterId(testCharacter.id); expect(concentration?.activeSpell).toBe('Bless'); expect(concentration?.spellLevel).toBe(1); }); }); describe('Concentration Checks', () => { beforeEach(() => { // Start concentration before each test startConcentration( testCharacter.id, 'Haste', 3, 1, 10, ['target-1'], concentrationRepo, characterRepo ); }); it('should succeed on high constitution save', () => { // Character has CON +3, so roll of 7+ passes DC 10 // We can't easily mock the random roll, so we test the logic const result = checkConcentration(testCharacter, 10, concentrationRepo); expect(result.characterId).toBe(testCharacter.id); expect(result.spell).toBe('Haste'); expect(result.saveDC).toBe(10); expect(result.constitutionModifier).toBe(3); expect(typeof result.broken).toBe('boolean'); }); it('should handle high damage requiring higher DC', () => { const result = checkConcentration(testCharacter, 40, concentrationRepo); expect(result.saveDC).toBe(20); // Half of 40 expect(result.damageAmount).toBe(40); }); it('should return no check if not concentrating', () => { const otherCharacter: Character = { ...testCharacter, id: 'other-wizard', }; characterRepo.create(otherCharacter); const result = checkConcentration(otherCharacter, 10, concentrationRepo); expect(result.spell).toBe('none'); expect(result.broken).toBe(false); }); }); describe('Breaking Concentration', () => { beforeEach(() => { startConcentration( testCharacter.id, 'Haste', 3, 1, 10, ['target-1'], concentrationRepo, characterRepo ); }); it('should break concentration on voluntary end', () => { const result = breakConcentration( { characterId: testCharacter.id, reason: 'voluntary' }, concentrationRepo, characterRepo ); expect(result.broken).toBe(true); expect(result.reason).toBe('voluntary'); expect(concentrationRepo.isConcentrating(testCharacter.id)).toBe(false); }); it('should break concentration on death', () => { const result = breakConcentration( { characterId: testCharacter.id, reason: 'death' }, concentrationRepo, characterRepo ); expect(result.broken).toBe(true); expect(result.reason).toBe('death'); }); it('should break concentration on new spell', () => { const result = breakConcentration( { characterId: testCharacter.id, reason: 'new_spell' }, concentrationRepo, characterRepo ); expect(result.broken).toBe(true); expect(result.reason).toBe('new_spell'); }); it('should clear character concentrating_on field', () => { breakConcentration( { characterId: testCharacter.id, reason: 'voluntary' }, concentrationRepo, characterRepo ); const character = characterRepo.findById(testCharacter.id); expect(character?.concentratingOn).toBeNull(); }); }); describe('Duration Checks', () => { it('should not break concentration within duration', () => { startConcentration( testCharacter.id, 'Haste', 3, 1, 10, undefined, concentrationRepo, characterRepo ); const result = checkConcentrationDuration( testCharacter.id, 5, // Round 5 (4 rounds elapsed) concentrationRepo, characterRepo ); expect(result).toBeNull(); expect(concentrationRepo.isConcentrating(testCharacter.id)).toBe(true); }); it('should break concentration when duration exceeded', () => { startConcentration( testCharacter.id, 'Haste', 3, 1, 10, undefined, concentrationRepo, characterRepo ); const result = checkConcentrationDuration( testCharacter.id, 12, // Round 12 (11 rounds elapsed, exceeds max 10) concentrationRepo, characterRepo ); expect(result).toBeTruthy(); expect(result?.broken).toBe(true); expect(result?.reason).toBe('duration'); expect(concentrationRepo.isConcentrating(testCharacter.id)).toBe(false); }); it('should not break concentration with no duration limit', () => { startConcentration( testCharacter.id, 'Mage Armor', 1, 1, undefined, // No duration limit undefined, concentrationRepo, characterRepo ); const result = checkConcentrationDuration( testCharacter.id, 1000, // Many rounds later concentrationRepo, characterRepo ); expect(result).toBeNull(); }); }); describe('Automatic Concentration Breaks', () => { beforeEach(() => { startConcentration( testCharacter.id, 'Haste', 3, 1, 10, undefined, concentrationRepo, characterRepo ); }); it('should break concentration on death (hp <= 0)', () => { characterRepo.update(testCharacter.id, { hp: 0 }); const updatedChar = characterRepo.findById(testCharacter.id)!; const result = checkAutomaticConcentrationBreak( updatedChar, concentrationRepo, characterRepo ); expect(result).toBeTruthy(); expect(result?.broken).toBe(true); expect(result?.reason).toBe('death'); }); it('should break concentration when unconscious', () => { characterRepo.update(testCharacter.id, { conditions: [{ name: 'unconscious' }] }); const updatedChar = characterRepo.findById(testCharacter.id)!; const result = checkAutomaticConcentrationBreak( updatedChar, concentrationRepo, characterRepo ); expect(result).toBeTruthy(); expect(result?.reason).toBe('incapacitated'); }); it('should break concentration when stunned', () => { characterRepo.update(testCharacter.id, { conditions: [{ name: 'stunned' }] }); const updatedChar = characterRepo.findById(testCharacter.id)!; const result = checkAutomaticConcentrationBreak( updatedChar, concentrationRepo, characterRepo ); expect(result).toBeTruthy(); expect(result?.reason).toBe('incapacitated'); }); it('should not break concentration for non-incapacitating conditions', () => { characterRepo.update(testCharacter.id, { conditions: [{ name: 'frightened' }, { name: 'restrained' }] }); const updatedChar = characterRepo.findById(testCharacter.id)!; const result = checkAutomaticConcentrationBreak( updatedChar, concentrationRepo, characterRepo ); expect(result).toBeNull(); }); it('should not break if not concentrating', () => { characterRepo.update(testCharacter.id, { hp: 0 }); const updatedChar = characterRepo.findById(testCharacter.id)!; // Break concentration first breakConcentration( { characterId: testCharacter.id, reason: 'voluntary' }, concentrationRepo, characterRepo ); const result = checkAutomaticConcentrationBreak( updatedChar, concentrationRepo, characterRepo ); expect(result).toBeNull(); }); }); describe('Get Concentration State', () => { it('should return active concentration', () => { startConcentration( testCharacter.id, 'Haste', 3, 1, 10, ['target-1', 'target-2'], concentrationRepo, characterRepo ); const concentration = getConcentration(testCharacter.id, concentrationRepo); expect(concentration).toBeTruthy(); expect(concentration?.activeSpell).toBe('Haste'); expect(concentration?.spellLevel).toBe(3); expect(concentration?.targetIds).toEqual(['target-1', 'target-2']); }); it('should return null if not concentrating', () => { const concentration = getConcentration(testCharacter.id, concentrationRepo); expect(concentration).toBeNull(); }); }); describe('Integration: Full Concentration Flow', () => { it('should handle complete concentration lifecycle', () => { // Start concentration startConcentration( testCharacter.id, 'Haste', 3, 1, 10, ['ally-1'], concentrationRepo, characterRepo ); // Check state let concentration = getConcentration(testCharacter.id, concentrationRepo); expect(concentration?.activeSpell).toBe('Haste'); // Take minor damage - should maintain let checkResult = checkConcentration(testCharacter, 5, concentrationRepo); expect(checkResult.saveDC).toBe(10); // Take major damage - requires high save checkResult = checkConcentration(testCharacter, 40, concentrationRepo); expect(checkResult.saveDC).toBe(20); // Check duration (still within) let durationResult = checkConcentrationDuration( testCharacter.id, 5, concentrationRepo, characterRepo ); expect(durationResult).toBeNull(); // Check duration (exceeded) durationResult = checkConcentrationDuration( testCharacter.id, 15, concentrationRepo, characterRepo ); expect(durationResult?.broken).toBe(true); // Verify concentration ended concentration = getConcentration(testCharacter.id, concentrationRepo); expect(concentration).toBeNull(); }); }); });

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