Skip to main content
Glama
hp-persistence.test.ts10.6 kB
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { handleCreateEncounter, handleExecuteCombatAction, handleEndEncounter, handleGetEncounterState, clearCombatState } from '../../src/server/combat-tools'; import { handleCreateCharacter, handleGetCharacter, closeTestDb } from '../../src/server/crud-tools'; import { closeDb, getDb } from '../../src/storage'; const mockCtx = { sessionId: 'test-session' }; /** * CRIT-001: HP Desynchronization After Combat * * Player Experience: * I created a character with 50 HP. Entered combat, took 20 damage (now at 30 HP). * Combat ended. Later I checked my character and they're back at 50 HP. * I basically can't die because damage doesn't persist. * * Root Cause: * Combat encounter has its own participant state that includes HP, but this HP * isn't synced back to the character table when the encounter ends. */ describe('CRIT-001: HP Persistence After Combat', () => { beforeEach(() => { closeDb(); getDb(':memory:'); clearCombatState(); }); afterEach(() => { closeTestDb(); }); it('should persist HP changes after encounter ends', async () => { // 1. Create a character with 50 HP const charResult = await handleCreateCharacter({ name: 'Test Hero', stats: { str: 16, dex: 14, con: 15, int: 10, wis: 12, cha: 8 }, hp: 50, maxHp: 50, ac: 16, level: 3 }, mockCtx); const character = JSON.parse(charResult.content[0].text); expect(character.hp).toBe(50); // 2. Create an encounter with this character and an enemy const encounterResult = await handleCreateEncounter({ seed: 'hp-persistence-test', participants: [ { id: character.id, name: character.name, initiativeBonus: 2, hp: character.hp, maxHp: character.maxHp, conditions: [] }, { id: 'enemy-goblin', name: 'Goblin', initiativeBonus: 1, hp: 10, maxHp: 10, isEnemy: true, conditions: [] } ] }, mockCtx); // Extract encounter ID from the response const encounterText = encounterResult.content[0].text; const encounterIdMatch = encounterText.match(/Encounter ID: (encounter-[^\n]+)/); expect(encounterIdMatch).toBeTruthy(); const encounterId = encounterIdMatch![1]; // 3. Execute an attack that deals damage to the character // Use very high attack bonus to guarantee a hit const attackResult = await handleExecuteCombatAction({ encounterId, action: 'attack', actorId: 'enemy-goblin', targetId: character.id, attackBonus: 20, // Very high bonus to guarantee hit dc: 10, // Low DC to ensure hit damage: 20 // Base damage (may be doubled on crit) }, mockCtx); // Verify the attack hit const attackText = attackResult.content[0].text; expect(attackText).toContain('HIT'); // 4. Verify HP changed in encounter state const stateResult = await handleGetEncounterState({ encounterId }, mockCtx); const heroInEncounter = stateResult.participants.find( (p: any) => p.id === character.id ); expect(heroInEncounter).toBeDefined(); // HP should be less than 50 (took damage) expect(heroInEncounter.hp).toBeLessThan(50); const hpAfterCombat = heroInEncounter.hp; // 5. End the encounter await handleEndEncounter({ encounterId }, mockCtx); // 6. CRITICAL TEST: Check if HP persisted back to character record const reloadedResult = await handleGetCharacter({ id: character.id }, mockCtx); const reloadedCharacter = JSON.parse(reloadedResult.content[0].text); // HP in character record should match the HP at end of combat expect(reloadedCharacter.hp).toBe(hpAfterCombat); }); it('should persist HP changes for multiple characters', async () => { // Create two characters const hero1Result = await handleCreateCharacter({ name: 'Fighter', stats: { str: 16, dex: 14, con: 15, int: 10, wis: 12, cha: 8 }, hp: 40, maxHp: 40, ac: 18, level: 3 }, mockCtx); const hero1 = JSON.parse(hero1Result.content[0].text); const hero2Result = await handleCreateCharacter({ name: 'Wizard', stats: { str: 8, dex: 14, con: 12, int: 18, wis: 13, cha: 10 }, hp: 25, maxHp: 25, ac: 12, level: 3 }, mockCtx); const hero2 = JSON.parse(hero2Result.content[0].text); // Create encounter const encounterResult = await handleCreateEncounter({ seed: 'multi-hp-test', participants: [ { id: hero1.id, name: hero1.name, initiativeBonus: 2, hp: hero1.hp, maxHp: hero1.maxHp, conditions: [] }, { id: hero2.id, name: hero2.name, initiativeBonus: 1, hp: hero2.hp, maxHp: hero2.maxHp, conditions: [] }, { id: 'enemy-orc', name: 'Orc', initiativeBonus: 0, hp: 15, maxHp: 15, isEnemy: true, conditions: [] } ] }, mockCtx); const encounterText = encounterResult.content[0].text; const encounterIdMatch = encounterText.match(/Encounter ID: (encounter-[^\n]+)/); const encounterId = encounterIdMatch![1]; // Damage both heroes (use high bonus to guarantee hits) await handleExecuteCombatAction({ encounterId, action: 'attack', actorId: 'enemy-orc', targetId: hero1.id, attackBonus: 20, dc: 10, damage: 15 }, mockCtx); await handleExecuteCombatAction({ encounterId, action: 'attack', actorId: 'enemy-orc', targetId: hero2.id, attackBonus: 20, dc: 10, damage: 10 }, mockCtx); // Get HP values after combat (before ending encounter) const stateResult = await handleGetEncounterState({ encounterId }, mockCtx); const hero1InEncounter = stateResult.participants.find((p: any) => p.id === hero1.id); const hero2InEncounter = stateResult.participants.find((p: any) => p.id === hero2.id); expect(hero1InEncounter.hp).toBeLessThan(40); // Took damage expect(hero2InEncounter.hp).toBeLessThan(25); // Took damage const hp1AfterCombat = hero1InEncounter.hp; const hp2AfterCombat = hero2InEncounter.hp; // End encounter await handleEndEncounter({ encounterId }, mockCtx); // Verify both characters have updated HP that matches combat state const reloaded1 = JSON.parse( (await handleGetCharacter({ id: hero1.id }, mockCtx)).content[0].text ); const reloaded2 = JSON.parse( (await handleGetCharacter({ id: hero2.id }, mockCtx)).content[0].text ); expect(reloaded1.hp).toBe(hp1AfterCombat); expect(reloaded2.hp).toBe(hp2AfterCombat); }); it('should not sync HP for enemies/NPCs that are not in character table', async () => { // Create a player character const heroResult = await handleCreateCharacter({ name: 'Hero', stats: { str: 14, dex: 14, con: 14, int: 14, wis: 14, cha: 14 }, hp: 30, maxHp: 30, ac: 15, level: 2 }, mockCtx); const hero = JSON.parse(heroResult.content[0].text); // Create encounter with ad-hoc enemy (not in character table) const encounterResult = await handleCreateEncounter({ seed: 'adhoc-enemy-test', participants: [ { id: hero.id, name: hero.name, initiativeBonus: 2, hp: hero.hp, maxHp: hero.maxHp, conditions: [] }, { id: 'random-goblin-123', name: 'Random Goblin', initiativeBonus: 1, hp: 7, maxHp: 7, isEnemy: true, conditions: [] } ] }, mockCtx); const encounterText = encounterResult.content[0].text; const encounterIdMatch = encounterText.match(/Encounter ID: (encounter-[^\n]+)/); const encounterId = encounterIdMatch![1]; // Hero takes damage (use high bonus to guarantee hit) await handleExecuteCombatAction({ encounterId, action: 'attack', actorId: 'random-goblin-123', targetId: hero.id, attackBonus: 20, dc: 10, damage: 12 }, mockCtx); // Get hero HP after combat const stateResult = await handleGetEncounterState({ encounterId }, mockCtx); const heroInEncounter = stateResult.participants.find((p: any) => p.id === hero.id); expect(heroInEncounter.hp).toBeLessThan(30); // Took damage const hpAfterCombat = heroInEncounter.hp; // End encounter - should NOT throw error for missing enemy in DB await handleEndEncounter({ encounterId }, mockCtx); // Hero HP should be synced to match combat state const reloadedHero = JSON.parse( (await handleGetCharacter({ id: hero.id }, mockCtx)).content[0].text ); expect(reloadedHero.hp).toBe(hpAfterCombat); // Ad-hoc enemy should not cause any errors (it's not in character table) // This test passes if no exception was thrown }); });

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