Files
Module-uo/server/index.js
wtclaude 88bfe9310e
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 40s
feat(events): one lease and the participation verbs (Phase 11b)
The UO half of protocol 6 part b. No route added, no schema change, no
MODULE_API bump.

`uo.playercaps.skillcap` is the one lease, and the catalog is short because
ServUO made it short: of the 158 non-Bridge `Config.Get` call sites in
`Scripts/`, roughly eight are read live. This one is read inside
`CharacterCreation.cs`'s per-character path, so it is both live and observable --
which is what "proven" has to mean, since the failure an allowlist exists to
prevent is a key that applies cleanly and changes nothing.

Its `apply()` sends a DURATION rather than the deadline: an absolute time
computed here and honoured there is measured against two clocks, and a shard
running ten minutes fast would restore a ten-minute lease the instant it took it.
Its `restore()` turns `lease.drifted` into `{ drifted: true, current }` rather
than an error, because core records drift as a distinct successful outcome and an
error would put the row on the retry ladder. Its `inForce()` asks whether the
shard still HOLDS the lease, never whether the value still matches -- see the
core PR.

`uo.participation.open` / `.collect` count who took part and file them on the
success envelope. `open` is the one resource in this module that must NOT
reconcile by boot stamp: every other resource here lives in shard memory, so a
changed bootId IS the proof it is gone, while the participation ledger is written
into the world save precisely so it survives that restart. It asks instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:57 -05:00

196 lines
10 KiB
JavaScript

// ── module-uo's server entry point ─────────────────────────────────────────
//
// Core requires this file once, synchronously, while `app.js` is still being
// required, and calls the exported function with `(ctx, api)`. The normative
// contract is docs/website/MODULE_API.md §2.2; the three rules that shape every
// line below are worth restating where they will be read:
//
// 1. **No `await`, and no database.** `scripts/routeManifest.js` and
// `swagger/swagger.js` both require core's `app.js` with the pool pointed
// at a dead port, so a module that queried at registration time would hang
// both. Everything needing a live database is in `onBoot`.
// 2. **Never resolve what core owns.** This module lives at
// `<website>/modules/uo/`, outside `server/`, so Node's resolver never
// reaches core's `node_modules` and `require('express')` fails outright.
// express, express-validator, the database, the logger, the middleware and
// the rest of §2.3 arrive on `ctx` and are re-exported by `./core`.
// 3. **Never reach into core's tree.** No relative path may escape this
// module's root; `scripts/checkImports.js` enforces that in CI (§5.1).
//
// **Require order is load-bearing, and it is why the requires below are inside
// the function.** Every ported file reaches core through `./core`, whose members
// resolve `ctx` when called — but a router does `const express = core.express` at
// its own file scope, which runs the moment it is required. So `core.init(ctx)`
// has to happen before the first `require` of anything under `router/`. Hoisting
// these to the top of the file would break the module with an error about `ctx`
// being missing, from a file that never mentions it. Node caches modules, so
// requiring here costs nothing after the first call.
const core = require('./core')
/**
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
* @param {object} api what the module registers (§2.4)
*/
module.exports = function register(ctx, api) {
core.init(ctx)
/* eslint-disable global-require */
const publicShard = require('./router/public/shard.router')
const publicAtlas = require('./router/public/atlas.router')
const adminShard = require('./router/admin/shard.router')
const adminUoLink = require('./router/admin/uoLink.router')
const playerShard = require('./router/player/shard.router')
const usersShardExtension = require('./router/admin/usersShard.router')
const shardStreams = require('./config/shardStreams')
const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences')
const engagementSeeds = require('./config/engagementSeeds')
const uoEventActions = require('./config/uoEventActions')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command')
const boot = require('./boot')
/* eslint-enable global-require */
const log = core.logger()
// The five prefixes, exactly the ones `module.json` declares — the loader
// compares the two and rejects a mismatch in either direction. Each router
// mounts INSIDE its tier, so it structurally cannot reach above its prefix,
// and the tier's own gate is already applied: `/admin` sits behind
// `noindex, isLoggedIn, requireRole(...)`, `/player` behind
// `noindex, requireAuth`, `/public` behind nothing by design.
//
// The URLs these produce are byte-identical to the ones core served before the
// extraction (§1.2). That is the whole point of moving the code and not the
// paths: the shipped Android app calls `POST /api/v1/admin/shard/kick`, and the
// Discord bot reads `/api/v1/public/shard/*`, and neither knows or needs to
// know that a module answers now.
api.registerRoutes({
public: { '/shard': publicShard, '/atlas': publicAtlas },
admin: { '/shard': adminShard, '/uo-link': adminUoLink },
player: { '/shard': playerShard },
})
// The six `/admin/users/:id/shard/*` URLs, which hang off a CORE resource and
// therefore cannot be a mount of our own (§1.9). Core declares the slot in
// `users.router.js` and we fill it; the router gets `req.params.id` from the
// parent via `mergeParams`. Core's own routes on the resource win any path
// conflict, which is correct — it owns the user.
api.registerExtension('admin.users.detail', usersShardExtension)
// The push catalog and the news leg. Core kept the push infrastructure and the
// announce worker; what it never had was an opinion about *shard* streams or
// about talking to a town crier, and those are content (MODULE_SYSTEM.md §1.8).
//
// Seven of these stream ids and the leg id `towncrier` are grandfathered
// (§6.5) — they are stored in `notification_subs` and `announce_job_legs.leg`
// and read by the shipped Android app, so a rename here is a data migration
// plus a client break rather than a tidy-up.
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
// The engagement contract (MODULE_API 1.7.0, ENGAGEMENT.md Phase 11). Triggers
// are PAYLOAD contracts: what a rule may fire on, what a template may
// interpolate, and — the part that is a security boundary — the widest audience
// an operator may ever give each one. `uo.cheat.detected` ceilings at `staff`
// and the three operator-facing ones at `admin` (added to the lattice in 1.8.0),
// and core refuses a rule that widens either.
//
// **Triggers and notification streams share ONE id namespace** (§7.2), so this
// registration and the one above are two facets of one space and core enforces
// that an id has exactly one owner across both. None of the ids below reuses a
// stream id: the stream catalog keeps its seven grandfathered names and these
// are the `uo.*`-prefixed ones §8.6 specifies. A trigger-only id gets email and
// in-app preferences and no push toggle, which is correct — there is nothing to
// push it to, and the shipped Android client's catalog is unchanged.
api.registerEventTriggers(shardTriggers.TRIGGERS)
// Audiences are named sets of PEOPLE an operator composes rules and segments
// out of (§5.1a). Their own id space, and their own ceiling arithmetic: a
// composition takes the narrowest ceiling it contains, never the widest.
//
// Registration is a claim; nothing resolves until the engine asks, which is
// after `onBoot` — and it must be, because every resolver reads the database
// and registration must not (§2.2 rule 1).
api.registerAudiences(shardAudiences.AUDIENCES)
// What this module SHIPS behind those two (MODULE_API 1.9.0, ENGAGEMENT.md
// Phase 11b): sixteen in-universe message bodies on two channels each, and
// twenty-five rules — every one of them `enabled = 0`, which the registry
// enforces rather than trusts.
//
// **A catalogue an operator turns on, not a switch that fires on upgrade.**
// Nothing here mails anybody: a rule that is off produces nothing, and a rule
// that is on still passes the ceiling, the per-user preference, the suppression
// list and the verification gate before anything is sent — all of them core's.
//
// The nine security and operational triggers point at core's generic bodies
// (decision 9). A cheat report should read like a cheat report.
//
// ONE rule group, and the choice is deliberate: a group is seeded once, so a
// twenty-sixth rule appended to `triggers-v1` in a later version would reach
// fresh installs ONLY. A future trigger wants its own group key.
api.registerEngagementSeeds({
templates: engagementSeeds.TEMPLATES,
ruleGroups: engagementSeeds.RULE_GROUPS,
})
// Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here.
//
// Registration is a claim, not a call — nothing below runs until core
// reconciles, which is after `onBoot`. That matters because every method reads
// the database, and registration must not.
api.registerTeamProvider(teamProvider)
// `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
// §7.1). The definition travels to the bot; the handler stays here and runs in
// the website process, because the bot container has no `modules` volume and
// cannot load a line of this module's code.
//
// Core registers NO commands of its own. "Guild" is this module's word — core
// does not own it on a page (phase 3) and does not publish it in a channel
// either.
api.registerSlashCommands([guildCommand])
// The event contract (MODULE_API 1.10.0, EVENTS.md F, EVENTS_PLAN.md Phase 9).
// Three verbs an event author can put in a step, the one budget dimension that
// bounds a broadcast, and the three option sources the spawn atlas answers.
//
// **All of it is optional, by the contract's own posture.** A deployment
// without this module still has an event engine that can announce, wait, cue a
// human and publish results; what these add is the ability for an event to
// reach the GAME. Nothing here is a precondition for anything of core's.
//
// The wave is deliberately the verbs that need no protocol change: the write
// plane they use has existed since protocol 2.1 and the admin screens have
// driven it by hand for months. The world verbs -- creatures, gates, leases --
// wait for Phase 11 to put an idempotency key and a lease deadline on the wire,
// because a world write core cannot prove ran exactly once is not one this
// module is willing to make unattended.
api.registerEventBudgets(uoEventActions.BUDGETS)
api.registerEventActions(uoEventActions.ACTIONS)
// Phase 11b. One live-read config key, and the module never writes it: an author
// puts `core.lease` in a step and core owns the duration bound, the
// two-events-one-target check and the teardown restore.
api.registerEventLeases(uoEventActions.LEASES)
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
streams: shardStreams.STREAMS.length,
triggers: shardTriggers.TRIGGERS.length,
audiences: shardAudiences.AUDIENCES.length,
eventActions: uoEventActions.ACTIONS.length,
})
}