THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.
The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:
• zero internal-file imports from the module into core; the built chunk has
zero bare import specifiers and bundles no React
• routes.manifest.json AND routes.guards.json are byte-identical
• /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
zero CSP violation reports
729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.
Two things the spike changed in the contract:
• ctx.express / ctx.validator. A module lives outside server/, so Node never
reaches server/node_modules and require('express') fails outright — the
server-side twin of the one-React rule, which §2.6 had only for the client.
• window.__rg.jsxRuntime, so a module can build with the automatic JSX
runtime its tooling already assumes rather than being forced to classic.
And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.
Co-Authored-By: Claude <noreply@anthropic.com>
391 lines
12 KiB
JavaScript
391 lines
12 KiB
JavaScript
const { pool, query } = require('../../core')
|
|
|
|
// 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,
|
|
}
|