// ── 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.** Your module lives at // `/modules//`, which is 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`. // This is not a style rule: 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 worldRouter = require('./router/public/world.router') const clansRouter = require('./router/public/clans.router') const clanProvider = require('./model/clans/clanProvider.model') const eventActions = require('./config/eventActions') const boot = require('./boot') /* eslint-enable global-require */ const log = core.logger() // One prefix, on one tier. 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 you forgot to declare and a prefix you declared and // never registered both fail loudly at boot instead of quietly at runtime. // // This mounts at `/api/v1/public/world`. The router sits INSIDE the tier // router, so it structurally cannot reach above its prefix, and the tier's own // gate is already applied: `public` is behind nothing by design, `admin` sits // behind `noindex, isLoggedIn, requireRole(...)` and `player` behind // `noindex, requireAuth`. You add per-route gates on top; you never // re-implement the tier gate. // // **Prefixes share one namespace with core's own, and `/world` was chosen to // stay out of it.** Core answers `/api/v1/public/` + contact, modules, pages, // posts, settings, status, version and wiki. The loader rejects a collision at // registration time — but four of those eight are mounted at the tier root // rather than under a prefix of their own, and the loader's probe cannot see // them. `/status` would have been the obvious name for this module's route and // is exactly the one that would have gone wrong. Check the list before you // choose (§2.4, and MODULE_SYSTEM.md §2.7's own note about the probe). api.registerRoutes({ public: { '/world': worldRouter, '/clans': clansRouter }, }) // ── Teams: this module is the authoritative source of them ─────────────── // // A Team is a CORE entity — core owns the tables, the reconciler, the access // rules, the forum and the activity feed. What core does not own is the word for // one, because this game says clan and the next will say company. So core asks // this module three questions and never reads its tables (MODULE_API 1.6.0). // // **This is the first registration where core calls YOU and waits**, which is // what makes it unlike every other line in this file: the others hand core a // router to mount or a row to draw. Two consequences worth carrying: // // • **Registration is a claim, not a call.** Nothing in the provider runs // until core reconciles, which is after `onBoot` — which is what makes it // legal for every one of its methods to read the database while this // function may not (§2.2). // • **One provider per deployment.** Unlike every other registry this holds a // single value: two modules answering "what Teams exist" would produce two // disjoint sets under one table with no rule for merging them. A second // registration is a collision, reported against the module that holds it. // // The whole object is passed rather than picking its members out, so adding the // optional ones is an edit to the provider and not to this file. api.registerTeamProvider(clanProvider) // ── Engagement: declaring what your game can announce ──────────────────── // // The three calls below are one seam, and it is the one where a module is most // tempted to reach past the boundary. **You declare what CAN happen; core // decides who is told.** A module never names a person, a channel or an // address, and never sends anything (MODULE_API 1.7.0 and 1.9.0; §2.7). // // A TRIGGER is not a notification stream, and the two are easy to confuse // because both are catalogs of things that happen. A stream is a subscribe // toggle you publish to yourself. A trigger is a PAYLOAD CONTRACT an operator // writes rules against — it says what variables the event carries and how wide // an audience it may ever be given, and core does the sending. Their ids share // one namespace, so declaring both for one id is legal and is one event with a // toggle and a contract; taking an id another module owns is not. api.registerEventTriggers([ { id: 'examplegame.world.status_changed', label: 'World came up or went down', description: 'The game server changed between online and offline.', kind: 'event', // The cooldown subject: "once per world", not "once per user". It must // NAME one of the variables below — core refuses the registration // otherwise, with this trigger's id in the message, and the module does not // load. That check exists because the failure it prevents is silent: a // subjectKey naming nothing keys every subject on `undefined`, which looks // exactly like the feature working right up until two worlds share it. subjectKey: 'worldName', audience: 'authenticated', // what a rule is CREATED with // ...and the widest it may EVER be given. Required, with no default, // because there is no safe value to guess: `owner` would silently break a // broadcast and `authenticated` would silently widen a staff-only event. // The values are ordered by CONTAINMENT, not by size — see chapter 2. ceiling: 'authenticated', version: 1, variables: [ // Every variable needs an `example`, and it is not decoration: it is what // lets an operator preview and test-send a template without waiting for a // real game event, which is the reason template systems ship untested. { name: 'worldName', type: 'string', required: true, example: 'Example World' }, { name: 'status', type: 'string', required: true, example: 'online' }, { name: 'players', type: 'int', required: false, example: 42 }, // A `url` is validated SITE-RELATIVE, because it ends up in an href in a // mail somebody opens days later. Never a full URL of your own. { name: 'url', type: 'url', required: false, example: '/world' }, ], }, ]) // An AUDIENCE is a named set of PEOPLE this module can resolve over its own // data, for an operator to point a rule at. "This clan's members" is one; // "everyone who opened the last mail" is not, and nothing here builds it. // // **The resolver returns user ids and nothing else.** It is not handed a // template, a channel or an address and it cannot enumerate them — core maps // ids to addresses on its own side, after preferences, suppression and the // verification gate. That is what stops this becoming the back door §2.7 spends // a section closing. // // Audiences are their OWN id space, unlike triggers and streams: an audience // names a set of people and a trigger names an event, so the two may share a // name without colliding. api.registerAudiences([ { id: 'examplegame.clan.members', label: 'Members of a clan', // `int` or `string` only, and CONSTANT — an operator fills these in when // they save the rule. There is no way to say "the clan this event was // about"; if a rule needs that, the EVENT carries its own recipients // instead. Finding that out late is a phase's worth of rework. params: [{ id: 'clanId', type: 'string', required: true }], ceiling: 'members', resolve: async ({ clanId }) => clanProvider.listClanMemberUserIds({ clanId }), }, ]) // Finally the CONTENT: the bodies your messages use, and the rules that decide // when one is sent. Both arrive **switched off** — `enabled` is not a parameter // and there is no call that sets it. An operator turns a module's mail on; // installing a module never does. // // The two halves have different lifetimes, and the asymmetry is the contract: // // • **Templates are re-ensured on every boot**, under `seedVersion`, so // improving a default body reaches deployments that never edited it — and // one an operator HAS edited is marked customized and left alone. Bump // `seedVersion` when the body changes; never for a comment. // • **Rule groups are offered ONCE, per named group key.** Re-offering would // resurrect a rule an operator deleted and reset one they enabled. So a rule // appended to an existing group reaches FRESH INSTALLS ONLY. That is the // guarantee rather than a limitation to work around: a rule that has to // reach existing deployments takes a NEW group key, and you choose that // knowingly because you name the groups. // // Core's generic bodies are a first-class answer, not a fallback: point a // channel at `notify.event` / `inapp.event` / `notify.digest` and author // nothing. Ship a body of your own when the message has something to say that a // structural projection of the payload cannot. Below, the mail does — a world // coming back deserves a sentence — and the in-app item does not, so it uses // core's. // // Note the two casings, which are not a slip: a TEMPLATE is an object this call // shapes (`triggerId`), and a RULE is a row (`trigger_id`). Copy them as they // are. api.registerEngagementSeeds({ templates: [ { // MUST be namespaced `.` — the key column is unique across the // whole table, and an unprefixed `notify.event` from a module would // collide with core's own body and win. key: 'examplegame.world-status-changed', name: 'World — status changed', channel: 'email', subject: '{{worldName}} is {{status}}', triggerId: 'examplegame.world.status_changed', triggerVersion: 1, seedVersion: 1, // The same block objects the template editor writes, so an operator can // open this in the admin panel and keep editing from here. // // **This is the one thing in this file core does not check for you.** // `registerEngagementSeeds` asserts that `blocks` is a non-empty array and // stops; the BODY is validated by the block registry, which runs in the // editor and in the renderer. So a malformed block registers, seeds, and // first shows itself when an operator opens the body or a rule fires. // Two that are easy to get wrong: every block carries its own `id`, and // `email.heading`'s `level` is 'h1' | 'h2' | 'h3' — not a number. blocks: [ { id: 'h', type: 'email.heading', props: { level: 'h2', text: '{{worldName}} is {{status}}' } }, { id: 'intro', type: 'email.text', props: { text: 'There are {{players}} players online right now.' } }, { id: 'cta', type: 'email.button', props: { label: 'Open the world page', url: '{{url}}' } }, ], }, ], ruleGroups: [ { key: 'world-v1', note: 'the world status rule, seeded once', rules: [ { trigger_id: 'examplegame.world.status_changed', name: 'World status changes', audience: 'authenticated', channels: ['email', 'inapp'], template_keys: { email: 'examplegame.world-status-changed', inapp: 'inapp.event', }, // Two ceilings on volume, and they answer different questions. The // cooldown is per SUBJECT — one mail per world per hour, however many // times it flaps. The hourly cap is per RULE, and is the thing that // keeps a misconfiguration from becoming a mail storm. cooldown_seconds: 3600, max_sends_per_hour: 200, }, ], }, ], }) // ── Events: what a scheduled event may do to your game ─────────────────── // // MODULE_API 1.10.0, `EVENTS.md` §F, and chapter 5 of this kit. Four // declarations, and the whole of the file they come from is about the four // rules that are invisible until an outage. // // **This is core CALLING YOU**, like the Team provider above and unlike // everything else in this function — but from further away than either, because // the thing on the other end is a game server. That distance is the reason an // action declares `budgetMs` and the reason its failure default is a retry. // // **Every one of the four is optional.** A module that registers none of them // leaves its deployment with an event engine that can announce, wait, cue a // human and publish results, which is a working product. Each one *adds* what // an author can reach for; none is load-bearing for the engine. // // Registered in this order because it is the order they depend on each other: // an action's `cost` may only name a budget some module declared, and a param's // `source` names an option source. Core resolves both after every module has // registered, so the order here is for a reader rather than for the loader. api.registerEventBudgets(eventActions.BUDGETS) api.registerEventOptionSources(eventActions.OPTION_SOURCES) api.registerEventLeases(eventActions.LEASES) api.registerEventActions(eventActions.ACTIONS) // 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. // // Both are optional. A module with neither still reaches `started`. api.onBoot(boot.onBoot) api.onShutdown(boot.onShutdown) log.info('registered', { version: require('../module.json').version, routes: 'public:/world,/clans', }) }