/** * WorldBlockCache — LRU write-through cache for world blocks * * Replaces the unbounded in-memory Map that previously loaded ALL blocks at * startup. Provides the same Map-like interface (get/set/delete) expected by * DStarLite pathfinder and Turtle._deleteBlockAtPosition, but caps memory * usage at `maxSize` entries and falls through to SQLite on cache miss. * * Bulk reads (initial_state, /api/world/blocks) bypass the cache and query * the database directly via helper methods. */ export class WorldBlockCache { /** * @param {Object} db - The database module (server/database.js) * @param {number} maxSize - Max entries in the LRU cache (default 50 000) */ constructor(db, maxSize = 50_000) { this._db = db; this._maxSize = maxSize; this._cache = new Map(); // key -> { value, prev, next } (LRU doubly-linked) this._head = null; // most recently used this._tail = null; // least recently used // Track total block count from DB (updated lazily) this._dbCount = null; } // ========== Map-compatible interface ========== /** * Get a block by "x,y,z" key. * Returns the cached value or falls through to the database. */ get(key) { // Cache hit if (this._cache.has(key)) { const node = this._cache.get(key); this._promote(node); return node.value; } // Cache miss — query DB const [x, y, z] = key.split(',').map(Number); let row; try { row = this._db.getBlock(x, y, z); } catch (e) { return undefined; } if (!row) return undefined; const value = { name: row.name, metadata: row.metadata, discoveredBy: row.discoveredBy, timestamp: row.discovered_at, }; this._put(key, value); return value; } /** * Store a block (write-through: updates cache + DB is handled by caller via storeBlock). */ set(key, value) { this._put(key, value); this._dbCount = null; // invalidate count cache return this; } /** * Delete a block from cache (DB deletion handled by caller). */ delete(key) { if (this._cache.has(key)) { const node = this._cache.get(key); this._unlink(node); this._cache.delete(key); } this._dbCount = null; return true; } /** * Check if a key exists (checks cache first, then DB). */ has(key) { if (this._cache.has(key)) return true; const [x, y, z] = key.split(',').map(Number); try { return this._db.getBlock(x, y, z) !== null; } catch (e) { return false; } } /** * Approximate size — returns DB count (not cache size). */ get size() { if (this._dbCount === null) { try { this._dbCount = this._db.getWorldBlockCount(); } catch (e) { // Fallback: count cached entries this._dbCount = this._cache.size; } } return this._dbCount; } /** * Iterate all entries — queries DB directly (not bounded by cache). * Used for initial_state and /api/world/blocks. * Returns an iterator of [key, value] pairs. */ *entries() { const rows = this._db.getWorldBlocks(100000); for (const row of rows) { const key = `${row.x},${row.y},${row.z}`; yield [key, { name: row.block_name, metadata: row.metadata, discoveredBy: row.discovered_by, timestamp: row.discovered_at, }]; } } // ========== Bulk helpers for API / WebSocket ========== /** * Get blocks in an area (for spatial queries). */ getBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) { return this._db.getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ); } /** * Get all blocks formatted for the /api/world/blocks response. * @param {number} limit - Max rows to return */ getAllBlocksForAPI(limit = 100000) { const rows = this._db.getWorldBlocks(limit); return rows.map(row => ({ x: row.x, y: row.y, z: row.z, name: row.block_name, metadata: row.metadata, discoveredBy: row.discovered_by, timestamp: row.discovered_at, })); } /** * Warm the cache with blocks near a set of positions (e.g., turtle locations). */ warmArea(cx, cy, cz, radius = 32) { const blocks = this._db.getWorldBlocksInArea( cx - radius, cy - radius, cz - radius, cx + radius, cy + radius, cz + radius, ); for (const row of blocks) { const key = `${row.x},${row.y},${row.z}`; if (!this._cache.has(key)) { this._put(key, { name: row.block_name, metadata: row.metadata, discoveredBy: row.discovered_by, timestamp: row.discovered_at, }); } } } // ========== Internal LRU ========== _put(key, value) { if (this._cache.has(key)) { const node = this._cache.get(key); node.value = value; this._promote(node); } else { const node = { key, value, prev: null, next: null }; this._cache.set(key, node); this._addToHead(node); // Evict if over capacity if (this._cache.size > this._maxSize) { this._evict(); } } } _promote(node) { if (node === this._head) return; this._unlink(node); this._addToHead(node); } _addToHead(node) { node.prev = null; node.next = this._head; if (this._head) this._head.prev = node; this._head = node; if (!this._tail) this._tail = node; } _unlink(node) { if (node.prev) node.prev.next = node.next; else this._head = node.next; if (node.next) node.next.prev = node.prev; else this._tail = node.prev; node.prev = null; node.next = null; } _evict() { if (!this._tail) return; const evicted = this._tail; this._unlink(evicted); this._cache.delete(evicted.key); } }