Four event verbs and the announce leg, per PLAN.md §29: - rust.participation.open / .collect: the plugin counts who takes part (seconds, kills or both, in a zone this run opened or the whole server) and collect files them as the run's participants, keyed by Steam id. - rust.kit.entitle: the five recipient modes (D101), rows in the new rust_perm_run_grants (D84) unioned into the permission push, one extra use of the kit per reward as site-held credits on perm.sync (D103), and the rust.kit.entitled notice deferred from phase 10 (D64). - rust.announce: one server or every server (D105). - rust.chat announce leg, speaking only on servers whose new news switch is on (D104) - a card on Admin -> Rust visibility (D106). Budgets rust.grants and rust.announcements; the kit source and four fixed-choice sources (core has no enum param type). rust_perm_run_grants carries core's idempotency key so a revert of a lost answer can find its rows. Protocol 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
207 lines
11 KiB
JavaScript
207 lines
11 KiB
JavaScript
// ── The server entry point ─────────────────────────────────────────────────
|
|
//
|
|
// Core requires this file once, synchronously, while its own `app.js` is still
|
|
// being required, and calls the exported function with `(ctx, api)`. That is the
|
|
// entire server-side handshake: everything this module can reach arrives on
|
|
// `ctx`, and everything it can offer is registered through `api`.
|
|
//
|
|
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
|
|
//
|
|
// ── Three rules, and each one has a failure behind it ──────────────────────
|
|
//
|
|
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
|
|
// with the connection pool pointed at a dead port — the route-manifest
|
|
// generator and the OpenAPI generator both do it — so a module that queried
|
|
// at registration time would hang both. Anything that needs a live database
|
|
// goes in `onBoot`, which runs after the schema is up.
|
|
//
|
|
// 2. **Never resolve what core owns.** This module lives at
|
|
// `<website>/modules/rust/`, outside core's `server/`, so Node's resolver
|
|
// never reaches core's `node_modules` and `require('express')` from here
|
|
// simply fails. express, express-validator, the database, the logger and the
|
|
// middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`. A
|
|
// second express in the process would be a second `Router` prototype, exactly
|
|
// as a second React would be a second renderer.
|
|
//
|
|
// 3. **Never reach into core's tree.** No relative path may escape this module's
|
|
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
|
|
//
|
|
// ── Why the requires are INSIDE the function ───────────────────────────────
|
|
//
|
|
// Every file below reaches core through `./core`, whose members resolve `ctx`
|
|
// when they are CALLED. But a router writes `const express = core.express` at its
|
|
// own file scope, and that runs the moment the file 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 breaks the module with an
|
|
// error about a missing `ctx`, thrown from a file that never mentions one.
|
|
//
|
|
// 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 publicRust = require('./router/public/rust.router')
|
|
const playerRust = require('./router/player/rust.router')
|
|
const adminRust = require('./router/admin/rust.router')
|
|
const usersRust = require('./router/admin/usersRust.router')
|
|
const teamProvider = require('./model/clans/teamProvider')
|
|
const { TRIGGERS } = require('./engagement/triggers')
|
|
const { STREAMS } = require('./engagement/streams')
|
|
const { AUDIENCES } = require('./engagement/audiences')
|
|
const seeds = require('./engagement/seeds')
|
|
const eventLeases = require('./eventLeases')
|
|
const eventWorld = require('./eventWorld')
|
|
const eventRewards = require('./eventRewards')
|
|
const boot = require('./boot')
|
|
/* eslint-enable global-require */
|
|
|
|
const log = core.logger()
|
|
|
|
// One prefix, on each of the three tiers (R14). The keys here must match
|
|
// `module.json`'s `mounts` exactly — the loader compares the two and rejects a
|
|
// mismatch in EITHER direction, so a route never declared and a prefix declared
|
|
// and never registered both fail loudly at boot rather than quietly at runtime.
|
|
//
|
|
// Each router sits INSIDE its tier router, so it structurally cannot reach
|
|
// above its prefix, and the tier's gate is already applied: `public` is behind
|
|
// nothing by design, `admin` behind `noindex, isLoggedIn, requireRole(...)` and
|
|
// `player` behind `noindex, requireAuth`. Per-route gates go on top; the tier
|
|
// gate is never re-implemented.
|
|
//
|
|
// **Prefixes share ONE namespace with core's own, and the collision probe
|
|
// cannot see all of it.** Core answers several public routes mounted at the
|
|
// tier root rather than under a prefix — `/status` and `/version` among them —
|
|
// and the loader's check cannot find those. `/rust` collides with nothing on
|
|
// any of the three tiers, checked against core's mount tables rather than
|
|
// assumed.
|
|
api.registerRoutes({
|
|
public: { '/rust': publicRust },
|
|
player: { '/rust': playerRust },
|
|
admin: { '/rust': adminRust },
|
|
})
|
|
|
|
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
|
|
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
|
|
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
|
|
// declared before the slot is mounted, so core wins any path conflict — it owns
|
|
// the user, and this module owns what it can say about one.
|
|
//
|
|
// **It is declared twice, in two different places, on purpose.** This call is
|
|
// the SERVER half and `module.json`'s `extensions` array is held against it by
|
|
// the loader. The CLIENT half is `registry.registerExtension(ID,
|
|
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
|
|
// phase 1 found that the hard way with `site.footer.status`, which is a client
|
|
// slot and fails the load outright when named there.
|
|
api.registerExtension('admin.users.detail', usersRust)
|
|
|
|
// Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
|
|
// becomes the deployment's one authoritative source of them. Core asks; the
|
|
// provider answers from the clan boards (`model/clans`), and refuses rather
|
|
// than guessing whenever no board is current.
|
|
//
|
|
// **One provider per deployment**, so a site running module-uo as well cannot
|
|
// have both — the second registration is a collision core reports against the
|
|
// module that made it. That is core's rule and a real constraint on a mixed
|
|
// UO + Rust site; it is recorded in §24 rather than worked around here.
|
|
api.registerTeamProvider(teamProvider)
|
|
|
|
// Notifications and engagement (R7, PLAN.md §25). Four registrations that are
|
|
// one decision, because they only mean something together:
|
|
//
|
|
// triggers what can happen, what a template may say about it, and the
|
|
// widest audience a rule on it may EVER have — the security
|
|
// boundary; core refuses a rule that widens a ceiling
|
|
// streams which of those may reach a phone. Core pushes an engagement
|
|
// rule only to devices subscribed to a stream of the SAME id, so
|
|
// a trigger missing here can never buzz anybody (D65)
|
|
// audiences named sets of people over this module's data, for an operator
|
|
// to point a rule at; each answers user ids and nothing else
|
|
// seeds the two bodies worth writing, and one disabled rule group per
|
|
// family — installing this module mails nobody
|
|
//
|
|
// What fires them is `engagement/emit.js`, off the ingest cursor and the
|
|
// refresh. Registration is a claim, not a call: nothing here touches the
|
|
// database, and the seeds are written by core after the schema is up.
|
|
//
|
|
// The announce leg arrived with phase 13b (D62, D104), when there was a chat
|
|
// verb to deliver through; it is registered below with the rewards. There is
|
|
// still no post hook: nothing in game mirrors a post as state.
|
|
api.registerEventTriggers(TRIGGERS)
|
|
api.registerNotificationStreams(STREAMS)
|
|
api.registerAudiences(AUDIENCES)
|
|
api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS })
|
|
|
|
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
|
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
|
// that must not serve traffic until it has warmed a cache gets that for free.
|
|
// It has no timeout, deliberately: a slow boot delays the listener, which is the
|
|
// guarantee rather than a problem to be timed out.
|
|
//
|
|
// `onShutdown` runs while core's database pool and push dispatcher are still
|
|
// open, because flushing through them is the only thing it is for. It gets a
|
|
// five-second budget and is abandoned past it.
|
|
api.onBoot(boot.onBoot)
|
|
api.onShutdown(boot.onShutdown)
|
|
|
|
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
|
|
// and must give back. Core's `core.lease` is the verb; these are the values it
|
|
// may name and the four callables each ships. Every lease is targeted and the
|
|
// target names the server (D73), which is how one value on one server gets
|
|
// exactly one holder without core learning what a server is.
|
|
//
|
|
// The option sources are the three targets' own (D78).
|
|
api.registerEventLeases(eventLeases.LEASES)
|
|
|
|
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
|
|
// back — a zone, crates, NPCs — and the budgets that price them, each declared
|
|
// beside the verb that spends it (D79, D89). A lease spends none of them.
|
|
//
|
|
// The rewards (PLAN.md §29, protocol 10) join them: the tally, the kit reward
|
|
// and the chat line, with the two budgets they spend. Registered in the same
|
|
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
|
|
// its own statement of what it declares.
|
|
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
|
|
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
|
|
|
|
// News in game chat (D104). Core enqueues every registered leg for every
|
|
// published post, so the leg itself sends only to the servers whose switch an
|
|
// operator turned on — off by default, and a server that is down is skipped.
|
|
api.registerAnnounceLeg(eventRewards.LEG)
|
|
|
|
// ONE call for every option source: core takes a batch once, as this module's
|
|
// complete statement, and refuses a second.
|
|
api.registerEventOptionSources([
|
|
...eventLeases.OPTION_SOURCES,
|
|
...eventWorld.OPTION_SOURCES,
|
|
...eventRewards.OPTION_SOURCES,
|
|
])
|
|
|
|
// Everything else this module will register — the slash commands — is
|
|
// deliberately absent, and arrives with the phase that has something real to
|
|
// put in it. A registration with nothing behind it is worse than a missing
|
|
// one: a declared trigger nothing emits and a declared slot nothing fills are
|
|
// both surfaces an operator can configure and then wait on.
|
|
|
|
log.info('registered', {
|
|
version: require('../module.json').version,
|
|
routes: 'public:/rust player:/rust admin:/rust',
|
|
extensions: 'admin.users.detail',
|
|
teams: 'first-party clans',
|
|
triggers: TRIGGERS.length,
|
|
streams: STREAMS.length,
|
|
audiences: AUDIENCES.length,
|
|
leases: eventLeases.LEASES.length,
|
|
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
|
|
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
|
|
announceLeg: eventRewards.LEG.leg,
|
|
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length,
|
|
})
|
|
}
|