Skip to main content
Glama
encounter.repo.ts8.23 kB
import Database from 'better-sqlite3'; import { Encounter, EncounterSchema, DEFAULT_GRID_BOUNDS } from '../../schema/encounter.js'; /** * EncounterRepository - Persistence layer for combat encounters * * PHASE 1: Position Persistence * - Positions are stored within the tokens JSON * - Terrain obstacles are stored in a separate terrain column * - Grid bounds define valid coordinate ranges * * Storage format: * - tokens: JSON array of TokenSchema (includes position, movementSpeed, size) * - terrain: JSON object with obstacles and difficultTerrain arrays * - grid_bounds: JSON object with minX, maxX, minY, maxY, minZ?, maxZ? */ export class EncounterRepository { constructor(private db: Database.Database) { // Ensure schema is up to date (add columns if missing) this.ensureSchema(); } /** * Ensure the encounters table has all required columns * This handles migration for existing databases */ private ensureSchema(): void { // Check if terrain column exists const tableInfo = this.db.prepare('PRAGMA table_info(encounters)').all() as { name: string }[]; const columnNames = tableInfo.map(c => c.name); if (!columnNames.includes('terrain')) { this.db.prepare('ALTER TABLE encounters ADD COLUMN terrain TEXT').run(); } if (!columnNames.includes('grid_bounds')) { this.db.prepare('ALTER TABLE encounters ADD COLUMN grid_bounds TEXT').run(); } if (!columnNames.includes('props')) { this.db.prepare('ALTER TABLE encounters ADD COLUMN props TEXT').run(); } } create(encounter: Encounter): void { const validEncounter = EncounterSchema.parse(encounter); const stmt = this.db.prepare(` INSERT INTO encounters (id, region_id, tokens, round, active_token_id, status, terrain, props, grid_bounds, created_at, updated_at) VALUES (@id, @regionId, @tokens, @round, @activeTokenId, @status, @terrain, @props, @gridBounds, @createdAt, @updatedAt) `); stmt.run({ id: validEncounter.id, regionId: validEncounter.regionId || null, // PHASE 1: Tokens JSON now includes position data tokens: JSON.stringify(validEncounter.tokens), round: validEncounter.round, activeTokenId: validEncounter.activeTokenId || null, status: validEncounter.status, // PHASE 1: Persist terrain separately terrain: validEncounter.terrain ? JSON.stringify(validEncounter.terrain) : null, // PHASE 1: Persist props props: validEncounter.props ? JSON.stringify(validEncounter.props) : null, // PHASE 2: Persist grid bounds gridBounds: validEncounter.gridBounds ? JSON.stringify(validEncounter.gridBounds) : null, createdAt: validEncounter.createdAt, updatedAt: validEncounter.updatedAt, }); } findByRegionId(regionId: string): Encounter[] { const stmt = this.db.prepare('SELECT * FROM encounters WHERE region_id = ?'); const rows = stmt.all(regionId) as EncounterRow[]; return rows.map((row) => EncounterSchema.parse({ id: row.id, regionId: row.region_id, tokens: JSON.parse(row.tokens), round: row.round, activeTokenId: row.active_token_id || undefined, status: row.status, // Restoring props props: row.props ? JSON.parse(row.props) : undefined, // Restoring terrain terrain: row.terrain ? JSON.parse(row.terrain) : undefined, createdAt: row.created_at, updatedAt: row.updated_at, }) ); } /** * Save combat state to database * PHASE 1: Now persists positions, terrain, and grid bounds * * @param encounterId The encounter ID * @param state The CombatState object (includes participants with positions) */ saveState(encounterId: string, state: any): void { const stmt = this.db.prepare(` UPDATE encounters SET tokens = ?, round = ?, active_token_id = ?, status = ?, terrain = ?, props = ?, grid_bounds = ?, updated_at = ? WHERE id = ? `); // Map CombatState to DB format // PHASE 1: Participants now include position, movementSpeed, size, movementRemaining const currentTurnId = state.turnOrder[state.currentTurnIndex]; stmt.run( JSON.stringify(state.participants), state.round, currentTurnId, 'active', // PHASE 1: Persist terrain state.terrain ? JSON.stringify(state.terrain) : null, // PHASE 1: Persist props state.props ? JSON.stringify(state.props) : null, // PHASE 2: Persist grid bounds state.gridBounds ? JSON.stringify(state.gridBounds) : null, new Date().toISOString(), encounterId ); } /** * Load combat state from database * PHASE 1: Now restores positions, terrain, and grid bounds * * @param encounterId The encounter ID * @returns CombatState object with all spatial data, or null if not found */ loadState(encounterId: string): any | null { const row = this.findById(encounterId); if (!row) return null; // PHASE 1: Parse participants with position data const participants = JSON.parse(row.tokens); // PHASE 1: Parse terrain if present const terrain = row.terrain ? JSON.parse(row.terrain) : undefined; // PHASE 1: Parse props if present const props = row.props ? JSON.parse(row.props) : undefined; // PHASE 2: Parse grid bounds if present, else use defaults const gridBounds = row.grid_bounds ? JSON.parse(row.grid_bounds) : DEFAULT_GRID_BOUNDS; // Reconstruct turn order from participants (sorted by initiative) // Note: This assumes participants are stored in initiative order const sortedParticipants = [...participants].sort((a: any, b: any) => { const initA = a.initiative ?? 0; const initB = b.initiative ?? 0; if (initB !== initA) return initB - initA; return (a.id as string).localeCompare(b.id); }); const turnOrder = sortedParticipants.map((p: any) => p.id); // Handle LAIR action if applicable const lairOwner = participants.find((p: any) => p.hasLairActions); if (lairOwner) { // Insert LAIR at initiative 20 position const lairIndex = sortedParticipants.findIndex((p: any) => (p.initiative ?? 0) <= 20); if (lairIndex === -1) { turnOrder.push('LAIR'); } else { turnOrder.splice(lairIndex, 0, 'LAIR'); } } return { participants: participants, turnOrder, currentTurnIndex: turnOrder.indexOf(row.active_token_id ?? turnOrder[0]), round: row.round, // PHASE 1: Restore terrain terrain, props, // PHASE 2: Restore grid bounds gridBounds, // LAIR action support hasLairActions: !!lairOwner, lairOwnerId: lairOwner?.id }; } findById(id: string): EncounterRow | undefined { const stmt = this.db.prepare('SELECT * FROM encounters WHERE id = ?'); return stmt.get(id) as EncounterRow | undefined; } delete(id: string): boolean { const stmt = this.db.prepare('DELETE FROM encounters WHERE id = ?'); const result = stmt.run(id); return result.changes > 0; } } interface EncounterRow { id: string; region_id: string | null; tokens: string; round: number; active_token_id: string | null; status: string; // PHASE 1: Terrain and bounds persistence terrain: string | null; props: string | null; grid_bounds: string | null; created_at: string; updated_at: string; }

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