Merge pull request 'feat(kit): the engagement contract, taught and built — cutover 5 of 7' (#9) from feat/engagement-contract into main
Reviewed-on: #9 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
|
|||||||
|
|
||||||
## What you register
|
## What you register
|
||||||
|
|
||||||
Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
|
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
|
||||||
is not their signatures but the model behind them.
|
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
|
||||||
|
model behind them.
|
||||||
|
|
||||||
**Every call stages; nothing is committed until your whole module is known good.**
|
**Every call stages; nothing is committed until your whole module is known good.**
|
||||||
The shape of a claim is checked at the call, so a malformed one throws with your
|
The shape of a claim is checked at the call, so a malformed one throws with your
|
||||||
@@ -279,6 +280,119 @@ Every hook is awaited and none may throw past core: a subscriber's failure costs
|
|||||||
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
|
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
|
||||||
somebody's blog post edit would be a worse bug than a stale mirror.
|
somebody's blog post edit would be a worse bug than a stale mirror.
|
||||||
|
|
||||||
|
### Telling core something happened
|
||||||
|
|
||||||
|
Three registrations and one call, and together they are the seam where a module
|
||||||
|
is most tempted to reach past the boundary. The rule that keeps them safe is one
|
||||||
|
sentence: **you declare what CAN happen; core decides who is told.**
|
||||||
|
|
||||||
|
A **trigger** is not a notification stream, and the two are easy to confuse
|
||||||
|
because both are catalogs of things that happen in your game. A stream is a
|
||||||
|
subscribe toggle, and you publish to it yourself. A trigger is a **payload
|
||||||
|
contract**: it names the variables an event carries and how wide an audience it
|
||||||
|
may ever be given, an operator writes rules against it, and *core* does the
|
||||||
|
sending. Their ids share one namespace, so declaring both for the same id is
|
||||||
|
legal — that is one event with a toggle and a contract — while taking an id
|
||||||
|
another module owns is not.
|
||||||
|
|
||||||
|
Two fields on a trigger are worth more than their size.
|
||||||
|
|
||||||
|
**`ceiling` is required and has no default, and the values are ordered by
|
||||||
|
containment rather than by size.** It is the widest audience a rule on this
|
||||||
|
trigger may ever be given. There is no safe value to guess: `owner` silently
|
||||||
|
breaks a broadcast, `authenticated` silently widens something meant for staff.
|
||||||
|
And the ladder reading of the seven values is the trap — a `staff` ceiling does
|
||||||
|
**not** permit `owner`, because "one person" for a cheat-detection event is *the
|
||||||
|
player it was detected on*. Fewer people is not less exposure.
|
||||||
|
|
||||||
|
**`subjectKey` must name one of your declared variables**, because it is what the
|
||||||
|
cooldown is keyed on — "once per house", not "once per user". Core checks it at
|
||||||
|
registration and refuses the module, so this is one you meet at your first boot
|
||||||
|
rather than in production. The check is there because the failure it prevents is
|
||||||
|
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
|
||||||
|
which looks exactly like the feature working right up until two houses share it.
|
||||||
|
|
||||||
|
Every variable needs an `example`, and it is not decoration: it is what lets an
|
||||||
|
operator preview and test-send a body without waiting for a real game event,
|
||||||
|
which is the reason template systems ship untested. The type set is closed and
|
||||||
|
has no `object` or `array` — a message that has to walk a structure has outgrown
|
||||||
|
interpolation.
|
||||||
|
|
||||||
|
An **audience** is a named set of *people* you can resolve over your 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.
|
||||||
|
|
||||||
|
**Your 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
|
||||||
|
narrowness is deliberate — a module still cannot send mail, and this is the
|
||||||
|
obvious place a back door would go. Two consequences follow from it:
|
||||||
|
|
||||||
|
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
|
||||||
|
its last good answer. Core enforces that, and your resolver should choose it
|
||||||
|
too, so the log can say which clan.
|
||||||
|
- **Its params are CONSTANT.** An operator fills them 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. This is the constraint most
|
||||||
|
worth knowing before you design around it rather than after.
|
||||||
|
|
||||||
|
The third registration ships the **content**: the bodies your messages use and
|
||||||
|
the rules that decide when one is sent. Both arrive **switched off**, and
|
||||||
|
`enabled` is not a parameter. 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 re-ensure on every boot** under a seed version, so a better default
|
||||||
|
reaches deployments that never edited it while one an operator *has* edited is
|
||||||
|
left alone. **Rule groups are offered once, per named group key**, because
|
||||||
|
re-offering would resurrect a rule somebody deleted and reset one they enabled.
|
||||||
|
The consequence is easy to trip over: a rule appended to an existing group
|
||||||
|
reaches **fresh installs only**. That is the guarantee rather than a limitation
|
||||||
|
to route around, and a rule that must reach existing deployments takes a new
|
||||||
|
group key. You name the groups, so the choice is yours to make knowingly.
|
||||||
|
|
||||||
|
Core's generic bodies are a first-class answer rather than a fallback. Point a
|
||||||
|
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
|
||||||
|
own when the message has something to say that a structural projection of the
|
||||||
|
payload cannot.
|
||||||
|
|
||||||
|
**One thing in a seeded body is not checked when you register it.** The call
|
||||||
|
asserts that `blocks` is a non-empty array and stops there; the body itself is
|
||||||
|
validated by the block registry, which runs in the editor and in the renderer. So
|
||||||
|
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
|
||||||
|
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
|
||||||
|
Templates once, and you have checked the half that boot cannot.
|
||||||
|
|
||||||
|
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
|
||||||
|
triggers — core binds the owner from the calling module and never reads it from
|
||||||
|
the arguments, so there is no shape of this call that fires somebody else's
|
||||||
|
event. It returns nothing and, in production, never throws: there is nothing a
|
||||||
|
module could correctly do about a delivery failure from inside a game-event
|
||||||
|
handler, so there is nothing to await. **Outside production it does throw**, at
|
||||||
|
your call site — a payload that does not match the contract you declared is a bug
|
||||||
|
rather than a condition, and the throw is how you meet it in your own tests
|
||||||
|
instead of in an operator's log six weeks later.
|
||||||
|
|
||||||
|
Beside it is the one call that skips the rules entirely.
|
||||||
|
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
|
||||||
|
inbox. Reach for it when there is nothing for an operator to decide — a job that
|
||||||
|
person started has finished — and for anything else use a trigger, so the message
|
||||||
|
can be turned off, re-targeted, or sent by mail as well without a code change. The
|
||||||
|
posture is `emit`'s: the owner is bound from the calling module, it returns
|
||||||
|
nothing, and it will not tell you that the user has that channel switched off,
|
||||||
|
because a module that could see that could enumerate people's preferences one
|
||||||
|
write at a time.
|
||||||
|
|
||||||
|
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
|
||||||
|
thirty seconds and emits only when the world's online state actually changed.
|
||||||
|
Core's cooldown and hourly cap would both hold if it did not — but leaning on
|
||||||
|
them means emitting "the world is still up" and calling it news, and the operator
|
||||||
|
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
|
||||||
|
Two smaller traps sit inside the same function and are worth reading in
|
||||||
|
`template/server/boot.js`: the previous state has to be read *before* the write,
|
||||||
|
or every poll looks like no change at all, and the very first boot has no previous
|
||||||
|
state, which is not a change either.
|
||||||
|
|
||||||
### Becoming the source of Teams
|
### Becoming the source of Teams
|
||||||
|
|
||||||
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
|
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
|
||||||
|
|||||||
@@ -1,34 +1,47 @@
|
|||||||
{
|
{
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
|
"ref": "66bb3b9a3fad01112c06f32d931c9bae56d22de6",
|
||||||
"why": [
|
"why": [
|
||||||
"The core this kit is written against, pinned to a commit rather than a branch.",
|
"The core this kit is written against, pinned to a commit rather than a branch.",
|
||||||
"This one is the Teams cutover, the commit MODULE_API_VERSION 1.6.0 reached",
|
"This one is the engagement cutover, the commit MODULE_API_VERSION 1.9.0 reached",
|
||||||
"`main` on, and 1.6.0 is what template/module.json declares. It moved here from",
|
"`main` on, and 1.9.0 is what template/module.json declares. It moved here from",
|
||||||
"the 1.5.0 bump because Teams expanded the contract the book teaches: the",
|
"1.6.0 (the Teams cutover) because engagement expanded the contract the book",
|
||||||
"template now registers a Team provider and declares slots for core to fill,",
|
"teaches by three registrations and two calls: a module now declares what its",
|
||||||
"and both are members that exist only at this ref and later.",
|
"game can announce and never who is told.",
|
||||||
"",
|
"",
|
||||||
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
||||||
"version template/module.json declares still equals this core's",
|
"version template/module.json declares still equals this core's",
|
||||||
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
|
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
|
||||||
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10).",
|
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10). Note what that means in the other",
|
||||||
|
"direction, because it is easy to misread as a safety net: the check clones",
|
||||||
|
"THIS ref, so a core that has moved past it does not turn the repo red on its",
|
||||||
|
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is",
|
||||||
|
"not wrong, it is DATED - and this file is where the date is written down.",
|
||||||
"",
|
"",
|
||||||
"That mechanism earned its keep this time. Writing the chapters against 1.6.0",
|
"The mechanism earned its keep again here. Writing chapter 2's engagement",
|
||||||
"found that core's inverted-slot fills named three of module-uo's slots",
|
"section against 1.9.0 found that the seeded body a module ships is the one",
|
||||||
"literally, so the direction worked for that one module and silently did",
|
"thing registerEngagementSeeds does not validate - it checks that `blocks` is a",
|
||||||
"nothing for any other game - an empty page with nothing logged. That is the",
|
"non-empty array and stops - so the template's own example body had a heading",
|
||||||
"exact class of thing a book written for an audience outside this org is meant",
|
"level of 2 where the block registry takes 'h2', and no block ids at all. It",
|
||||||
"to catch, and it was fixed in core before this pin moved.",
|
"would have registered, seeded, and failed the first time an operator opened it.",
|
||||||
|
"Caught by running the template's register() through core's real registry at",
|
||||||
|
"this ref, which is what a re-read is for; both the fix and the gap are now in",
|
||||||
|
"the chapter and beside the code.",
|
||||||
|
"",
|
||||||
|
"That gap is also why this file's own instruction is not enough on its own. The",
|
||||||
|
"template job builds and tests the template against fakes and checks this",
|
||||||
|
"number; it does not load the module into core. A declaration a fake accepts",
|
||||||
|
"and core refuses would ship green, so a pin move is a run against a real core,",
|
||||||
|
"not just an edit here.",
|
||||||
"",
|
"",
|
||||||
"The branch said `edge` until 2026-08-12, when the module system cut over and",
|
"The branch said `edge` until 2026-08-12, when the module system cut over and",
|
||||||
"that branch was deleted (MODULE_SYSTEM.md 2.9). Teams cut a second `edge` and",
|
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
|
||||||
"this pin skipped it entirely: the kit is written against what shipped, never",
|
"`edge` of their own and this pin skipped both: the kit is written against what",
|
||||||
"against what is in flight. Nothing in CI reads the branch field - it clones",
|
"shipped, never against what is in flight. Nothing in CI reads the branch field",
|
||||||
"the repo and checks out the sha - which is why a wrong label here would sit",
|
"- it clones the repo and checks out the sha - which is why a wrong label here",
|
||||||
"unnoticed. It is for the person deciding whether a newer core is worth",
|
"would sit unnoticed. It is for the person deciding whether a newer core is",
|
||||||
"re-reading the book for.",
|
"worth re-reading the book for.",
|
||||||
"",
|
"",
|
||||||
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
|
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
|
||||||
"sha, reviewable in a diff."
|
"sha, reviewable in a diff."
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ const { stripFences } = require('./lib/markdown')
|
|||||||
const ROOT = path.resolve(__dirname, '..')
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
const QUIET = process.argv.includes('--quiet')
|
const QUIET = process.argv.includes('--quiet')
|
||||||
|
|
||||||
// Directories that hold no prose we own.
|
// Directories that hold no prose we own. `.core` and `core` are core's own
|
||||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
// checkout: .gitignore reserves both because moving `ci/core-ref.json` means
|
||||||
|
// cloning core in here first, and without this that clone hands the reader nine
|
||||||
|
// broken links in somebody else's README the moment they follow the pin-bump
|
||||||
|
// instructions. CI never saw it — the clone happens in the `template` job and
|
||||||
|
// this check runs in `prose` — which is exactly the kind of failure that only
|
||||||
|
// ever meets a person.
|
||||||
|
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.core', 'core'])
|
||||||
|
|
||||||
/** Every markdown file in the repo, repo-relative, sorted. */
|
/** Every markdown file in the repo, repo-relative, sorted. */
|
||||||
function markdownFiles(dir = ROOT, out = []) {
|
function markdownFiles(dir = ROOT, out = []) {
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ backticking table names**.
|
|||||||
| `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) |
|
| `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) |
|
||||||
| `server/package.json` | package `name` and `description` |
|
| `server/package.json` | package `name` and `description` |
|
||||||
| `server/core.js` | the message every accessor throws |
|
| `server/core.js` | the message every accessor throws |
|
||||||
|
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
|
||||||
| `server/boot.js` | the placeholder world name |
|
| `server/boot.js` | the placeholder world name |
|
||||||
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
||||||
| `server/db/purge.sql` | the same table names |
|
| `server/db/purge.sql` | the same table names |
|
||||||
@@ -123,6 +124,7 @@ backticking table names**.
|
|||||||
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
|
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
|
||||||
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
||||||
| `server/test/_fakes.js` | `ctx.moduleId` |
|
| `server/test/_fakes.js` | `ctx.moduleId` |
|
||||||
|
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
|
||||||
| `server/test/worldStatus.test.js` | the fixture's world name |
|
| `server/test/worldStatus.test.js` | the fixture's world name |
|
||||||
| `server/test/clanProvider.test.js` | the fixture's world name |
|
| `server/test/clanProvider.test.js` | the fixture's world name |
|
||||||
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "examplegame",
|
"id": "examplegame",
|
||||||
"name": "Example Game",
|
"name": "Example Game",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"coreApi": "^1.6.0",
|
"coreApi": "^1.9.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
|
|||||||
@@ -52,7 +52,45 @@ async function refresh() {
|
|||||||
try {
|
try {
|
||||||
// A real module calls its sidecar's REST API here. Two hardcoded values
|
// A real module calls its sidecar's REST API here. Two hardcoded values
|
||||||
// stand in, so that the page renders and the seam is visible.
|
// stand in, so that the page renders and the seam is visible.
|
||||||
await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' })
|
const next = { online: true, players: 0, worldName: 'Example World' }
|
||||||
|
|
||||||
|
// ── Emitting a declared event ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// **Emit on the TRANSITION, not on the poll.** This function runs every
|
||||||
|
// thirty seconds; a rule on an event fired every thirty seconds is a rule
|
||||||
|
// that mails somebody every thirty seconds. Core has a cooldown and an
|
||||||
|
// hourly cap and they would both hold, but leaning on them means the module
|
||||||
|
// is emitting "the world is still up" and calling it news. Read the previous
|
||||||
|
// state, compare, and emit only when the answer changed.
|
||||||
|
//
|
||||||
|
// The read is BEFORE the write for the same reason, and getting that
|
||||||
|
// backwards is the easy version of this bug: after `setStatus` the previous
|
||||||
|
// value is gone and every poll looks like no change at all — an emitter that
|
||||||
|
// never fires and never errors.
|
||||||
|
const previous = await worldStatusDb.getStatus()
|
||||||
|
await worldStatusDb.setStatus(next)
|
||||||
|
|
||||||
|
// `previous === null` is the first boot on a fresh install, not a change.
|
||||||
|
// Treating it as one would announce the world coming online to everyone the
|
||||||
|
// first time an operator started the site.
|
||||||
|
if (previous && Boolean(previous.online) !== next.online) {
|
||||||
|
// Fire-and-forget: no await, no return value, nothing to handle. Core
|
||||||
|
// validates the payload against what `index.js` declared, and what a
|
||||||
|
// mismatch does depends on where you are running. **In production it is
|
||||||
|
// dropped and logged** against this module, because a notification must
|
||||||
|
// never be able to break the thing it is about. **Anywhere else it throws**,
|
||||||
|
// at this line, so the stack points at your own call instead of at a
|
||||||
|
// warning nobody reads. Neither is a condition to catch: a payload that
|
||||||
|
// does not match the contract you declared is a bug to fix.
|
||||||
|
core.emit('examplegame.world.status_changed', {
|
||||||
|
data: {
|
||||||
|
worldName: next.worldName,
|
||||||
|
status: next.online ? 'online' : 'offline',
|
||||||
|
players: next.players,
|
||||||
|
url: '/world',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('could not refresh world status', { error: err.message })
|
log.warn('could not refresh world status', { error: err.message })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,21 @@ module.exports = {
|
|||||||
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||||
get middleware() { return need().middleware },
|
get middleware() { return need().middleware },
|
||||||
|
|
||||||
|
// Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than
|
||||||
|
// exposed as `get events()`, so that `require('../core').emit` taken at file
|
||||||
|
// scope still resolves `ctx` at call time like everything else here.
|
||||||
|
//
|
||||||
|
// **It returns nothing, and in production it never throws at the caller.** The
|
||||||
|
// emit is the end of this module's involvement: core validates the payload
|
||||||
|
// against the declared contract, decides which rules match, resolves who they
|
||||||
|
// reach and sends. A module cannot address a person, choose a channel or write
|
||||||
|
// a subject line, and this seam is deliberately too narrow to try (§2.7).
|
||||||
|
//
|
||||||
|
// Outside production a bad payload throws here rather than being logged, which
|
||||||
|
// is the point: you meet the mismatch in your own tests instead of in an
|
||||||
|
// operator's log six weeks later.
|
||||||
|
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
|
||||||
|
|
||||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||||
// only correct way to find a file you shipped, because the working directory is
|
// only correct way to find a file you shipped, because the working directory is
|
||||||
// core's and the module's location is the loader's business.
|
// core's and the module's location is the loader's business.
|
||||||
|
|||||||
@@ -103,6 +103,165 @@ module.exports = function register(ctx, api) {
|
|||||||
// optional ones is an edit to the provider and not to this file.
|
// optional ones is an edit to the provider and not to this file.
|
||||||
api.registerTeamProvider(clanProvider)
|
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 `<moduleId>.` — 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
// 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
|
// 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.
|
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||||
|
|||||||
@@ -82,4 +82,25 @@ async function replaceClan({ externalId, name, abbr, memberCount, members }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listClans, findClan, listMembers, replaceClan, CLANS, MEMBERS }
|
/**
|
||||||
|
* The site accounts behind one clan's roster — the whole of an audience resolver.
|
||||||
|
*
|
||||||
|
* `user_id` is NULL for most characters, and the filter is the point: an audience
|
||||||
|
* resolves to PEOPLE WITH ACCOUNTS, and a character nobody has linked is not one.
|
||||||
|
* Returning its NULL would hand core a hole in an array it is about to mail.
|
||||||
|
*
|
||||||
|
* DISTINCT because one person may hold several characters in the same clan, and
|
||||||
|
* the resolver's contract is a set of users rather than a list of characters.
|
||||||
|
* Without it a three-character player is told three times.
|
||||||
|
*/
|
||||||
|
async function listMemberUserIds(clanId) {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT DISTINCT user_id AS userId
|
||||||
|
FROM ${MEMBERS}
|
||||||
|
WHERE clan_id = ? AND user_id IS NOT NULL`,
|
||||||
|
[clanId],
|
||||||
|
)
|
||||||
|
return rows.map((r) => r.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listClans, findClan, listMembers, listMemberUserIds, replaceClan, CLANS, MEMBERS }
|
||||||
|
|||||||
@@ -292,6 +292,36 @@ async function projectRoster(externalId, members, viewer) {
|
|||||||
// a page that does not exist, which is worse.
|
// a page that does not exist, which is worse.
|
||||||
const pageUrlTemplate = '/examplegame/clans/{externalId}'
|
const pageUrlTemplate = '/examplegame/clans/{externalId}'
|
||||||
|
|
||||||
|
// ── The audience resolver ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Registered in `index.js` as `examplegame.clan.members` and called by core when
|
||||||
|
// a rule pointed at that audience fires. It lives beside the provider because it
|
||||||
|
// answers a question about the same rows, and it is NOT part of the provider —
|
||||||
|
// core calls it through the audience registry, not through the five members
|
||||||
|
// above.
|
||||||
|
//
|
||||||
|
// **Three rules, and every one of them protects somebody's mailbox rather than
|
||||||
|
// this module's correctness.**
|
||||||
|
//
|
||||||
|
// 1. Return user ids and nothing else. You are not handed a template, a channel
|
||||||
|
// or an address, and you may not enumerate them; core maps ids to addresses
|
||||||
|
// on its own side, after preferences, suppression and the verification gate.
|
||||||
|
// 2. Never widen on failure. A resolver that cannot answer returns the EMPTY set
|
||||||
|
// — never "everyone", never the last good answer. Core treats a throw the
|
||||||
|
// same way, but doing it here is what lets the log say which clan.
|
||||||
|
// 3. It is a SET of people, not a list of characters. The `DISTINCT` is in the
|
||||||
|
// query for that reason (see `clanProvider.db.js`).
|
||||||
|
async function listClanMemberUserIds({ clanId }) {
|
||||||
|
try {
|
||||||
|
return await db.listMemberUserIds(clanId)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not resolve clan members; resolving to nobody', {
|
||||||
|
clanId, message: err.message,
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getTeams,
|
getTeams,
|
||||||
getTeamMembers,
|
getTeamMembers,
|
||||||
@@ -300,4 +330,5 @@ module.exports = {
|
|||||||
rosterVisibleTo,
|
rosterVisibleTo,
|
||||||
pageUrlTemplate,
|
pageUrlTemplate,
|
||||||
gameIsReachable,
|
gameIsReachable,
|
||||||
|
listClanMemberUserIds,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ function fakeCtx(overrides = {}) {
|
|||||||
return log
|
return log
|
||||||
},
|
},
|
||||||
auth: { getUserFromRequest: spy(null) },
|
auth: { getUserFromRequest: spy(null) },
|
||||||
|
// The engagement seam (§2.3). One method, recording, because that is the
|
||||||
|
// whole of what a module may do with it: fire a declared event and stop.
|
||||||
|
// Core's own emit is fire-and-forget and returns nothing, so this does too —
|
||||||
|
// a fake that returned a receipt would invite a module to wait on one.
|
||||||
|
events: { emit: spy(undefined) },
|
||||||
middleware: {
|
middleware: {
|
||||||
requireAuth: (req, res, next) => next(),
|
requireAuth: (req, res, next) => next(),
|
||||||
requireRole: () => (req, res, next) => next(),
|
requireRole: () => (req, res, next) => next(),
|
||||||
@@ -85,7 +90,10 @@ function fakeCtx(overrides = {}) {
|
|||||||
* an operator's install.
|
* an operator's install.
|
||||||
*/
|
*/
|
||||||
function fakeApi() {
|
function fakeApi() {
|
||||||
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null }
|
const record = {
|
||||||
|
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
||||||
|
triggers: null, audiences: null, engagementSeeds: null,
|
||||||
|
}
|
||||||
const called = new Set()
|
const called = new Set()
|
||||||
const once = (name) => {
|
const once = (name) => {
|
||||||
if (called.has(name)) throw new Error(`${name}() called twice`)
|
if (called.has(name)) throw new Error(`${name}() called twice`)
|
||||||
@@ -103,6 +111,9 @@ function fakeApi() {
|
|||||||
// module registering a provider collides with the first. A fake cannot see
|
// module registering a provider collides with the first. A fake cannot see
|
||||||
// the second module, and asserting the half it can see is still worth doing.
|
// the second module, and asserting the half it can see is still worth doing.
|
||||||
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
||||||
|
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||||
|
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||||
|
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,3 +130,163 @@ test('the manifest declares what the loader requires', () => {
|
|||||||
// serves, so an entry in the module root would publish the whole module.
|
// serves, so an entry in the module root would publish the whole module.
|
||||||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── The engagement seam ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core validates most of what is declared here at registration, and a module
|
||||||
|
// that gets it wrong does not load. These tests are mostly NOT that validator
|
||||||
|
// restated: they are the rules a module can satisfy at boot and still have got
|
||||||
|
// WRONG in a way whose only symptom is mail somebody received. Where one does
|
||||||
|
// overlap core — the namespacing and subjectKey assertions below — it is because
|
||||||
|
// `npm test` is a cheaper place to meet the failure than a first boot, and the
|
||||||
|
// message here names the field.
|
||||||
|
|
||||||
|
test('every declared trigger is namespaced, ceilinged, and carries examples', () => {
|
||||||
|
const { api } = register()
|
||||||
|
const triggers = api.record.triggers
|
||||||
|
assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared')
|
||||||
|
|
||||||
|
for (const t of triggers) {
|
||||||
|
// Trigger ids and notification-stream ids are ONE namespace, so an id must
|
||||||
|
// carry this module's own prefix or it is a claim on somebody else's.
|
||||||
|
assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`)
|
||||||
|
|
||||||
|
// `ceiling` is required and has no default: there is no safe value to guess.
|
||||||
|
assert.ok(t.ceiling, `${t.id} declares no ceiling`)
|
||||||
|
|
||||||
|
// Core refuses this one too; failing it here just costs less. What the rule
|
||||||
|
// protects is the cooldown key — "once per world", not "once per user" — and
|
||||||
|
// a subjectKey naming nothing would key every subject on `undefined`.
|
||||||
|
const names = t.variables.map((v) => v.name)
|
||||||
|
assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`)
|
||||||
|
|
||||||
|
for (const v of t.variables) {
|
||||||
|
// Not decoration: the example is what makes a template previewable and
|
||||||
|
// test-sendable without waiting for a real game event.
|
||||||
|
assert.ok('example' in v, `${t.id}.${v.name} has no example`)
|
||||||
|
// The type set is closed. A payload that needs a structure has outgrown
|
||||||
|
// interpolation, and a template cannot walk one.
|
||||||
|
assert.ok(
|
||||||
|
['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type),
|
||||||
|
`${t.id}.${v.name} has type "${v.type}"`,
|
||||||
|
)
|
||||||
|
// A url is site-relative, because it ends up in an href in a mail somebody
|
||||||
|
// opens days later.
|
||||||
|
if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an audience resolves to nobody rather than to everybody when it fails', async () => {
|
||||||
|
const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } })
|
||||||
|
const { api } = register(ctx)
|
||||||
|
const audience = api.record.audiences[0]
|
||||||
|
|
||||||
|
// The one behaviour worth a test of its own. Core treats a throw the same way,
|
||||||
|
// so this is not core's guard restated — it is the module choosing the same
|
||||||
|
// answer deliberately, and the reason is that the alternatives are both worse:
|
||||||
|
// "everyone" mails the wrong people and a stale answer mails yesterday's.
|
||||||
|
assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a seeded rule names only this module’s triggers, and its own or core’s templates', () => {
|
||||||
|
const { api } = register()
|
||||||
|
const seeds = api.record.engagementSeeds
|
||||||
|
const ownKeys = new Set(seeds.templates.map((t) => t.key))
|
||||||
|
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
||||||
|
|
||||||
|
for (const t of seeds.templates) {
|
||||||
|
// The key column is UNIQUE across the whole table, so an unprefixed
|
||||||
|
// `notify.event` from a module would collide with core's body and win.
|
||||||
|
assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`)
|
||||||
|
// `protected` means "the system breaks without this body" — true of a
|
||||||
|
// password reset and of nothing a module ships. Core refuses a module
|
||||||
|
// template that sets it, because it would take an operator's delete button
|
||||||
|
// away.
|
||||||
|
assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of seeds.ruleGroups) {
|
||||||
|
for (const rule of group.rules) {
|
||||||
|
assert.ok(
|
||||||
|
api.record.triggers.some((t) => t.id === rule.trigger_id),
|
||||||
|
`${rule.name} names a trigger this module does not declare`,
|
||||||
|
)
|
||||||
|
for (const key of Object.values(rule.template_keys)) {
|
||||||
|
assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`)
|
||||||
|
}
|
||||||
|
// `enabled` is not a parameter, and a value passed for it is ignored
|
||||||
|
// rather than refused. Passing one anyway states an intention the platform
|
||||||
|
// will not honour, so the honest thing is not to write it.
|
||||||
|
assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every seeded body is a shape the block registry will accept', () => {
|
||||||
|
const { api } = register()
|
||||||
|
|
||||||
|
// The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a
|
||||||
|
// non-empty array and stops. The BODY is validated by core's block registry,
|
||||||
|
// which runs in the template 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. Core is not here to ask, so assert the two rules that are easy to
|
||||||
|
// get wrong and impossible to notice.
|
||||||
|
const HEADING_LEVELS = ['h1', 'h2', 'h3']
|
||||||
|
|
||||||
|
for (const t of api.record.engagementSeeds.templates) {
|
||||||
|
const ids = new Set()
|
||||||
|
for (const block of t.blocks) {
|
||||||
|
// Every block carries its own id, unique within the body: it is how the
|
||||||
|
// editor addresses one block, and how `inapp.event`'s renderer maps blocks
|
||||||
|
// onto the inbox row's columns by role.
|
||||||
|
assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`)
|
||||||
|
assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`)
|
||||||
|
ids.add(block.id)
|
||||||
|
assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`)
|
||||||
|
|
||||||
|
// A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads
|
||||||
|
// perfectly and is refused, and it renders at the default size in any
|
||||||
|
// preview that skips validation — which is the whole trap.
|
||||||
|
if (block.type === 'email.heading') {
|
||||||
|
assert.ok(
|
||||||
|
HEADING_LEVELS.includes(block.props.level),
|
||||||
|
`${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the world event fires on the transition and not on the poll', async () => {
|
||||||
|
const boot = require('../boot')
|
||||||
|
|
||||||
|
// Online already, and reporting online again. The refresh writes, and nothing
|
||||||
|
// is announced: this runs every thirty seconds, and a rule on an event fired
|
||||||
|
// every thirty seconds mails somebody every thirty seconds. Core's cooldown
|
||||||
|
// would hold — but leaning on it means emitting "still up" and calling it news.
|
||||||
|
const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } })
|
||||||
|
require('../core')._reset()
|
||||||
|
require('../core').init(steady)
|
||||||
|
await boot.refresh()
|
||||||
|
assert.deepStrictEqual(steady.events.emit.calls, [])
|
||||||
|
|
||||||
|
// Offline before, online now. One emit, with the declared payload.
|
||||||
|
const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } })
|
||||||
|
require('../core')._reset()
|
||||||
|
require('../core').init(flipped)
|
||||||
|
await boot.refresh()
|
||||||
|
assert.strictEqual(flipped.events.emit.calls.length, 1)
|
||||||
|
const [triggerId, envelope] = flipped.events.emit.calls[0]
|
||||||
|
assert.strictEqual(triggerId, 'examplegame.world.status_changed')
|
||||||
|
assert.strictEqual(envelope.data.status, 'online')
|
||||||
|
|
||||||
|
// A fresh install, where there is no previous row at all. Not a change — and
|
||||||
|
// announcing it would tell everyone the world came online the first time an
|
||||||
|
// operator started the site.
|
||||||
|
const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })
|
||||||
|
require('../core')._reset()
|
||||||
|
require('../core').init(fresh)
|
||||||
|
await boot.refresh()
|
||||||
|
assert.deepStrictEqual(fresh.events.emit.calls, [])
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user