const core = require('../../core') const { query } = core // Raw SQL for the cliloc table. `shard_clilocs` is IMPORT-OWNED: `replaceAll` // empties and refills it inside one transaction, and nothing else in the // codebase writes to it. No foreign keys, consistent with every other shard_* // table. const BATCH = 1000 /** * Replace the entire cliloc table in one transaction. * * All-or-nothing on purpose: a failed reload must leave the previous table * intact rather than a half-loaded one, because a partially-imported cliloc * table is indistinguishable from a complete one to anyone reading it — you * would just see some items named and some not, which is also what "no table at * all" looks like. * * `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly * commits, which would defeat exactly that guarantee. (The same trap the spawn * atlas import documents; at ~123k rows `DELETE` is still well under a second.) */ async function replaceAll(entries, meta) { const conn = await core.pool.getConnection() try { await conn.beginTransaction() await conn.query('DELETE FROM shard_clilocs') // Blank entries are dropped rather than stored. Roughly HALF of a real // cliloc table is empty strings — ids the client reserves and never uses — // and a row that resolves to no name is indistinguishable from no row at // all to every caller. Dropping them halves the table (123,490 → ~67,500) // and, more importantly, makes the binary and text imports converge on // identical content: the binary format carries the blanks explicitly and a // text export may or may not, depending on the tool. // // Later duplicates win. Merging across sources already happened upstream in // `readCliloc`, so in practice this collapses nothing — it is kept because // the plain format permits a repeated id WITHIN one file and the client's // own loader resolves it the same way (its dictionary assignment // overwrites). Without it, a file the game itself would load happily would // fail the batch insert on a primary-key collision. const byNumber = new Map() let blank = 0 for (const entry of entries) { if (!Number.isInteger(entry.number)) continue if (String(entry.text ?? '').trim() === '') { blank++ continue } byNumber.set(entry.number, entry) } const rows = [...byNumber.values()].map((e) => [e.number, e.flag ?? 0, e.text]) for (let i = 0; i < rows.length; i += BATCH) { await conn.batch('INSERT INTO shard_clilocs (number, flag, text) VALUES (?,?,?)', rows.slice(i, i + BATCH)) } await conn.query( 'INSERT INTO shard_cliloc_meta (id, payload) VALUES (1, ?) ' + 'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP', [JSON.stringify({ ...meta, count: rows.length })], ) await conn.commit() return { count: rows.length, blank, duplicates: entries.length - blank - rows.length } } catch (err) { await conn.rollback().catch(() => {}) throw err } finally { conn.release() } } async function getMeta() { const rows = await query('SELECT payload, imported_at FROM shard_cliloc_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 } } /** * Look up a batch of ids. * * Batched rather than one-at-a-time because every caller has a LIST: a character * sheet resolves a dozen equipment ids at once, and a page of marketplace * listings resolves fifty. `IN (...)` with generated placeholders keeps it one * round trip and one parameterized statement. */ async function lookup(numbers) { if (!Array.isArray(numbers) || numbers.length === 0) return [] const ids = [...new Set(numbers.filter((n) => Number.isInteger(n)))] if (ids.length === 0) return [] const placeholders = ids.map(() => '?').join(',') return query(`SELECT number, text FROM shard_clilocs WHERE number IN (${placeholders})`, ids) } async function count() { const rows = await query('SELECT COUNT(*) AS n FROM shard_clilocs') return Number(rows[0]?.n) || 0 } module.exports = { replaceAll, getMeta, lookup, count, }