Skip to main content
Glama
position-persistence.test.ts17.4 kB
/** * Position Persistence Tests (Phase 1) * * Tests to verify that positions, terrain, and grid bounds survive * the save/load cycle through the EncounterRepository. * * This addresses the critical gap identified in the EMERGENT_DISCOVERY_LOG: * "Positions not persisted - In-memory only, not serialized to database" */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import { initDB } from '../../src/storage/db.js'; import { migrate } from '../../src/storage/migrations.js'; import { EncounterRepository } from '../../src/storage/repos/encounter.repo.js'; import { Encounter, GridBounds, DEFAULT_GRID_BOUNDS } from '../../src/schema/encounter.js'; const TEST_DB_PATH = 'test-position-persistence.db'; describe('Phase 1: Position Persistence', () => { let db: ReturnType<typeof initDB>; let repo: EncounterRepository; beforeEach(() => { if (fs.existsSync(TEST_DB_PATH)) { fs.unlinkSync(TEST_DB_PATH); } db = initDB(TEST_DB_PATH); migrate(db); repo = new EncounterRepository(db); }); afterEach(() => { db.close(); if (fs.existsSync(TEST_DB_PATH)) { fs.unlinkSync(TEST_DB_PATH); } }); describe('Token Position Persistence', () => { it('persists positions through save/load cycle via saveState/loadState', () => { // Create encounter const encounter: Encounter = { id: 'enc-pos-1', tokens: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], position: { x: 5, y: 10 }, movementSpeed: 30, size: 'medium' }], round: 1, activeTokenId: 'hero-1', status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); // Simulate combat state with position const combatState = { participants: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, initiative: 15, hp: 25, // Took some damage maxHp: 30, conditions: [], position: { x: 8, y: 12 }, // Moved from (5,10) to (8,12) movementSpeed: 30, size: 'medium' }], turnOrder: ['hero-1'], currentTurnIndex: 0, round: 2 }; // Save state repo.saveState('enc-pos-1', combatState); // Load state const loaded = repo.loadState('enc-pos-1'); // Verify position persisted expect(loaded).not.toBeNull(); expect(loaded.participants[0].position).toEqual({ x: 8, y: 12 }); expect(loaded.participants[0].hp).toBe(25); expect(loaded.round).toBe(2); }); it('persists multiple participant positions', () => { const encounter: Encounter = { id: 'enc-multi-pos', tokens: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], movementSpeed: 30, size: 'medium' }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, hp: 7, maxHp: 7, conditions: [], movementSpeed: 30, size: 'small' } ], round: 1, status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [ { id: 'hero-1', name: 'Hero', initiativeBonus: 2, initiative: 18, hp: 30, maxHp: 30, conditions: [], position: { x: 0, y: 0 }, movementSpeed: 30, size: 'medium' }, { id: 'goblin-1', name: 'Goblin', initiativeBonus: 1, initiative: 12, hp: 7, maxHp: 7, conditions: [], position: { x: 10, y: 5 }, movementSpeed: 30, size: 'small' } ], turnOrder: ['hero-1', 'goblin-1'], currentTurnIndex: 0, round: 1 }; repo.saveState('enc-multi-pos', combatState); const loaded = repo.loadState('enc-multi-pos'); expect(loaded.participants.find((p: any) => p.id === 'hero-1').position).toEqual({ x: 0, y: 0 }); expect(loaded.participants.find((p: any) => p.id === 'goblin-1').position).toEqual({ x: 10, y: 5 }); }); it('handles participants without positions', () => { const encounter: Encounter = { id: 'enc-no-pos', tokens: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], movementSpeed: 30, size: 'medium' // No position }], round: 1, status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, initiative: 15, hp: 30, maxHp: 30, conditions: [] // Still no position }], turnOrder: ['hero-1'], currentTurnIndex: 0, round: 1 }; repo.saveState('enc-no-pos', combatState); const loaded = repo.loadState('enc-no-pos'); expect(loaded.participants[0].position).toBeUndefined(); }); it('persists 3D positions (z coordinate)', () => { const encounter: Encounter = { id: 'enc-3d', tokens: [{ id: 'flying-hero', name: 'Flying Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], position: { x: 5, y: 5, z: 10 }, // 10 squares up movementSpeed: 30, size: 'medium' }], round: 1, status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'flying-hero', name: 'Flying Hero', initiativeBonus: 2, initiative: 15, hp: 30, maxHp: 30, conditions: [], position: { x: 5, y: 5, z: 15 } // Flew higher }], turnOrder: ['flying-hero'], currentTurnIndex: 0, round: 1 }; repo.saveState('enc-3d', combatState); const loaded = repo.loadState('enc-3d'); expect(loaded.participants[0].position).toEqual({ x: 5, y: 5, z: 15 }); }); }); describe('Terrain Persistence', () => { it('persists terrain obstacles through save/load', () => { const encounter: Encounter = { id: 'enc-terrain', tokens: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], movementSpeed: 30, size: 'medium' }], round: 1, status: 'active', terrain: { obstacles: ['5,5', '5,6', '5,7'] // Wall }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, initiative: 15, hp: 30, maxHp: 30, conditions: [], position: { x: 0, y: 0 } }], turnOrder: ['hero-1'], currentTurnIndex: 0, round: 1, terrain: { obstacles: ['5,5', '5,6', '5,7'] } }; repo.saveState('enc-terrain', combatState); const loaded = repo.loadState('enc-terrain'); expect(loaded.terrain).toBeDefined(); expect(loaded.terrain.obstacles).toEqual(['5,5', '5,6', '5,7']); }); it('persists difficult terrain', () => { const encounter: Encounter = { id: 'enc-difficult', tokens: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], movementSpeed: 30, size: 'medium' }], round: 1, status: 'active', terrain: { obstacles: ['10,10'], difficultTerrain: ['3,3', '3,4', '4,3', '4,4'] // Swamp area }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, initiative: 15, hp: 30, maxHp: 30, conditions: [], position: { x: 0, y: 0 } }], turnOrder: ['hero-1'], currentTurnIndex: 0, round: 1, terrain: { obstacles: ['10,10'], difficultTerrain: ['3,3', '3,4', '4,3', '4,4'] } }; repo.saveState('enc-difficult', combatState); const loaded = repo.loadState('enc-difficult'); expect(loaded.terrain.difficultTerrain).toEqual(['3,3', '3,4', '4,3', '4,4']); }); }); describe('Grid Bounds Persistence (Phase 2)', () => { it('persists custom grid bounds', () => { const customBounds: GridBounds = { minX: -50, maxX: 50, minY: -50, maxY: 50 }; const encounter: Encounter = { id: 'enc-bounds', tokens: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], movementSpeed: 30, size: 'medium' }], round: 1, status: 'active', gridBounds: customBounds, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, initiative: 15, hp: 30, maxHp: 30, conditions: [], position: { x: 0, y: 0 } }], turnOrder: ['hero-1'], currentTurnIndex: 0, round: 1, gridBounds: customBounds }; repo.saveState('enc-bounds', combatState); const loaded = repo.loadState('enc-bounds'); expect(loaded.gridBounds).toEqual(customBounds); }); it('uses default bounds when none specified', () => { const encounter: Encounter = { id: 'enc-default-bounds', tokens: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], movementSpeed: 30, size: 'medium' }], round: 1, status: 'active', // No gridBounds specified createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'hero-1', name: 'Hero', initiativeBonus: 2, initiative: 15, hp: 30, maxHp: 30, conditions: [], position: { x: 0, y: 0 } }], turnOrder: ['hero-1'], currentTurnIndex: 0, round: 1 }; repo.saveState('enc-default-bounds', combatState); const loaded = repo.loadState('enc-default-bounds'); expect(loaded.gridBounds).toEqual(DEFAULT_GRID_BOUNDS); }); }); describe('Movement Properties Persistence (Phase 4)', () => { it('persists movement speed and remaining movement', () => { const encounter: Encounter = { id: 'enc-movement', tokens: [{ id: 'fast-hero', name: 'Fast Hero', initiativeBonus: 2, hp: 30, maxHp: 30, conditions: [], movementSpeed: 40, // Custom speed size: 'medium' }], round: 1, status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'fast-hero', name: 'Fast Hero', initiativeBonus: 2, initiative: 15, hp: 30, maxHp: 30, conditions: [], position: { x: 5, y: 5 }, movementSpeed: 40, movementRemaining: 15, // Used 25ft of movement hasDashed: false }], turnOrder: ['fast-hero'], currentTurnIndex: 0, round: 1 }; repo.saveState('enc-movement', combatState); const loaded = repo.loadState('enc-movement'); expect(loaded.participants[0].movementSpeed).toBe(40); expect(loaded.participants[0].movementRemaining).toBe(15); expect(loaded.participants[0].hasDashed).toBe(false); }); it('persists size category', () => { const encounter: Encounter = { id: 'enc-size', tokens: [{ id: 'dragon-1', name: 'Adult Dragon', initiativeBonus: 2, hp: 200, maxHp: 200, conditions: [], movementSpeed: 40, size: 'huge' }], round: 1, status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; repo.create(encounter); const combatState = { participants: [{ id: 'dragon-1', name: 'Adult Dragon', initiativeBonus: 2, initiative: 20, hp: 200, maxHp: 200, conditions: [], position: { x: 10, y: 10 }, movementSpeed: 40, size: 'huge' }], turnOrder: ['dragon-1'], currentTurnIndex: 0, round: 1 }; repo.saveState('enc-size', combatState); const loaded = repo.loadState('enc-size'); expect(loaded.participants[0].size).toBe('huge'); }); }); });

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