Five targeted leases over two planes, the item grant, the world save, and the atlas work the spawner dropdown needed. FIVE LEASES, ONE FACTORY `uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and `uo.seasonal.status`. The four callables differ only in which key they name, so they are built rather than repeated: five copies would be five chances for one of them to forget the drift check, which is the one thing §F says a lease must not be allowed to skip. It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot express five seconds cannot express this shard's own data. The seasonal lease is a THREE-value enum over EIGHT events. §G called `GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus` has three values and it is `EventType` that has nine entries. Eight rather than nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own `DropEra` rather than `Status`, so leasing it would apply cleanly, read back, restore cleanly and do nothing at all. Two behaviours worth the review. `inForce()` reads the frame's `holds` rather than a row's `held` flag, because a catalog walk can enumerate the keys but never the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL restore: there is nothing to give back, and reporting it failed would leave a ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the lease plane's vocabulary. THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST Core has the participants in `event_run_participants`, but a module cannot read core's tables -- so the alternative was a new core surface handing them over. Not needed: the shard has held the run's ledger since it opened, keyed by the same serials core stores as `member_key`. And the grant is RETRYABLE. §G called it un-retryable because a lost acknowledgement and a grant that never applied were the same event, which is exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than grants: 500 gold to forty people and a candle to forty people are not the same imposition. THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a line citing a committed artifact -- there is no committed artifact, as `spawnAtlasSource.js` says in its own header. It is the ONLY name for one particular spawner that exists off the shard, so a property lease could not have had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read. `uo.options.spawners` is the first searchable source and the first that had to be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop two thirds of the world and say nothing about which two thirds. ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from Phase 11a onward every sidecar call on a real deployment would have been refused -- the whole event plane dead, loudly, for a reason nobody would look here for. 11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two places this repo declares it. It survived both because both live walks set the column by hand while standing the rig up, which is exactly what makes a migration nobody runs invisible. All three sites go to 7. The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER -- a real check they once failed -- but all three being equally stale passes it, and nothing in this repo can anchor it to the wire. Recorded in the model's own header so the next reader knows. CHECKS `npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals` clean; the client builds and its 42 tests pass. `check:swagger` reports the fragment stale -- it is ALREADY stale on `edge` (verified by stashing this branch's changes and re-running) and this phase adds no route, so it is left alone rather than regenerated inside an unrelated change. Two bugs the new tests caught in this branch's own code before it left: `counted()` returns `.count` and the grant read `.value`, so every grant went out with `amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s `ok` was ignored, so a bad hue passed silently instead of refusing. Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
475 lines
15 KiB
JavaScript
475 lines
15 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,
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
replaceAtlas,
|
|
getMeta,
|
|
getFacets,
|
|
getPending,
|
|
setPending,
|
|
clearPending,
|
|
countCreatures,
|
|
listCreatures,
|
|
getCreature,
|
|
listCreaturePlaces,
|
|
listCreaturePoints,
|
|
listCreatureCompanions,
|
|
listRegions,
|
|
listLandmarks,
|
|
listDecorTypes,
|
|
listSpawners,
|
|
getDecorType,
|
|
listChampions,
|
|
}
|