Skip to main content
Glama
opportunity-attacks.test.ts16.4 kB
import { describe, it, expect, beforeEach } from 'vitest'; import { handleCreateEncounter, handleExecuteCombatAction, handleAdvanceTurn, clearCombatState } from '../../src/server/combat-tools.js'; import { closeDb, getDb } from '../../src/storage/index.js'; const mockCtx = { sessionId: 'test-session' }; /** * HIGH-003: No Opportunity Attacks * * Tests for opportunity attack mechanics: * - When a creature leaves a threatened square, adjacent enemies get a reaction attack * - Reactions reset at the start of each creature's turn * - Disengage action prevents opportunity attacks */ describe('HIGH-003: Opportunity Attacks', () => { beforeEach(() => { closeDb(); getDb(':memory:'); clearCombatState(); }); describe('Movement Provokes Opportunity Attacks', () => { it('should trigger opportunity attack when leaving threatened square', async () => { // Create encounter: hero adjacent to goblin, hero moves away const createResult = await handleCreateEncounter({ seed: 'opp-attack-1', participants: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 10, // Goes first hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, hp: 15, maxHp: 15, isEnemy: true, position: { x: 0, y: 0 } // Adjacent to hero } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // Hero moves away from goblin (leaving threatened square) const moveResult = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 5, y: 0 } // Moving away from goblin }, mockCtx); const moveText = moveResult.content[0].text; // Should show opportunity attack was triggered expect(moveText).toMatch(/opportunity attack/i); expect(moveText).toContain('Goblin'); }); it('should NOT trigger opportunity attack when moving within threat range', async () => { // Create encounter: hero adjacent to goblin, hero moves to another adjacent square const createResult = await handleCreateEncounter({ seed: 'opp-attack-2', participants: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 10, hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, hp: 15, maxHp: 15, isEnemy: true, position: { x: 0, y: 0 } } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // Hero moves to another square still adjacent to goblin const moveResult = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 1, y: 1 } // Still adjacent to goblin at (0,0) }, mockCtx); const moveText = moveResult.content[0].text; // Should NOT trigger opportunity attack (still in threat range) expect(moveText).not.toMatch(/opportunity attack/i); }); it('should NOT trigger opportunity attack from same faction', async () => { // Create encounter: hero adjacent to ally, hero moves away const createResult = await handleCreateEncounter({ seed: 'opp-attack-3', participants: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 10, hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'ally-1', name: 'Ally Fighter', initiativeBonus: 5, hp: 25, maxHp: 25, isEnemy: false, // Same faction as hero position: { x: 0, y: 0 } } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // Hero moves away from ally const moveResult = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 5, y: 0 } }, mockCtx); const moveText = moveResult.content[0].text; // Should NOT trigger opportunity attack (same faction) expect(moveText).not.toMatch(/opportunity attack/i); }); }); describe('Reaction Tracking', () => { it('should track reaction usage - only one OA per round per creature', async () => { // Create encounter: goblin adjacent to two heroes const createResult = await handleCreateEncounter({ seed: 'reaction-test-1', participants: [ { id: 'hero-1', name: 'Hero 1', initiativeBonus: 10, hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'hero-2', name: 'Hero 2', initiativeBonus: 9, hp: 30, maxHp: 30, position: { x: 0, y: 1 } }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, hp: 15, maxHp: 15, isEnemy: true, position: { x: 0, y: 0 } } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // Hero 1 moves away - goblin uses reaction const move1Result = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 5, y: 0 } }, mockCtx); expect(move1Result.content[0].text).toMatch(/opportunity attack/i); // Advance to hero 2's turn await handleAdvanceTurn({ encounterId }, mockCtx); // Hero 2 moves away - goblin already used reaction const move2Result = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-2', targetPosition: { x: 0, y: 5 } }, mockCtx); // Should NOT trigger second opportunity attack (reaction already used) expect(move2Result.content[0].text).not.toMatch(/opportunity attack/i); }); it('should reset reactions at start of creature turn', async () => { // Create encounter const createResult = await handleCreateEncounter({ seed: 'reaction-reset-1', participants: [ { id: 'hero-1', name: 'Hero 1', initiativeBonus: 10, hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 5, hp: 15, maxHp: 15, isEnemy: true, position: { x: 0, y: 0 } }, { id: 'hero-2', name: 'Hero 2', initiativeBonus: 1, hp: 30, maxHp: 30, position: { x: -1, y: 0 } } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // Hero 1 moves away - goblin uses reaction await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 5, y: 0 } }, mockCtx); // Advance through goblin's turn (reaction resets) await handleAdvanceTurn({ encounterId }, mockCtx); // Hero 1 -> Goblin await handleAdvanceTurn({ encounterId }, mockCtx); // Goblin -> Hero 2 // Move hero 1 back to adjacent position for round 2 test // Actually, let's advance to round 2 and test with hero 2 // Hero 2 is at (-1, 0), adjacent to goblin at (0, 0) const moveResult = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-2', targetPosition: { x: -5, y: 0 } // Moving away }, mockCtx); // Goblin's reaction should be available again (reset at start of its turn) expect(moveResult.content[0].text).toMatch(/opportunity attack/i); }); }); describe('Disengage Action', () => { it('should prevent opportunity attacks after disengage', async () => { const createResult = await handleCreateEncounter({ seed: 'disengage-test-1', participants: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 10, hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, hp: 15, maxHp: 15, isEnemy: true, position: { x: 0, y: 0 } } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // Hero takes disengage action const disengageResult = await handleExecuteCombatAction({ encounterId, action: 'disengage', actorId: 'hero-1' }, mockCtx); expect(disengageResult.content[0].text).toMatch(/disengage/i); // Hero moves away - should NOT provoke const moveResult = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 5, y: 0 } }, mockCtx); expect(moveResult.content[0].text).not.toMatch(/opportunity attack/i); }); }); describe('Edge Cases', () => { it('should not trigger OA from defeated enemies', async () => { const createResult = await handleCreateEncounter({ seed: 'defeated-test-1', participants: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 10, hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, hp: 5, // Low HP - will be defeated maxHp: 15, isEnemy: true, position: { x: 0, y: 0 } } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // First, defeat the goblin with an attack await handleExecuteCombatAction({ encounterId, action: 'attack', actorId: 'hero-1', targetId: 'goblin-1', attackBonus: 20, // Guaranteed hit dc: 5, damage: 100 // Overkill to ensure defeat }, mockCtx); // Hero moves away from defeated goblin const moveResult = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 5, y: 0 } }, mockCtx); // Should NOT trigger opportunity attack (goblin is defeated) expect(moveResult.content[0].text).not.toMatch(/opportunity attack/i); }); it('should apply opportunity attack damage', async () => { const createResult = await handleCreateEncounter({ seed: 'oa-damage-1', participants: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 10, hp: 30, maxHp: 30, position: { x: 1, y: 0 } }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, hp: 15, maxHp: 15, isEnemy: true, position: { x: 0, y: 0 } } ] }, mockCtx); const createData = JSON.parse(createResult.content[0].text.match(/<!-- STATE_JSON\n([\s\S]*?)\nSTATE_JSON -->/)?.[1] || '{}'); const encounterId = createData.encounterId; // Hero moves away - triggers OA const moveResult = await handleExecuteCombatAction({ encounterId, action: 'move', actorId: 'hero-1', targetPosition: { x: 5, y: 0 } }, mockCtx); // Should show attack roll and potential damage const text = moveResult.content[0].text; expect(text).toMatch(/opportunity attack/i); // If the attack hits, there should be damage info // The exact outcome depends on the dice roll, but the mechanics should be there expect(text).toMatch(/d20|Attack Roll|damage/i); }); }); });

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