Skip to main content
Glama
poi.repo.ts18.6 kB
/** * POI Repository - Persistence layer for Points of Interest * * Bridges world-level structures with room networks for navigation. * * @module storage/repos/poi */ import Database from 'better-sqlite3'; import { POI, POISchema, POICategory, POIDiscoveryState } from '../../schema/poi.js'; export class POIRepository { constructor(private db: Database.Database) { this.ensureSchema(); } /** * Ensure the POI table exists with all required columns */ private ensureSchema(): void { // Create POI table without foreign key constraints for flexibility // Network/room linking is optional and may reference entities created later this.db.exec(` CREATE TABLE IF NOT EXISTS pois ( id TEXT PRIMARY KEY, world_id TEXT NOT NULL, region_id TEXT, x INTEGER NOT NULL, y INTEGER NOT NULL, name TEXT NOT NULL, description TEXT, category TEXT NOT NULL, icon TEXT NOT NULL, structure_id TEXT, network_id TEXT, entrance_room_id TEXT, discovery_state TEXT NOT NULL DEFAULT 'unknown', discovered_by TEXT NOT NULL DEFAULT '[]', discovery_dc INTEGER, parent_poi_id TEXT, child_poi_ids TEXT NOT NULL DEFAULT '[]', population INTEGER NOT NULL DEFAULT 0, level INTEGER, tags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); // Create indexes for common queries this.db.exec(` CREATE INDEX IF NOT EXISTS idx_pois_world ON pois(world_id); CREATE INDEX IF NOT EXISTS idx_pois_coords ON pois(world_id, x, y); CREATE INDEX IF NOT EXISTS idx_pois_category ON pois(world_id, category); CREATE INDEX IF NOT EXISTS idx_pois_discovery ON pois(world_id, discovery_state); CREATE INDEX IF NOT EXISTS idx_pois_network ON pois(network_id); CREATE INDEX IF NOT EXISTS idx_pois_structure ON pois(structure_id); `); } // ============================================================ // CRUD OPERATIONS // ============================================================ create(poi: POI): void { const validated = POISchema.parse(poi); const stmt = this.db.prepare(` INSERT INTO pois ( id, world_id, region_id, x, y, name, description, category, icon, structure_id, network_id, entrance_room_id, discovery_state, discovered_by, discovery_dc, parent_poi_id, child_poi_ids, population, level, tags, created_at, updated_at ) VALUES ( @id, @worldId, @regionId, @x, @y, @name, @description, @category, @icon, @structureId, @networkId, @entranceRoomId, @discoveryState, @discoveredBy, @discoveryDC, @parentPOIId, @childPOIIds, @population, @level, @tags, @createdAt, @updatedAt ) `); stmt.run({ id: validated.id, worldId: validated.worldId, regionId: validated.regionId || null, x: validated.x, y: validated.y, name: validated.name, description: validated.description || null, category: validated.category, icon: validated.icon, structureId: validated.structureId || null, networkId: validated.networkId || null, entranceRoomId: validated.entranceRoomId || null, discoveryState: validated.discoveryState, discoveredBy: JSON.stringify(validated.discoveredBy), discoveryDC: validated.discoveryDC ?? null, parentPOIId: validated.parentPOIId || null, childPOIIds: JSON.stringify(validated.childPOIIds), population: validated.population, level: validated.level ?? null, tags: JSON.stringify(validated.tags), createdAt: validated.createdAt, updatedAt: validated.updatedAt }); } /** * Create multiple POIs in a single transaction. * Optimized for worldgen POI placement. * * @param pois - Array of POIs to create * @returns Number of POIs created * * @example * const pois = [ * { id: 'poi-1', worldId: 'w1', name: 'Capital', category: 'settlement', icon: 'city', x: 50, y: 50, ... }, * { id: 'poi-2', worldId: 'w1', name: 'Dark Cave', category: 'dungeon', icon: 'cave', x: 30, y: 70, ... }, * ]; * poiRepo.createBatch(pois); */ createBatch(pois: POI[]): number { if (pois.length === 0) return 0; const stmt = this.db.prepare(` INSERT INTO pois ( id, world_id, region_id, x, y, name, description, category, icon, structure_id, network_id, entrance_room_id, discovery_state, discovered_by, discovery_dc, parent_poi_id, child_poi_ids, population, level, tags, created_at, updated_at ) VALUES ( @id, @worldId, @regionId, @x, @y, @name, @description, @category, @icon, @structureId, @networkId, @entranceRoomId, @discoveryState, @discoveredBy, @discoveryDC, @parentPOIId, @childPOIIds, @population, @level, @tags, @createdAt, @updatedAt ) `); const insertMany = this.db.transaction((toInsert: POI[]) => { let count = 0; for (const poi of toInsert) { const validated = POISchema.parse(poi); stmt.run({ id: validated.id, worldId: validated.worldId, regionId: validated.regionId || null, x: validated.x, y: validated.y, name: validated.name, description: validated.description || null, category: validated.category, icon: validated.icon, structureId: validated.structureId || null, networkId: validated.networkId || null, entranceRoomId: validated.entranceRoomId || null, discoveryState: validated.discoveryState, discoveredBy: JSON.stringify(validated.discoveredBy), discoveryDC: validated.discoveryDC ?? null, parentPOIId: validated.parentPOIId || null, childPOIIds: JSON.stringify(validated.childPOIIds), population: validated.population, level: validated.level ?? null, tags: JSON.stringify(validated.tags), createdAt: validated.createdAt, updatedAt: validated.updatedAt }); count++; } return count; }); return insertMany(pois); } /** * Delete all POIs for a world */ deleteByWorldId(worldId: string): number { const stmt = this.db.prepare('DELETE FROM pois WHERE world_id = ?'); const result = stmt.run(worldId); return result.changes; } findById(id: string): POI | null { const stmt = this.db.prepare('SELECT * FROM pois WHERE id = ?'); const row = stmt.get(id) as POIRow | undefined; if (!row) return null; return this.rowToPOI(row); } findByWorldId(worldId: string): POI[] { const stmt = this.db.prepare('SELECT * FROM pois WHERE world_id = ? ORDER BY name'); const rows = stmt.all(worldId) as POIRow[]; return rows.map(row => this.rowToPOI(row)); } findByCoordinates(worldId: string, x: number, y: number): POI | null { const stmt = this.db.prepare('SELECT * FROM pois WHERE world_id = ? AND x = ? AND y = ?'); const row = stmt.get(worldId, x, y) as POIRow | undefined; if (!row) return null; return this.rowToPOI(row); } findByCategory(worldId: string, category: POICategory): POI[] { const stmt = this.db.prepare( 'SELECT * FROM pois WHERE world_id = ? AND category = ? ORDER BY name' ); const rows = stmt.all(worldId, category) as POIRow[]; return rows.map(row => this.rowToPOI(row)); } findByNetworkId(networkId: string): POI | null { const stmt = this.db.prepare('SELECT * FROM pois WHERE network_id = ?'); const row = stmt.get(networkId) as POIRow | undefined; if (!row) return null; return this.rowToPOI(row); } findByStructureId(structureId: string): POI | null { const stmt = this.db.prepare('SELECT * FROM pois WHERE structure_id = ?'); const row = stmt.get(structureId) as POIRow | undefined; if (!row) return null; return this.rowToPOI(row); } update(id: string, updates: Partial<POI>): POI | null { const existing = this.findById(id); if (!existing) return null; const updated: POI = { ...existing, ...updates, updatedAt: new Date().toISOString() }; const validated = POISchema.parse(updated); const stmt = this.db.prepare(` UPDATE pois SET region_id = ?, x = ?, y = ?, name = ?, description = ?, category = ?, icon = ?, structure_id = ?, network_id = ?, entrance_room_id = ?, discovery_state = ?, discovered_by = ?, discovery_dc = ?, parent_poi_id = ?, child_poi_ids = ?, population = ?, level = ?, tags = ?, updated_at = ? WHERE id = ? `); stmt.run( validated.regionId || null, validated.x, validated.y, validated.name, validated.description || null, validated.category, validated.icon, validated.structureId || null, validated.networkId || null, validated.entranceRoomId || null, validated.discoveryState, JSON.stringify(validated.discoveredBy), validated.discoveryDC ?? null, validated.parentPOIId || null, JSON.stringify(validated.childPOIIds), validated.population, validated.level ?? null, JSON.stringify(validated.tags), validated.updatedAt, id ); return validated; } delete(id: string): boolean { const stmt = this.db.prepare('DELETE FROM pois WHERE id = ?'); const result = stmt.run(id); return result.changes > 0; } // ============================================================ // DISCOVERY OPERATIONS // ============================================================ /** * Mark a POI as discovered by a character */ discoverPOI(poiId: string, characterId: string): POI | null { const poi = this.findById(poiId); if (!poi) return null; if (!poi.discoveredBy.includes(characterId)) { poi.discoveredBy.push(characterId); } // Update discovery state if not already discovered if (poi.discoveryState === 'unknown') { poi.discoveryState = 'discovered'; } return this.update(poiId, { discoveredBy: poi.discoveredBy, discoveryState: poi.discoveryState }); } /** * Get all POIs discovered by a character */ findDiscoveredByCharacter(worldId: string, characterId: string): POI[] { const stmt = this.db.prepare( `SELECT * FROM pois WHERE world_id = ? AND discovered_by LIKE ? ORDER BY name` ); const rows = stmt.all(worldId, `%"${characterId}"%`) as POIRow[]; return rows.map(row => this.rowToPOI(row)); } /** * Get POIs in a specific discovery state */ findByDiscoveryState(worldId: string, state: POIDiscoveryState): POI[] { const stmt = this.db.prepare( 'SELECT * FROM pois WHERE world_id = ? AND discovery_state = ? ORDER BY name' ); const rows = stmt.all(worldId, state) as POIRow[]; return rows.map(row => this.rowToPOI(row)); } // ============================================================ // SPATIAL QUERIES // ============================================================ /** * Find POIs within a bounding box */ findInBoundingBox( worldId: string, minX: number, maxX: number, minY: number, maxY: number ): POI[] { const stmt = this.db.prepare(` SELECT * FROM pois WHERE world_id = ? AND x >= ? AND x <= ? AND y >= ? AND y <= ? ORDER BY x, y `); const rows = stmt.all(worldId, minX, maxX, minY, maxY) as POIRow[]; return rows.map(row => this.rowToPOI(row)); } /** * Find POIs within a radius of a point */ findNearby(worldId: string, x: number, y: number, radius: number): POI[] { // Use bounding box for initial filter, then check actual distance const stmt = this.db.prepare(` SELECT *, ((x - ?) * (x - ?) + (y - ?) * (y - ?)) as dist_sq FROM pois WHERE world_id = ? AND x >= ? AND x <= ? AND y >= ? AND y <= ? ORDER BY dist_sq `); const rows = stmt.all( x, x, y, y, // For distance calculation worldId, x - radius, x + radius, y - radius, y + radius ) as (POIRow & { dist_sq: number })[]; const radiusSq = radius * radius; return rows .filter(row => row.dist_sq <= radiusSq) .map(row => this.rowToPOI(row)); } /** * Find the nearest POI to a point */ findNearest(worldId: string, x: number, y: number): POI | null { const stmt = this.db.prepare(` SELECT *, ((x - ?) * (x - ?) + (y - ?) * (y - ?)) as dist_sq FROM pois WHERE world_id = ? ORDER BY dist_sq LIMIT 1 `); const row = stmt.get(x, x, y, y, worldId) as POIRow | undefined; if (!row) return null; return this.rowToPOI(row); } // ============================================================ // LINKING OPERATIONS // ============================================================ /** * Link a POI to a NodeNetwork */ linkToNetwork(poiId: string, networkId: string, entranceRoomId?: string): POI | null { return this.update(poiId, { networkId, entranceRoomId }); } /** * Link a POI to a Structure */ linkToStructure(poiId: string, structureId: string): POI | null { return this.update(poiId, { structureId }); } /** * Add a child POI (sub-location) */ addChildPOI(parentId: string, childId: string): POI | null { const parent = this.findById(parentId); if (!parent) return null; if (!parent.childPOIIds.includes(childId)) { parent.childPOIIds.push(childId); } // Also update child's parent reference this.update(childId, { parentPOIId: parentId }); return this.update(parentId, { childPOIIds: parent.childPOIIds }); } // ============================================================ // SEARCH & FILTER // ============================================================ /** * Search POIs by tag */ findByTag(worldId: string, tag: string): POI[] { const stmt = this.db.prepare( `SELECT * FROM pois WHERE world_id = ? AND tags LIKE ? ORDER BY name` ); const rows = stmt.all(worldId, `%"${tag}"%`) as POIRow[]; return rows.map(row => this.rowToPOI(row)); } /** * Full-text search on name and description */ search(worldId: string, query: string): POI[] { const stmt = this.db.prepare(` SELECT * FROM pois WHERE world_id = ? AND (name LIKE ? OR description LIKE ?) ORDER BY name `); const pattern = `%${query}%`; const rows = stmt.all(worldId, pattern, pattern) as POIRow[]; return rows.map(row => this.rowToPOI(row)); } // ============================================================ // HELPERS // ============================================================ private rowToPOI(row: POIRow): POI { return POISchema.parse({ id: row.id, worldId: row.world_id, regionId: row.region_id || undefined, x: row.x, y: row.y, name: row.name, description: row.description || undefined, category: row.category, icon: row.icon, structureId: row.structure_id || undefined, networkId: row.network_id || undefined, entranceRoomId: row.entrance_room_id || undefined, discoveryState: row.discovery_state, discoveredBy: JSON.parse(row.discovered_by), discoveryDC: row.discovery_dc ?? undefined, parentPOIId: row.parent_poi_id || undefined, childPOIIds: JSON.parse(row.child_poi_ids), population: row.population, level: row.level ?? undefined, tags: JSON.parse(row.tags), createdAt: row.created_at, updatedAt: row.updated_at }); } } interface POIRow { id: string; world_id: string; region_id: string | null; x: number; y: number; name: string; description: string | null; category: string; icon: string; structure_id: string | null; network_id: string | null; entrance_room_id: string | null; discovery_state: string; discovered_by: string; discovery_dc: number | null; parent_poi_id: string | null; child_poi_ids: string; population: number; level: number | null; tags: string; 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