feat(modules): ctx additions and the post-hook registry (API 1.1.0)

Everything the extraction needed from core that ctx did not already offer.
Additions only, so minor.

ctx.activity.log, because an admin action a module performs has to land in
core's one audit log or the trail has a hole exactly where a module operates the
game -- a module keeping its own log would be a second place to look, which in
practice means a place nobody looks. Write-only; reading the log is the admin
panel's job and it spans every actor.

ctx.users.getById, one function for one caller: the admin.users.detail slot
router needs the user its prefix names. ctx.site.baseUrl, because a module has
to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a
getter, not a captured string, so it cannot go stale against the env.

ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed
over whole. The split is deliberate: a module states its own window and cap
because it knows what its endpoints cost, and takes the plumbing from core so
there is one express-rate-limit in the process and one place a breach is logged.
accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit
behind the same counter -- so a module's account-change route has to land IN it
rather than beside it. marketLimiter was UO policy living in core's file and
leaves with the route it guards.

registerPostHook is the fourth registry, and the last thing binding core to the
module. Core's post controller called newsGump.syncPost directly: core's CMS
naming a UO file. It now publishes what it already knows and a subscriber
decides what to do with it. Not folded into registerAnnounceLeg, which fires on
the same transition, because a leg is a one-shot DELIVERY with retry and
classification while a post hook maintains idempotent STATE, runs on delete as
well as save, and refreshes silently on an edit.

Also fixes a real loader defect the extraction exposed: schema table names were
matched against the RAW file, so a fragment whose header says "every CREATE
TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table
called `carries`. module-uo's fragment hit exactly that. Both scans now read
split statements, which strip comments -- the same class of bug as a boundary
check failing on its own documentation.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:07:45 -05:00
committed by Claude
parent b649345484
commit f50541f374
73 changed files with 187 additions and 14020 deletions

View File

@@ -1,127 +0,0 @@
#!/usr/bin/env node
//
// Refresh the spawn atlas from a ServUO tree, from the command line.
//
// npm run atlas:import # use the configured path
// npm run atlas:import -- --servuo <path> # override it for this run
// npm run atlas:import -- --force # reimport even if unchanged
// npm run atlas:import -- --approve # apply a staged refresh
// npm run atlas:import -- --status # report without changing anything
//
// The server does this itself on every boot (see `shardAtlas.refreshOnBoot`), so
// this is for operators who want to apply a map change without a restart, and
// for approving a refresh that was staged because it would remove a facet.
//
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
// is argument parsing and output formatting.
const db = () => require('../src/utils/db')
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i]
if (flag === '--servuo') args.servuo = argv[++i]
else if (flag === '--force') args.force = true
else if (flag === '--approve') args.approve = true
else if (flag === '--reject') args.reject = true
else if (flag === '--status') args.status = true
else if (flag === '--help' || flag === '-h') args.help = true
}
return args
}
const USAGE = `
Refresh the spawn atlas from a ServUO tree.
node scripts/importSpawnAtlas.js [options]
--servuo <path> Use this tree for this run instead of the configured path.
--force Reimport even when the source files are unchanged.
--approve Apply a refresh that was staged for removing a facet.
--reject Keep the current atlas and dismiss the staged refresh.
--status Report atlas and source state; change nothing.
With no options this imports only if the tree differs from what is loaded.
`
function describe(result) {
switch (result.status) {
case 'skipped':
return (
'No ServUO path configured — nothing to import.\n' +
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
)
case 'unavailable':
return `ServUO tree unavailable: ${result.reason}\n`
case 'unchanged':
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
case 'needsReview': {
return (
'Refresh NOT applied — it would remove ' +
`${result.removedFacets.length} facet(s): ${result.removedFacets.join(', ')}.\n` +
'This is what a half-copied or mid-update tree looks like, so it has been\n' +
'staged for review. The current atlas is unchanged.\n' +
'Apply it with --approve, or dismiss it with --reject.\n'
)
}
case 'imported': {
const c = result.counts
const added = result.addedFacets?.length ? ` Added facets: ${result.addedFacets.join(', ')}.` : ''
const removed = result.removedFacets?.length
? ` Removed facets: ${result.removedFacets.join(', ')}.`
: ''
return (
`Atlas imported: ${c.points} points, ${c.creatures} creatures, ` +
`${c.pointTypes} point/type rows, ${c.regions} regions, ` +
`${c.landmarks} landmarks, ${c.champions} champion altars.${added}${removed}\n`
)
}
case 'failed':
return `Atlas refresh failed: ${result.reason}\n`
default:
return `${JSON.stringify(result, null, 2)}\n`
}
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help) {
process.stdout.write(USAGE)
return
}
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
// `--servuo` is a per-run override and deliberately does NOT persist to the
// configured path; changing where the atlas permanently reads from is an
// admin action, not a side effect of a one-off import.
const override = { path: args.servuo ?? '' }
if (args.status) {
process.stdout.write(`${JSON.stringify(await shardAtlas.status(override), null, 2)}\n`)
return
}
if (args.reject) {
process.stdout.write(`${JSON.stringify(await shardAtlas.rejectPending(), null, 2)}\n`)
return
}
const result = args.approve
? await shardAtlas.approvePending(override)
: await shardAtlas.refresh({ ...override, force: Boolean(args.force) })
process.stdout.write(describe(result))
if (result.status === 'failed') process.exitCode = 1
}
if (require.main === module) {
main()
.catch((err) => {
process.stderr.write(`atlas:import failed: ${err.message}\n`)
process.exitCode = 1
})
.finally(() => db().close())
}
module.exports = { describe, parseArgs }