227 lines
5.7 KiB
JavaScript
227 lines
5.7 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|