const { pool, query } = require('../../utils/db') // Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas` // empties and refills all six inside one transaction, and nothing else in the // codebase writes to them. There are no foreign keys, consistent with every // other shard_* table. const BATCH = 500 const ATLAS_TABLES = [ 'shard_spawn_point_types', 'shard_spawn_points', 'shard_spawn_creatures', 'shard_regions', 'shard_landmarks', 'shard_champion_spawns', ] async function insertBatched(conn, sql, rows) { for (let i = 0; i < rows.length; i += BATCH) { await conn.batch(sql, rows.slice(i, i + BATCH)) } return rows.length } /** * Replace the entire atlas in one transaction. * * All-or-nothing on purpose: a failed reload must leave the previous atlas * intact rather than a half-loaded world, since a partially-imported atlas is * indistinguishable from a real one to anyone reading it. * * `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly * commits, which would defeat exactly that guarantee. At ~7k rows the cost of * `DELETE` is irrelevant. */ async function replaceAtlas(atlas, art = {}) { const conn = await pool.getConnection() const counts = {} try { await conn.beginTransaction() for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`) counts.creatures = await insertBatched( conn, 'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)', atlas.creatures.map((c) => [ c.slug, c.name, c.total ?? 0, c.points ?? 0, JSON.stringify(c.facets ?? {}), art[c.slug] ?? null, ]), ) counts.regions = await insertBatched( conn, 'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)', atlas.regions.map((r) => [ r.facet, r.name, r.type || null, r.priority ?? 0, r.parent || null, JSON.stringify(r.rects ?? []), ]), ) counts.landmarks = await insertBatched( conn, 'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)', atlas.landmarks.map((l) => [ l.facet, l.name, l.group || null, l.x ?? 0, l.y ?? 0, l.z ?? 0, ]), ) counts.champions = await insertBatched( conn, 'INSERT INTO shard_champion_spawns ' + '(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' + 'VALUES (?,?,?,?,?,?,?,?,?,?,?)', atlas.champions.map((c) => [ c.slug, c.name, c.group || null, c.type || null, c.randomType ? 1 : 0, c.facet, c.x ?? 0, c.y ?? 0, c.z ?? 0, c.radius ?? 0, c.label || null, ]), ) // Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the // join rows need to know them and `conn.batch()` reports no usable insertId // for a multi-row insert. Safe because this transaction just emptied the // table and nothing else writes to it. counts.points = await insertBatched( conn, 'INSERT INTO shard_spawn_points ' + '(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' + 'tod_start, tod_end, tod_mode, region, landmark, label) ' + 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', atlas.points.map((p, i) => [ i + 1, p.facet, p.name, p.x, p.y, p.width ?? 0, p.height ?? 0, p.range ?? 0, p.maxCount ?? 0, p.minDelay ?? 0, p.maxDelay ?? 0, p.todStart ?? 0, p.todEnd ?? 0, p.todMode ?? 0, p.region, p.landmark, p.label || 'Wilderness', ]), ) counts.pointTypes = await insertBatched( conn, 'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)', atlas.pointTypes, ) await conn.query( 'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' + 'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP', [JSON.stringify({ ...atlas.meta, importedCounts: counts })], ) // A completed import answers whatever was pending. await conn.query('DELETE FROM shard_atlas_pending') await conn.commit() return counts } catch (err) { await conn.rollback().catch(() => {}) throw err } finally { conn.release() } } async function getMeta() { const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1') if (rows.length === 0) return null const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload return { ...payload, importedAt: rows[0].imported_at } } /** Facet names currently loaded, used to detect a facet disappearing. */ async function getFacets() { const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet') return rows.map((row) => row.facet) } // ── Pending review ───────────────────────────────────────────────────────── async function getPending() { const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1') if (rows.length === 0) return null const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at } } async function setPending(payload, status = 'pending') { return query( 'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' + 'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' + 'detected_at = CURRENT_TIMESTAMP', [status, JSON.stringify(payload)], ) } async function clearPending() { return query('DELETE FROM shard_atlas_pending') } // ── Reads (the public /atlas surface) ────────────────────────────────────── // // Every read here is a plain indexed query over ~7k rows and is served entirely // from MariaDB: the atlas is static shard content, so nothing on this path // touches the sidecar and nothing degrades when the shard is down. // // A facet filter is expressed as EXISTS over the points, never as a JSON path // built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed // by facet name, and matching a key means either concatenating the name into a // path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as // wildcards, so `?facet=%` would quietly match everything. The join is exact and // uses the indexes that already exist. const CREATURE_FACET_EXISTS = `EXISTS ( SELECT 1 FROM shard_spawn_point_types t JOIN shard_spawn_points p ON p.id = t.point_id WHERE t.slug = c.slug AND p.facet = ? )` // Build the WHERE for a creature search. `q` is a substring match on the display // name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no // minimum token length to break a search for "orc". function creatureWhere({ q, facet }) { const where = [] const params = [] if (q) { where.push('c.name LIKE ?') params.push(`%${q}%`) } if (facet) { where.push(CREATURE_FACET_EXISTS) params.push(facet) } return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params } } async function countCreatures({ q = '', facet = '' } = {}) { const { sql, params } = creatureWhere({ q, facet }) const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params) return rows[0] ? Number(rows[0].n) : 0 } function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) { const { sql, params } = creatureWhere({ q, facet }) return query( `SELECT c.slug, c.name, c.total, c.points, c.facets, c.art FROM shard_spawn_creatures c ${sql} ORDER BY c.total DESC, c.name ASC LIMIT ? OFFSET ?`, [...params, limit, offset], ) } async function getCreature(slug) { const rows = await query( 'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?', [slug], ) return rows[0] || null } /** * Where a creature spawns, grouped by resolved place. * * This is the answer the atlas exists to give — "lizardman → Shrines, * Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455 * point rows in Node. */ function listCreaturePlaces(slug, { facet = '' } = {}) { const params = [slug] let facetSql = '' if (facet) { facetSql = 'AND p.facet = ?' params.push(facet) } return query( `SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive FROM shard_spawn_point_types t JOIN shard_spawn_points p ON p.id = t.point_id WHERE t.slug = ? ${facetSql} GROUP BY p.facet, p.label ORDER BY spawners DESC, p.facet ASC, p.label ASC`, params, ) } /** The individual spawners for a creature, newest-largest first. Bounded. */ function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) { const params = [slug] let facetSql = '' if (facet) { facetSql = 'AND p.facet = ?' params.push(facet) } params.push(limit) return query( `SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range, p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode, p.region, p.landmark, p.label, t.max_count FROM shard_spawn_point_types t JOIN shard_spawn_points p ON p.id = t.point_id WHERE t.slug = ? ${facetSql} ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC LIMIT ?`, params, ) } /** Every other creature sharing a spawner with this one. */ function listCreatureCompanions(slug, { limit = 24 } = {}) { return query( `SELECT o.slug, c.name, COUNT(*) AS shared FROM shard_spawn_point_types t JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug JOIN shard_spawn_creatures c ON c.slug = o.slug WHERE t.slug = ? GROUP BY o.slug, c.name ORDER BY shared DESC, c.name ASC LIMIT ?`, [slug, limit], ) } function listRegions({ facet = '', q = '' } = {}) { const where = [] const params = [] if (facet) { where.push('facet = ?') params.push(facet) } if (q) { where.push('name LIKE ?') params.push(`%${q}%`) } return query( `SELECT facet, name, type, priority, parent, rects FROM shard_regions ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY facet ASC, name ASC`, params, ) } function listLandmarks({ facet = '', q = '' } = {}) { const where = [] const params = [] if (facet) { where.push('facet = ?') params.push(facet) } if (q) { where.push('(name LIKE ? OR grp LIKE ?)') params.push(`%${q}%`, `%${q}%`) } return query( `SELECT facet, name, grp, x, y, z FROM shard_landmarks ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY facet ASC, grp ASC, name ASC`, params, ) } function listChampions({ facet = '' } = {}) { const params = [] let where = '' if (facet) { where = 'WHERE facet = ?' params.push(facet) } return query( `SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label FROM shard_champion_spawns ${where} ORDER BY facet ASC, name ASC`, params, ) } module.exports = { replaceAtlas, getMeta, getFacets, getPending, setPending, clearPending, countCreatures, listCreatures, getCreature, listCreaturePlaces, listCreaturePoints, listCreatureCompanions, listRegions, listLandmarks, listChampions, }