Until now the only way a creature got a picture on this site was for an
operator to open UOFiddler on a desktop, export sprites by hand, copy them to
the web host and write a spawnAtlas.art.json naming each one. Almost nobody
did, so shard_spawn_creatures.art was NULL on every install.
The shard has had those files the whole time. Admin -> Shard -> Import now
walks its asset manifest, fetches only the sprites whose hash changed, writes
them under uploads/atlas/, asks the shard for a body id per atlas creature
(§8: it CONSTRUCTS the creature and reads Body.BodyID, which is the only thing
that is right for a shard's own custom creatures) and points each creature at
its picture. On a stock client that is 787 portraits, about a megabyte.
**The one thing v8.md §12 got wrong, and it is not cosmetic.** It says
`shard_spawn_creatures.art` "starts being filled by the import". That table is
emptied and refilled by replaceAtlas on EVERY atlas refresh, and a refresh runs
on every boot -- so a filename stored there would be destroyed by an ordinary
re-parse of the ServUO tree, with the next Update finding the client files
unchanged, reporting "nothing to do", and never restoring it. Nothing would
report a fault; the pictures would just be gone.
So the assets and the body map live in their own tables outside that blast
radius, and applyAtlas re-derives `art` on the way past as
`{ ...derived, ...operatorMap }` -- which is also the one place "the operator's
own artwork wins" is enforced, on every rebuild rather than only at import.
Smaller decisions worth not rediscovering:
- The derivation joins on the catalogue KEY, not on the body id. The simpler
join is correct today and stops being correct the moment phase 6 adds
body/400/a2/f0, at which point one slug matches dozens of rows.
- Filenames are content-addressed. A stable name overwritten in place leaves
every browser and CDN serving the previous client's sprite, with the database
row perfectly correct.
- An unchanged key whose FILE is missing is fetched again. The row and the disk
can disagree (a wiped uploads volume, a restore from a dump), and a broken
image on a creature page is worse than one re-fetched sprite.
- A key the shard cannot render is not a failure. Two thirds of the playable
ghost and gargoyle bodies have no art on a stock client, and an import that
reported eight failures every time would teach an operator to ignore the panel.
- A key that VANISHED from the manifest needs review before anything changes:
an unmounted client volume and a deliberate downgrade look identical here.
23 new tests; 674 server and 42 client tests pass. The SQL was also run against
a real MariaDB, which is what proved the CONCAT join and the singleton CHECK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
531 lines
17 KiB
JavaScript
531 lines
17 KiB
JavaScript
const core = require('../../core')
|
|
|
|
const { query } = 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',
|
|
'shard_decor_types',
|
|
]
|
|
|
|
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 core.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,
|
|
]),
|
|
)
|
|
|
|
// Optional: a tree with no Data/Decoration leaves this empty rather than
|
|
// failing the import, and the decoration verb then simply has nothing to
|
|
// offer. `?? []` rather than a guard, so an atlas built by an older parser
|
|
// (no `decor` key at all) reloads cleanly instead of throwing here.
|
|
counts.decor = await insertBatched(
|
|
conn,
|
|
'INSERT INTO shard_decor_types (type, item_id, uses) VALUES (?,?,?)',
|
|
(atlas.decor ?? []).map((d) => [d.type, d.itemId ?? 0, d.uses ?? 0]),
|
|
)
|
|
|
|
// 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, unique_id, 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.uniqueId || null,
|
|
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,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Every decoration type this shard uses, most-used first.
|
|
*
|
|
* Ordered by `uses` because a dropdown of 313 types needs the ones the shard
|
|
* actually reaches for at the top; the alphabetical tiebreak keeps the order
|
|
* stable across imports, which matters for a form an author scrolls.
|
|
*/
|
|
function listDecorTypes({ q = '' } = {}) {
|
|
const where = []
|
|
const params = []
|
|
if (q) {
|
|
where.push('type LIKE ?')
|
|
params.push(`%${q}%`)
|
|
}
|
|
return query(
|
|
`SELECT type, item_id, uses
|
|
FROM shard_decor_types
|
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
ORDER BY uses DESC, type ASC`,
|
|
params,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Spawners an author can name, searched by name and bounded (Phase 12b).
|
|
*
|
|
* **A search rather than a list, and the numbers are why.** This tree has 6,707
|
|
* spawn points against a 2,000-entry dropdown bound, so a flat read would drop
|
|
* two thirds of the world and say nothing about which two thirds — the failure
|
|
* Phase 12a named for decoration, arriving for real. `resolveOptionSource` grew
|
|
* a `q` for this.
|
|
*
|
|
* Only rows with a `unique_id` are offered: that is the only name for a spawner
|
|
* that exists off the shard, and a row without one cannot be targeted from a
|
|
* form however it is labelled. A shard's own in-world spawners have none and are
|
|
* addressed by serial, which an author types rather than picks.
|
|
*
|
|
* Ordered by `max_count DESC` so the spawners worth an event's attention come
|
|
* first, with a stable alphabetical tiebreak for a form somebody scrolls.
|
|
*/
|
|
function listSpawners({ q = '', limit = 200 } = {}) {
|
|
const where = ['unique_id IS NOT NULL', "unique_id <> ''"]
|
|
const params = []
|
|
if (q) {
|
|
where.push('(name LIKE ? OR region LIKE ? OR landmark LIKE ?)')
|
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`)
|
|
}
|
|
params.push(Number(limit) || 200)
|
|
return query(
|
|
`SELECT unique_id, name, facet, region, landmark, max_count
|
|
FROM shard_spawn_points
|
|
WHERE ${where.join(' AND ')}
|
|
ORDER BY max_count DESC, name ASC
|
|
LIMIT ?`,
|
|
params,
|
|
)
|
|
}
|
|
|
|
/** One decoration type, or nothing when this shard's files never name it. */
|
|
async function getDecorType(type) {
|
|
const rows = await query(
|
|
'SELECT type, item_id, uses FROM shard_decor_types WHERE type = ?',
|
|
[type],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Every creature the atlas knows, as `{ slug, name }` (docs/link/v8.md §8).
|
|
*
|
|
* `name` is the ServUO CLASS NAME, not a display string invented here: the atlas
|
|
* build picks the winning spelling of the spawn type token, so "GiantSpider" is
|
|
* what the column holds and what `ScriptCompiler.FindTypeByName` will resolve.
|
|
* That is the one property that lets the asset import ask its question without a
|
|
* new column, and it is worth knowing before anyone "tidies" this into a
|
|
* prettified label.
|
|
*/
|
|
async function allCreatureTypes() {
|
|
return query('SELECT slug, name FROM shard_spawn_creatures ORDER BY slug')
|
|
}
|
|
|
|
/**
|
|
* Point creatures at their artwork, from a `{ slug: filename }` map.
|
|
*
|
|
* Everything NOT in the map is set back to NULL, which is deliberate: a creature
|
|
* whose body stopped resolving must lose its portrait rather than keep pointing
|
|
* at a file that is about to be deleted. A broken image is worse than no image,
|
|
* and no image is the state the whole atlas UI was designed around.
|
|
*
|
|
* One transaction, and a single `CASE` update rather than a statement per slug —
|
|
* at ~800 creatures the round trips are the cost, not the work.
|
|
*/
|
|
async function setCreatureArt(map) {
|
|
const entries = Object.entries(map ?? {}).filter(
|
|
([slug, file]) => typeof slug === 'string' && slug !== '' && typeof file === 'string' && file !== '',
|
|
)
|
|
|
|
const conn = await core.pool.getConnection()
|
|
|
|
try {
|
|
await conn.beginTransaction()
|
|
await conn.query('UPDATE shard_spawn_creatures SET art = NULL WHERE art IS NOT NULL')
|
|
|
|
for (let i = 0; i < entries.length; i += BATCH) {
|
|
await conn.batch(
|
|
'UPDATE shard_spawn_creatures SET art = ? WHERE slug = ?',
|
|
entries.slice(i, i + BATCH).map(([slug, file]) => [file, slug]),
|
|
)
|
|
}
|
|
|
|
await conn.commit()
|
|
|
|
return entries.length
|
|
} catch (err) {
|
|
await conn.rollback().catch(() => {})
|
|
throw err
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
replaceAtlas,
|
|
allCreatureTypes,
|
|
setCreatureArt,
|
|
getMeta,
|
|
getFacets,
|
|
getPending,
|
|
setPending,
|
|
clearPending,
|
|
countCreatures,
|
|
listCreatures,
|
|
getCreature,
|
|
listCreaturePlaces,
|
|
listCreaturePoints,
|
|
listCreatureCompanions,
|
|
listRegions,
|
|
listLandmarks,
|
|
listDecorTypes,
|
|
listSpawners,
|
|
getDecorType,
|
|
listChampions,
|
|
}
|