Compare commits

..

23 Commits

Author SHA1 Message Date
f7692abcd9 Merge pull request 'fix(template): EmptyState takes children, not message' (#13) from fix/template-empty-state into main
Reviewed-on: #13
2026-09-23 05:36:44 +00:00
43308c3f7f fix(template): EmptyState takes children, not message
All checks were successful
PR Checks / prose (pull_request) Successful in 10s
PR Checks / template (pull_request) Successful in 37s
Core's EmptyState renders its children and nothing else. The template's
clan list passed the sentence as message=, which React drops without a
word, so the panel rendered as an empty box - and module-rust, built
from this template, shipped six of those across four phases before a
browser walk noticed (docs modules/rust/PLAN.md §23.4).

Same class of bug as the PageHeader subtitle the Teams work found here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 00:32:52 -05:00
e852e5574d Merge pull request 'docs(book): the game host already has the files your site wants (chapter 3 §2b)' (#12) from docs/asset-bridge-p9 into main
Reviewed-on: #12
2026-09-14 22:25:37 +00:00
ad37cade6e docs(book): the game host already has the files your site wants (chapter 3 §2b)
All checks were successful
PR Checks / prose (pull_request) Successful in 16s
PR Checks / template (pull_request) Successful in 36s
The integration kit's share of the Asset Bridge, and the whole of it: one section
in the sidecar chapter, teaching the pattern rather than re-specifying anything.
`docs/link/v8.md` stays normative and is linked out to, as every chapter does.

The problem is general even though our instance of it is not. Most games keep
content on the host that a website wants to show -- sprites, icons, portraits,
localisation tables, map definitions -- and the tempting answer is to make it the
operator's problem: export it on a desktop with a third-party tool, upload the
result, repeat after every patch. It works once and rots immediately.

The four design notes are the ones that cost us real time to learn: content rides
request/reply and never events (a sidecar that persists and broadcasts every
event would write megabytes of sprite into its store and fan it out to every
client); serve one at a time and put "busy" in the protocol so a caller treats it
as flow control; two stages, so the common case -- a restart that changed nothing
-- costs one small round trip; and version your DERIVATION separately from the
protocol, because improving how you read a file changes your bytes while the
file's hash stays put.

Plus the operational note that surprises people: do not import on boot.

Based on `main` rather than `edge` deliberately -- the kit's chapter 5 and the
§2a it follows are on main only, so this section has nowhere to sit on edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:09:57 -05:00
7f746aee3d Merge pull request 'chore(ci): pin the kit to the Event System core, and go green (Phase 16b cutover, 5 of 6)' (#11) from chore/events-cutover-repin into main
Reviewed-on: #11
2026-09-10 01:23:59 +00:00
5dc14fa626 chore(ci): pin the kit to the Event System core, and go green (Phase 16b)
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 34s
`ci/core-ref.json` moves from 66bb3b9a (MODULE_API 1.9.0, the engagement
cutover) to 655fbf3f -- the commit 1.10.0 reached `main` on, website#199.

This closes a red `main` rather than only dating the book. Chapter 5 landed in
#10 declaring `coreApi ^1.10.0` while this file still named a 1.9.0 core, and
`checkCoreApi` asserts EQUALITY, so the repo has been red on that check since it
merged. That was deliberate and said so in the PR, but the red belongs to the
cutover window and not to the repo; this is the commit that was always going to
close it, and it could not be written until the events sha existed on `main`.
Same shape Teams phase 11 used.

A pin move is a RUN, not an edit -- the template job checks this number and
tests the template against fakes, and a fake accepts what core refuses. So the
template's real declarations went through core's real registries at this exact
ref: the budget, the option source, the lease and the event action were all
accepted, and `apply()` accepted the set. Nothing else here needed to move;
`template/module.json` has declared ^1.10.0 since #10.

Verified against a core at this ref:

  checkCoreApi         coreApi ^1.10.0 matches the pinned core's 1.10.0
  registry rig         all four declarations accepted, apply() accepted
  template server      82 pass, 0 fail          check:imports OK
  template client      20 pass, 0 fail          check:externals OK, build OK
  prose checks         checkLinks, checkRenameSites, checkChapterPaths all OK
  their own tests      10 pass, 11 pass

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 19:50:40 -05:00
a72b002f75 Merge pull request 'feat(kit): the event contract, taught and built (Phase 15)' (#10) from feature/events-p15-event-contract into main
Reviewed-on: #10
2026-09-08 23:27:03 +00:00
f89044b42e feat(kit): the event contract, taught and built (chapter 5)
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s
The fifth chapter, and the template code it teaches out of. Events is the first
thing in the book that goes the other way — chapters 1-4 move data out of the
game and onto a page; an event changes a live world on a schedule, unattended.

**Chapter 5** covers the four declarations (budgets, option sources, leases,
actions), leads with the lease because EVENTS.md §H is right that it is the
primitive that travels and the spawn is the special case, and gives one section
each to the four things that are invisible until an outage: the envelope's
failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring `cost`.

**Chapters 3 and 4 gain one section each** for the command plane, because
without them chapter 5 teaches a module to send an idempotency key to a sidecar
the book never told anyone to build a command path in. Both say at the top that
they are skippable until you want chapter 5.

**The template ships one of each declaration**, with `server/sidecarClient.js`
as the near end — a real timeout, a real key passthrough, a simulated transport
in one function marked for replacement. That file is named for the filename
`noGameConnection.test.js` already anticipated, so the test stays green now and
fires correctly the moment `deliver()` becomes a request.

Two things writing it found, both now in the chapter and beside the code:

  * **An idempotency key belongs on a command, never on a question.** The first
    draft keyed every call including the reads; an at-most-once store then
    answers every future read with the first one's reply, forever. The lease
    applied correctly and the module could no longer see it. Hence `ask` and
    `send` as two functions.

  * **A refusal's reason goes in `error`; core reads no other name.** The first
    draft used `detail`, on the strength of the one place EVENTS.md §H mentions
    it, and every refusal it produced was anonymous on the run console.

Proved by running the template's real declarations through core's real registry
at `edge` (all four accepted) and its real envelopes through the real
`events/dispatch.js` classifier.

**CI is RED on `checkCoreApi` and that is the mechanism working.** The template
now declares `coreApi: ^1.10.0` and `ci/core-ref.json` pins the engagement
cutover, where `main` is still 1.9.0. Equality is the check, a bump is meant to
turn this repo red until someone re-reads the chapters, and the pin move rides
in the events cutover (EVENTS_PLAN.md P16) as its own commit. Do not "fix" it.

Refs EVENTS_PLAN.md Phase 15, EVENTS.md §F, MODULE_API.md 1.10.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:10:18 -05:00
e9ca759227 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>
2026-09-01 18:03:52 +00:00
a8fa524263 feat(kit): the engagement contract, taught and built (cutover 5 of 7)
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 31s
The kit was pinned to website 963d734 -- MODULE_API 1.6.0, the Teams cutover --
and the platform is on 1.9.0. Three registrations and two calls arrived in
between, and a reader building against this book would have found no mention of
any of them: a module can now declare what its game can announce, and never who
is told.

Moving `ci/core-ref.json` is the mechanism for exactly this. The pin is now
66bb3b9a (website `main`, the engagement cutover) and `template/module.json`
declares `^1.9.0`.

What chapter 2 gained, under "Telling core something happened":

  * a TRIGGER is a payload contract, not a notification stream -- the two share
    one id namespace and are constantly confused;
  * `ceiling` is required, has no default, and is a CONTAINMENT tree rather than
    a size ladder (a `staff` ceiling does not permit `owner`);
  * an AUDIENCE resolver returns user ids and nothing else, resolves to NOBODY
    on failure, and takes CONSTANT params -- the constraint worth knowing before
    you design around it;
  * templates re-ensure per seedVersion, rule groups are offered ONCE per group
    key, so a rule appended to an existing group reaches fresh installs only;
  * `ctx.events.emit` binds the owner and is fire-and-forget; `ctx.inbox.push`
    is the direct write, for when there is nothing for an operator to decide.

The template builds all of it: one trigger, one audience over the clan roster it
already had, one seeded body and one seeded rule group, and an emitter in
`boot.js` that fires on the TRANSITION rather than on the poll. Seven new tests,
including the audience that resolves to nobody when its query throws.

Three claims were wrong and are corrected here rather than shipped:

  * core validates `subjectKey` against the declared variables and refuses the
    module; the draft taught a cooldown keyed on `undefined`, which the check
    exists to prevent and a reader will never see.
  * `emit` throws OUTSIDE production and only drops-and-logs inside it. Teaching
    the second half alone leaves a developer meeting a throw the book says
    cannot happen.
  * the seeded body itself was malformed -- heading `level: 2` where the block
    registry takes 'h2', and no block ids at all.

The third is the one worth keeping: `registerEngagementSeeds` checks that
`blocks` is a non-empty array and stops, so that body would have registered,
seeded, and failed the first time an operator opened it. Found by running the
template's `register()` through core's real registry at the pinned ref -- which
CI does not do, and cannot: the template job checks the version and runs the
template against fakes. A fake accepts what core refuses. The gap is now named
in the chapter, beside the code, and in the pin's own comment, and the rule that
bit has a test that fails on it.

Also: `checkLinks` skipped `.core/`. Bumping this pin means cloning core into
that directory first, and the walk then reported nine broken links in someone
else's README. CI never saw it -- the clone happens in the `template` job and
the check runs in `prose` -- so it was a failure only a person could meet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:57:55 -05:00
39736f8448 Merge pull request 'docs(book): teach the derived release version, and move the template onto it' (#8) from docs/release-cadence into main
Reviewed-on: #8
2026-08-19 18:06:38 +00:00
3979fa5abf docs(book): teach the derived release version, and move the template onto it
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 33s
The template shipped the declared-version release engine the reference module
has just abandoned: publish when a push to `main` leaves `module.json` at a
version with no release yet. Both flavours of the workflow move to the engine
`link`, `installer` and now Module-uo run - feat!/BREAKING CHANGE -> major,
feat -> minor, fix|perf -> patch - with `module.json` kept as a floor and a
`workflow_dispatch` backdoor for a manifest change with no releasable code
behind it. The tag is the number that ships, and the job writes it into the
`module.json` inside the bundle.

The chapter keeps the declared model in view rather than deleting it, because
the reason it was abandoned is the part a reader needs: its cost is paid on
every release, and the drift it prevents is something review catches anyway. A
week of merged work in the reference module produced no bundle at all.

Also carried over from the same pass: a tag pushed without a release behind it
is recovered instead of standing down forever, and the changelog moved into the
plan step (so assemble clears `$OUT`, not `dist/`).

Kept: the `# CHANGE THESE` banner, the exclusion list from the acceptance run's
F4, and the GitHub twin's `MODULE_SOURCE_HOSTS` note.

checkLinks, checkRenameSites and checkChapterPaths pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 13:01:51 -05:00
67aff992a4 Merge pull request 'docs(book): a mod is a plugin too, and RCON is not the Rust answer' (#7) from docs/no-rcon-example into main
Reviewed-on: #7
2026-08-19 11:15:28 +00:00
d497a3b09a docs(book): name Oxide, and the question "how many sidecars" answers
All checks were successful
PR Checks / prose (pull_request) Successful in 8s
PR Checks / template (pull_request) Successful in 28s
Follows the org lead's two corrections on docs#170. The Rust example is an OXIDE
plugin - naming the framework is the difference between a design a reader can
start from and one they have to go and choose for themselves - and the
architecture pairs one sidecar to one game server, on that server's own host.

Chapter 3 gains the general form of that second one, since it is the chapter
where a reader decides what to build: if your game runs as a fleet, "how many
sidecars" is answered by where the loopback boundary is, not by how many
processes you would rather run. Your module holding several clients is the
cheaper end of that trade, and core never learns there is more than one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:16:48 -05:00
744e5b7944 docs(book): a mod is a plugin too, and RCON is not the Rust answer
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in -28s
The kit taught, to an audience outside this org, that Rust needs "no game-side
plugin to write at all" because it ships RCON. That is overruled: the Rust dry
run reaches the game through a MOD - a plugin loaded by the server's own
framework, hooking events and dialling out - exactly as the ServUO overlay does
(docs#170).

Chapter 3's section kept its question and lost its example, which turned out to
improve it. The useful test is not "does my game expose a protocol" but "does it
DELIVER EVENTS": a remote-control channel is built for an operator typing
commands and tells you what you asked about, when you ask, and a website needs
what happened whether or not anyone was listening. A channel that answers
questions can only be polled, and polling turns "someone left the clan at 14:02"
into "the count was different at 14:03".

Rust now appears in that section as the counter-example rather than the example,
and carries the finding that is actually worth having: its server is a BINARY
where ServUO is source you compile, and the three-part shape survives that
unchanged. The plugin-dials-out arrangement is not a property of having source
access.

Chapter 4 said a game with a remote-control protocol may not need any of it, and
that its worked example is source you build. Both now say what is true - the
rules in that chapter are properties of being inside a game loop, and apply
identically to a mod in a closed server.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:11:03 -05:00
622abe9ed5 Merge pull request 'feat(kit): the two shapes Teams added, taught and built (Teams phase 11)' (#6) from feature/teams-phase11 into main
Reviewed-on: #6
2026-08-19 09:03:38 +00:00
1c7d6151c7 ci(core-ref): pin to the Teams cutover, where 1.6.0 reached main
All checks were successful
PR Checks / prose (pull_request) Successful in 11s
PR Checks / template (pull_request) Successful in 36s
The two mechanical lines the phase owed. template/module.json already declares
^1.6.0; this is the sha that makes checkCoreApi agree with it, which it could
not do until the cutover landed - the check is an equality against a core on
main, and 1.6.0 had only ever been on edge.

The `why` block records what the check bought this time rather than just what it
is for. Writing the chapters against 1.6.0 is what found that core's inverted
slot fills named three of module-uo's slots literally, so the direction worked
for that one module and silently did nothing for any other game. A book written
for an audience outside this org is exactly the instrument that finds that, and
it was fixed in core before this pin moved.

Also: the pin skipped the Teams `edge` entirely. The kit is written against what
shipped, never against what is in flight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:02:24 -05:00
4093293009 fix(kit): what installing the template into a real core showed
The template was built into a running core - MariaDB, the real loader, a browser
- and both halves of the walk passed: core reconciled two Teams out of the
provider on the first boot, /public/teams/<slug>/members came back with
projected:true, and the clan page rendered core's activity feed and forum in the
two slots this module declared. That last one is the whole point of the phase: a
module whose id is not "uo" now gets core's Team content, which is what
website#160 fixed. module-uo's own guild page was walked on the same core and is
unchanged.

Two things the walk found, both of the kind only a browser can:

PageHeader takes `lead`, not `subtitle`. The template has been passing subtitle
since it was written, and an unknown prop on a React component is silently
dropped - so every page built from this template rendered its heading with
nothing under it, on a site where every core page has a line there. Nothing warns
anywhere. Fixed on all three pages, and chapter 2 now says to check prop names
against 3.4 rather than guessing them, beside the paragraph about `shell` that
exists for exactly the same reason.

"1 members" on the clan list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:31:16 -05:00
7875848ee7 feat(kit): the two shapes Teams added, taught and built
MODULE_API 1.6.0 expands the contract this book teaches against, so the book
owes two shapes and one correction. Chapter 2 gains both and the template grows
a working version of each, because a reader following a snippet has no way to
find out whether it runs.

ONE SENTENCE WAS WRONG. Chapter 2 said, of extension slots, "Only core may
declare a slot; a module may only fill one". 1.6.0 inverted exactly that: a
module declares a place on its OWN page and core fills it. That is not a stale
detail - a new game's module cannot implement Teams at all without the inverted
direction, so it is the shape the reader needs and did not have.

THE TWO SHAPES

- The inverted slot. A new "Slots go the other way too" section: why the
  direction has to invert (core owns the Team, not the word for one), the
  namespace rule, one slot per PLACE, the optional { core } naming which of
  core's three contributions goes there, and why asking for one core does not
  offer throws when almost everything else in that registry fails open.

- registerTeamProvider, in "Becoming the source of Teams". The first
  registration where core calls YOU and waits, which is where every rule in it
  comes from: the envelope, the ten-second budget, refusing as a normal answer,
  and the one mistake worth naming - answering with an empty list because the
  game is unreachable, which core reads as authoritative and acts on.
  projectRoster gets its own treatment because it is the exception that fails
  CLOSED. pageUrlTemplate is a footnote beside it, as intended.

WHAT THE TEMPLATE GREW

model/clans/ - the provider over two tables, with the guards that matter: an
unreachable game refuses rather than reporting no clans, an empty roster is
refused unless the game says the clan is empty (which is why the schema keeps a
member count the rows cannot supply), and the audience rule lives in one file
that both projectRoster and the module's own page consult, because a second copy
drifts in the direction that publishes what core is withholding.

Its own /clans routes, deliberately not /teams - core mounts that itself, and
the loader would refuse the collision. A clan list page and a clan page that
declares three slots for core.

12 provider tests and three registration tests, 47 server and 20 client in
total. The purge test finally proves something: two of the three tables are now
a parent and its child.

WHAT IT DOES NOT DO. Enumerate the contract. The kit teaches one path end to end
and links out; it has never mentioned three pre-Teams registrations and that is
the design, not a gap.

FOUND WHILE WRITING IT: core filled three literal uo.guild.* slot names, so the
inverted direction reached exactly one module and every other game's page came
up empty with nothing logged. Fixed in website#160 / Module-uo#15 / docs#165
before this chapter could teach it - which is what this phase is for.

The ci/core-ref.json pin moves in a later commit on this branch: checkCoreApi is
an equality against a core on main, and 1.6.0 does not reach main until the
cutover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:21:55 -05:00
77418aaef5 Merge pull request 'chore(ci): the pinned core is on main now, not edge' (#5) from chore/core-pin-on-main into main
Reviewed-on: #5
2026-08-12 23:07:42 +00:00
8fa4210477 chore(ci): the pinned core is on main now, not edge
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 26s
The module system cut over on 2026-08-12 and website's `edge` branch was
deleted, so `ci/core-ref.json` named a branch that no longer exists.

The sha did not move. The pinned commit is an ancestor of `main`, the contract
is still MODULE_API_VERSION 1.5.0, and no chapter changed - this is a label
correction, not a re-pin, and deliberately not the moment d2 exists to create.

Nothing in CI reads the `branch` field: the workflow clones the repo and checks
out the sha, which is both why the cutover could not break the build and why a
wrong label here would have sat unnoticed indefinitely. The field is for the
person deciding whether a newer core is worth re-reading the book for, and a
branch that no longer exists tells them nothing. The `why` block now says which
half is load-bearing.

checkCoreApi.js's not-a-core error also asserted that core `main` "has none
until the cutover", which stopped being true at the same merge. A reader hitting
that message would have gone looking for a cutover that already happened - the
kit's own lesson from the CRLF defect, that a failure message naming a diagnosis
has to be right about it, applied to the kit's own scripts.

Verified both paths: the check still passes against a real core (^1.5.0 vs
1.5.0), and the rewritten message renders as intended. checkLinks,
checkRenameSites and checkChapterPaths all clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 18:05:44 -05:00
abf9344189 Merge pull request 'fix(kit): everything the acceptance run found — Phase 5 slice 3' (#4) from fix/acceptance-findings into main
Reviewed-on: #4
2026-08-12 22:42:49 +00:00
f8f7014d53 fix(kit): everything the acceptance run found — Phase 5 slice 3
All checks were successful
PR Checks / prose (pull_request) Successful in -38s
PR Checks / template (pull_request) Successful in 29s
A cold agent was given this repo and the documents it links to, and nothing
else — no core source, no module-uo — and asked to build a module for a second
game. It did, in one pass. The record is docs/modules/kit-acceptance.md; this is
the repair list, plus the two things it recommended that were not defects.

The one it could not find, because it had no core to render against: a module
page built exactly as this kit teaches renders OUTSIDE the site. PublicLayout is
the chrome, not the body. Core grew an opt-in `shell` prop for it
(MODULE_API_VERSION 1.5.0, website#148); the template passes shell="narrow" and
chapter 2 explains why you name a width and never a class.

Fixed:

- **F1, and the worst of them, because it lands in the first twenty minutes.**
  `npm run check:swagger` failed on a PRISTINE template on Windows: the check
  compared the committed fragment byte-for-byte and a default Windows clone is
  CRLF while the generator writes LF. The message blamed "the routes or their
  annotations". Now `template/.gitattributes` pins `eol=lf` and the comparison
  normalises line endings anyway — a check may only fail for the reason it names,
  and this one names a diagnosis.
- **F3** — `.gitea/workflows/release.yml` carries `gitea.example.com` and
  `your-org/your-module` under a literal `# CHANGE THESE`, was not in the rename
  checklist, and `checkRenameSites.js` could not match it, so CI was silent by
  construction. Row added, pattern widened. (The agent reported both workflow
  flavours; only the Gitea one is affected — GitHub supplies its own variables.
  Corrected in the record.) The near-miss is kept in the check's comments and its
  suite: the obvious widening is `example\.com`, which fires on a fixture URL in
  checkImports.test.js. Every alternative has to be a string that cannot occur by
  accident, which is the same rule that made the id `examplegame`.
- **F4** — the release bundle's include list was hardcoded, so adding
  `server/utils/` would have silently dropped it from every release while the
  bundle check stayed green. Inverted to an exclusion list, in both flavours, and
  run by hand because a release workflow never executes in CI.
- **F5** — the annotation-quoting warning was wrong in both directions, and the
  correction is measured rather than reasoned. A backtick is harmless (the
  template's own description has two spans and they survive). A `"` is not, and
  it does not throw: `'A "quoted" status'` is silently TRUNCATED to `A "` while
  swagger-autogen prints Success and the error capture sees nothing. The only
  signal is check:swagger blaming your routes.
- **F6** — `template/.gitignore`, so a copied template that is `git init`ed
  inherits ignore rules instead of nothing.
- **F7** — the UI kit is eight exports across five rows, not seven. The contract
  said seven and this kit had faithfully carried the miscount out of it.

Adopted, not defects:

- Chapter 1 now says to run every check on the untouched copy first. That is what
  found F1; without a baseline the first failure is ambiguous forever.
- The template ships the §2.7 self-check the agent wrote for itself. The rule has
  no CI in general — an outbound socket is not statically detectable — but a
  module can make a decidable claim about its own tree. Ported from its code with
  a header explaining how to NARROW it when a sidecar client arrives, since
  talking to your sidecar is the expected shape and is not what §2.7 forbids.

The pin moves to website edge 4ad8b2b, the 1.5.0 bump, and template/module.json
declares ^1.5.0 — so checkCoreApi's equality assertion still holds and the
template uses a member that exists only at that ref and later.

32 server + 18 client template tests, 21 kit-script tests, all four checks green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:40:02 -05:00
47 changed files with 5217 additions and 267 deletions

View File

@@ -43,9 +43,11 @@ in the contract ([`MODULE_API.md`][api] §2.7, `MODULE_API_VERSION` 1.4.0), not
style preference, and chapter 3 is mostly about why. The short version: the
website is the internet-facing process and your game is not; the sidecar persists
before it forwards, so a website that is down or mid-deploy loses nothing; and a
game must never block on a web request. A game that already exposes a
remote-control surface — Rust's RCON over WebSocket, say — needs a *thin* sidecar,
not none.
game must never block on a web request. A game that genuinely delivers events on a
surface of its own needs a *thin* sidecar, not none — but check that it delivers
events rather than answering questions, because a channel built for an operator
typing commands can only be polled, and polling turns "someone left at 14:02" into
"the count was different at 14:03".
## Start here
@@ -55,7 +57,10 @@ not none.
the contract cannot do yet.
2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it,
rename it, and you have a running module before you have read a chapter.
3. **The book** — [`book/`](book/), four chapters, in the order the work happens.
3. **The book** — [`book/`](book/), five chapters, in the order the work happens.
The first four are the job. The fifth is optional and comes after you have a
working module: what to declare if you want a scheduled event on the website to
be able to change your live world, and get it back afterwards.
## The one rule this kit follows
@@ -67,6 +72,7 @@ kit and one of them disagree, they win and the kit has a bug:
| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. |
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. |
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, what core owns, and every rule chapter 5 explains the reasoning behind. |
The kit *teaches*: the order to do things in, the reasoning, worked examples, and
the mistakes that cost this project time. Where it must show a member list it
@@ -102,6 +108,7 @@ same licence, and so does anything derived from it.
[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md

View File

@@ -47,6 +47,30 @@ cp -r template/ ~/my-module
cd ~/my-module
```
**Run every check on the untouched copy before you change a line.** Jump ahead to
*Build it* and run all of it — the tests, the build, the three guards — on the
template exactly as it arrived:
```bash
npm ci --prefix server && npm test --prefix server
npm run check:imports --prefix server
npm run check:swagger --prefix server
npm ci --prefix client && npm run build --prefix client
npm run check:externals --prefix client && npm test --prefix client
```
It takes two minutes and it buys you a **baseline**. Every one of those commands
is green on a pristine template, so from here on a red one is something you did —
and you will know which edit did it, because you were green a moment ago. Without
that, the first failure is ambiguous forever: is this my mistake, or was the
template already like this?
That is not a hypothetical. The kit's own acceptance run
([`kit-acceptance.md`][acceptance]) found `check:swagger` failing on an untouched
copy on Windows, with a message that blamed the reader's routes. It is fixed, and
the reason the run *found* it rather than being derailed by it is that it had a
baseline.
Your module id is the single most load-bearing string in it: it is the directory
core loads you from, the key in core's database, the URL segment every one of your
pages hangs under, and the prefix every one of your tables must carry. It must
@@ -125,9 +149,9 @@ Two more things that look like your module failing and are not:
Restart core and read the log. A module that loaded says so:
```
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world"}
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world"]}}
INFO [modules] schema ensured for module "examplegame" {"statements":2}
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world,/clans"}
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world","/clans"]}}
INFO [modules] schema ensured for module "examplegame" {"statements":4}
INFO [examplegame:boot] booted {"refreshMs":30000}
INFO [modules] module "examplegame" started
```
@@ -141,7 +165,13 @@ Then, in the browser:
- **`/examplegame/status`** renders your page, with a **World** row in the public
header pointing at it. That row is now an ordinary nav row: an operator can
reorder it, relabel it or hide it from the nav editor exactly as they can core's.
- **`/api/v1/public/world/status`** answers JSON.
- **`/examplegame/clans`** lists the two clans the template seeds at boot, and one
of them renders at `/examplegame/clans/clan-1` — the page that declares three
places for core to fill. On a core with Teams those hold the activity feed, the
forum and the notification control; on one without, they render nothing and the
page is exactly as complete. Both are correct outcomes and neither logs anything.
- **`/api/v1/public/world/status`** answers JSON, and so does
`/api/v1/public/clans`.
- **`/api/v1/public/modules`** lists you, with the `capabilities` array from your
`module.json`. This is how a client — core's SPA, the Android app, anything —
feature-detects you.
@@ -242,3 +272,4 @@ module must never do.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
[renamecheck]: ../scripts/checkRenameSites.js
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md

View File

@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
## What you register
Seven calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
is not their signatures but the model behind them.
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
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.**
The shape of a claim is checked at the call, so a malformed one throws with your
@@ -183,8 +184,8 @@ An operator looking at a user in the admin panel wants that user's characters
right there, not on a separate screen.
`api.registerExtension(slot, router)` mounts your routes under a core resource,
and its client twin renders your component inside a core page. **Only core may
declare a slot; a module may only fill one**, and one module per slot.
and its client twin renders your component inside a core page. Core declares the
slot, you fill it, and one module per slot.
The naming rule is worth internalising, because it is what keeps a game-agnostic
core game-agnostic: **a slot is named for a PLACE, never for a meaning.**
@@ -194,6 +195,63 @@ styling; the module owns the label, the target, the data, and whether it renders
anything at all. The moment core types a slot by its content, it has re-acquired
the semantics the module system exists to remove.
### Slots go the other way too
The direction above assumes core owns the page. Since `MODULE_API_VERSION` 1.6.0
there is the mirror of it, and **you will need it the moment your game has
anything like a guild**: a module declares a place on its own page and core fills
it.
```jsx
// client/src/entry.jsx — WHERE, in your words, and WHICH of core's contributions
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
// client/src/routes/public/Clan.jsx — from the UI kit
<Slot name="examplegame.clan.detail" externalId={externalId} moduleId="examplegame" />
```
**Why it has to invert.** A Team is a core entity — core owns the tables, the
membership sync, the access rules, the forum, the activity feed. What core does
not own is the *word*. A UO shard says guild, yours will say clan or company or
crew, and a core-rendered `/teams` page would publish a noun core invented, beside
your own page for the same thing. So the page is yours, and the parts core cannot
hand over are contributed into it. What core cannot hand over is the test for
whether something belongs in a slot: the activity feed's public/members split can
only be resolved by whatever owns membership, and that is core. You could render a
feed; you could not decide who sees which half of it.
Four rules, and the first two are the ones the shape depends on:
- **Your slot name is namespaced under your module id**, enforced rather than
conventional. It is what keeps two modules from claiming one name, and it makes
the owner readable where the slot is rendered.
- **Core names a CONTRIBUTION, never your slot.** `team.activity`, `team.forum`
and `team.notify` are core's three; the place they land in is yours to name and
yours to position. This is the half a second game depends on, and the first cut
of 1.6.0 had it the other way round — core filled three literal slot names
belonging to the first module, so everyone else's page came up empty with
nothing logged. This kit is what found that.
- **One slot per PLACE, not one per page.** A slot holds one component, so three
contributions want three declarations — and then you decide where each sits. The
template puts the notification control above its roster because muting is an
action *on* the page, and the feed and forum below it because they are content
*in* it. That decision is the reason to declare three.
- **Asking for a contribution core does not offer throws**, which is unusual here
— the client registry otherwise fails open. Core's catalogue is fixed at build
time and your `coreApi` range has already been checked, so an unknown one is
always a typo or a version skew, and the failure it would otherwise produce is a
page that renders empty forever.
`{ core }` is optional. A slot that asks for nothing stays empty, which is what
you want for a place you intend to fill yourself — and **first fill wins**, so a
module that fills its own declared slot keeps it and core's contribution is
skipped. The page is yours.
An empty slot renders nothing and is never an error: a core with no Teams, a
deployment with the forum switched off, a viewer with no membership. Design the
page to read correctly with every slot empty, because on some deployment it will
be.
### Notification streams, announce legs, post hooks
Three registries for three genuinely different things, and the distinctions are
@@ -222,6 +280,208 @@ 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
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
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
this one is not like the others.
**Every registration up to here hands core something to hold.** A router to
mount, a nav row to draw, a hook to call when a post is saved. This hands core
something it will *pick up and call*, from its own reconciler, and — for the
optional fourth method — on a request path with a visitor waiting. It is the
first place in this contract where **core calls you and waits**, and every rule
below falls out of that one fact.
The three required methods answer the three questions core has about the Teams
you are authoritative for: what Teams exist, who is in one, and which of those
lead. `template/server/model/clans/clanProvider.model.js` is a working one,
including the guard clauses; the shape is:
```js
getTeams() // () => { ok, complete?, teams: [{ externalId, name, abbr?, meta? }] }
getTeamMembers(externalId) // => { ok, complete?, members: [{ memberKey, displayName?, rankLabel?,
// leader?, online?, userId? }] }
getTeamLeaders(externalId) // => { ok, leaders: [memberKey] }
// the module knows it cannot answer — sidecar down, cache cold, boot unfinished
{ ok: false, reason: 'sidecar unreachable' }
```
**The envelope is the contract, and it is not decoration.** A rejected promise, a
synchronous throw, a timeout past core's ten-second budget, a non-object, a
missing `ok`, a malformed row — core reads every one of them as `{ ok: false }`.
There is no shape a failure can take that core reads as "zero Teams". That is the
whole argument for it: a bare array has exactly one such shape, `[]`, and it is
the one you return while your sidecar is still connecting.
**So refusing is normal.** `{ ok: false }` is an ordinary answer, not an error you
failed to handle. Core keeps the projection it has, records your reason and shows
it to an operator. A refusal costs staleness and nothing else.
**The mistake to not make** is answering `{ ok: true, teams: [] }` because your
game is unreachable. It reads as an authoritative "this deployment has no Teams",
and core acts on authoritative answers — it archives Teams that have stopped
existing and departs members who have left. A cold start would empty every roster
on the site, and your module would have done it by being helpful. The template's
provider therefore refuses whenever its data might be stale, *even though the rows
it holds are perfectly readable*: core cannot tell a snapshot five minutes old
from one five days old, and it makes destructive decisions from a complete answer.
Same reasoning one level down — an empty roster is refused unless the game says
the Team is empty, because the Team and its roster arrive on separate frames in
any real ingest and there is a window where you know one and not the other.
**`projectRoster(externalId, members, viewer)` is optional and fails CLOSED**, and
that asymmetry is the part worth carrying away. It answers *who may look at this
roster*, on the request path, because the audience model is yours — core does not
know what your rungs are called and cannot invent one. For the other three, an
unanswered call must change nothing. For this one, "keep what you have" means
serving the roster unprojected to whoever asked, which is a leak. So core
distinguishes two refusals and you get the right one for free:
- **no provider, or no `projectRoster`** — nothing is being withheld, so core
serves the roster whole at its own public shape. That is what makes the method
genuinely optional.
- **a `projectRoster` that refused, threw, timed out or answered malformed** — core
serves an empty roster and says so. You claimed an opinion and then did not give
it.
Two smaller things the template gets right and are easy to get wrong: it hands
back the member keys **core** supplied (core's rows, core's `member_key` spelling)
rather than its own, and it treats an anonymous viewer — core hands over `null` —
as an *answer* rather than as a lookup that failed. The second one refuses on
every anonymous visit, which on a public deployment is most of your traffic.
**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.
**`pageUrlTemplate` is data, not a method** — `'/examplegame/clans/{externalId}'`
— and it is the fifth member. Teams have no core page, so core cannot work out
where yours is, and a notification email about a forum reply that cannot link to
the thread is most of the way to useless. A relative path only; core substitutes
`{externalId}` and `{slug}` and does nothing else with it. Data rather than a
callback deliberately: a function here would put a module hook on the mail path,
one more thing that can hang, to produce a string that never varies.
**What core never gets is your tables.** It asks the questions; you own the
storage, the ingest and the game↔site account mapping (`userId` on a member is
resolved by you, because a core that resolved it would be core reading a module's
table by name). The traffic in the other direction is `ctx.teams.*`, and it is
narrow on purpose.
### The lifecycle hooks
`api.onBoot(fn)` runs after core's schema, after your schema fragment, and
@@ -388,16 +648,53 @@ every case than one that shows a link which then answers `403`.
Core publishes a small set of components and hooks on `window.__rg.ui`
([§3.4][api] is the list): the public layout, a page header, the loading, error
and empty states, the async hook every data page uses, and read-only access to the
session and site settings. Enough to build a page that looks like the site it is
installed in, and nothing else.
and empty states, the async hook every data page uses, read-only access to the
session and site settings, and `Slot`. Enough to build a page that looks like the
site it is installed in, and nothing else.
`Slot` is the odd one — not a widget but the thing that renders a place you
declared for core, from ["Slots go the other way too"](#slots-go-the-other-way-too)
above. It is in the kit rather than left to you for the reason the kit exists at
all: reimplementing it would mean a second error boundary with different
behaviour, and what this one contains is *core's* content failing inside *your*
page.
**`PublicLayout` needs a `shell`, and this is the one that will catch you.** The
layout is the *chrome* — header, footer, the flex column they sit in. The `shell`
prop is the *body*: the centred max-width column, the vertical padding, and the
element whose `flex: 1` is the only thing holding the footer at the bottom of the
viewport.
```jsx
<PublicLayout shell="narrow"> // 'narrow' · 'mid' · 'wide'
```
Omit it and your content starts hard against the left edge of the window with no
padding, and the footer climbs up underneath it. It reads as a stylesheet bug in
your module and it is not one — core's own pages write that wrapper by hand, and
before `MODULE_API_VERSION` 1.5.0 a module had no way to. **Name a width, never a
class:** the class names are core's stylesheet's and it is free to rename them,
which is exactly why they are not in the contract and this prop is.
That paragraph exists because the kit's acceptance run
([`kit-acceptance.md`][acceptance]) built a module by following this chapter to the
letter, and its page rendered outside the site. Everything else it wrote was right.
**And check a component's prop names against [§3.4][api] rather than guessing
them.** `PageHeader` takes `eyebrow`, `title`, `lead` and `center` — a page that
passes `subtitle` renders its heading and nothing under it, because an unknown
prop on a React component is silently dropped. Nothing warns, in the console or
anywhere else; the page simply looks emptier than every core page around it. This
template shipped exactly that mistake until a run of it against a real core was
looked at, which is the only way that class of thing is ever found.
**It is curated and closed, not a re-export of core's component library.** Adding
to it is a minor version bump; changing a kit component's props is a major one.
to it is a minor version bump, and so is adding an optional prop to a member;
changing an existing prop is a major one.
That is a real constraint on core, and it is the price of the boundary being worth
anything.
So: when you want an eighth thing, bundle it. Tables, chips, tabs, editors — those
So: when you want something it does not have, bundle it. Tables, chips, tabs, editors — those
are yours, and your chunk carries them. Reaching into core's tree for a component
is the one thing that is never available, and `template/server/scripts/checkImports.js`
exists to make sure a moment of weakness fails the build instead of shipping.
@@ -433,10 +730,26 @@ nothing at runtime will ever say so.
The trap: **swagger-autogen reports a broken annotation and then prints
`Success`.** It logs a syntax error, drops that annotation, and exits zero. The
template's generator captures those diagnostics and fails on them — keep that.
Two ways an annotation breaks are an object literal one brace short, and a `"` or
a backtick inside a single-quoted description. A third is only visible in a
rendered page: an escaped apostrophe (`\'`) survives literally into the output,
because the annotation is never evaluated as JavaScript. Use a typographic `’`.
The usual cause is an object literal one brace short.
**A quote character is worse, because it does not log anything.** These
annotations are evaluated as JavaScript literals, so a `'` or a `"` inside a
single-quoted description ends the string early — and for a `"` in the middle of
a sentence the result is not an error at all. The value is silently **truncated**
at that character:
```js
// #swagger.summary = 'A "quoted" world status'
// → "summary": "A \"" and swagger-autogen still prints Success
```
Nothing throws, so the generator's error capture has nothing to capture. The only
signal is `check:swagger` calling the fragment stale, with a message that blames
your routes. **If that check fires and your routes did not change, look for a
quote in an annotation first.** Backticks are safe — Markdown spans survive
verbatim. And an escaped apostrophe (`\'`) is a third case, visible only in a
rendered page: the annotation is never evaluated as JavaScript by the reader, so
Swagger UI shows the backslash. Use a typographic `’` throughout.
## Packaging and release
@@ -451,11 +764,30 @@ installed, the schema fragment, the OpenAPI fragment. Core downloads the tarball
verifies it against the `sha256` in the install manifest, and unpacks it. Nothing
runs `npm` on the way.
**The version is declared in `module.json`, not computed from commit subjects.**
You already have one authoritative version — it is what core records and what the
admin panel shows — and two sources for one number is how they drift. A release
happens when a push to `main` leaves a version that has no release yet, so
bumping is an ordinary reviewed change and publishing is the workflow's business.
**The version is computed from your commit subjects, and `module.json`'s is a
floor.** Every push to `main` carrying a `feat:`, `fix:` or `perf:` publishes a
bundle — `feat!:` and `BREAKING CHANGE` make it a major, `feat:` a minor, the
rest a patch — and a `main` that gained none of those cuts no release. The number
that ships is the **tag**, which the workflow writes into the `module.json` inside
the bundle.
The alternative is tempting and it is what this project's own reference module
did first: let `module.json`'s version decide, and release whenever a push leaves
it at a version with no release yet. You already have that number, it is what core
records and what the admin panel shows, and two sources for one number is how they
drift. It was abandoned on 2026-08-19 for a reason worth knowing before you copy
either shape — **its cost is paid on every release, and the drift it prevents is
something review catches anyway.** A week of merged work produced no bundle at
all, because none of it happened to touch that line, and shipping it meant first
merging a pull request whose entire content was a number.
So the declaration is kept, demoted to a floor: name a version in `module.json`
above the newest tag and *that* is what releases. It is still how you say "this
one is a minor" when a `coreApi` bump forces the question. And for a change with
nothing releasable behind it — a widened `coreApi`, a new mount, a new capability
— run the workflow by hand: leave `version` blank to bump the newest tag by
`bump`, or type an exact version.
The workflow tags and publishes and never writes to a branch, so a protected
`main` needs no exception.
@@ -476,6 +808,7 @@ where you publish.
| Do not read `process.env` for core configuration | Configuration with two sources and no panel. Your own config is a settings key or your own table. |
| No `process.exit`, no signal handlers, no listeners | A module taking the site down, or racing core's shutdown. |
| Write only inside your module root and the upload directory | A module that cannot be uninstalled cleanly. |
| Never read or write a core table — including the Team tables you populate | A module racing core's own reconciler for rows core owns. You answer questions about Teams; core stores them. |
| **Never open a connection to a game server from the website process** | The whole of [chapter 3](03-sidecar.md). |
That last one is newer than the others and is the reason this kit is three
@@ -485,3 +818,4 @@ it** — an outbound socket is not statically detectable the way an internal
the next chapter is for.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md

View File

@@ -89,6 +89,93 @@ A module cannot do any of this from inside the website process. There is nowhere
put what arrives while the website is not running, because the website not running
is exactly the case.
## 2a. The other direction, if you ever want events
Everything above is about data leaving the game. Skip this section until you want
[chapter 5](05-events.md) — but read it *before* you build the sidecar rather than
after, because retrofitting it is more work than allowing for it.
An event on the website is core telling your module *"do this to the world now"*,
and your module telling your sidecar, and your sidecar telling the game. That is a
**command** — a request with a reply, going the way nothing above goes. It needs
three things the read path does not.
**Request/reply correlation.** A command is not a broadcast: the caller waits for
an answer and has to know which answer is theirs. `uo-link` does this in
`sidecar/src/rpc.rs` — an id on the way out, a map of pending calls, the reply
matched back and the waiter woken. You need it for reads that ask the game a live
question too, so it is often already there; commands are what make it load-bearing.
**An idempotency key, executed at most once, stored where the game is.** Core hands
your module a key that is a function of the step's identity and never of the
attempt, so every retry carries the same one. The far end must execute a given key
once and answer a repeat with **the reply the first attempt produced** — not by
running the command again.
That store belongs as close to the game as the state it protects. A store in the
sidecar is right for a command whose effect is the sidecar's own; a command that
changes the *world* needs the store where the world is, because the case it exists
for is the game restarting mid-run. And a repeat arriving while the original is
still in flight is its own answer — "busy", transient by construction, because the
work is happening.
Without this, a command that arrived, ran, and whose acknowledgement was lost is
indistinguishable from one that never arrived. The only safe policy is then never
to retry, which means a game restarting mid-event writes the step off.
**A deadline the game enforces on its own.** A borrowed value — a doubled gather
rate, a raised spawn cap — carries an expiry down the wire, and the game side must
restore the baseline when it passes **without being asked again**. The website's
copy of that deadline is for the console. The game's copy is the fail-safe: if the
website is never heard from again, the value still comes back.
Two details that are easy to get wrong and expensive to change later. Send the
deadline as a **duration**, not an absolute time — two machines' clocks are two
clocks. And if the borrowed value lives in the game's own save file, the *hold*
must be persisted and the timer re-armed at load; a restart preserves the change
and destroys only the thing that would have undone it.
## 2b. The game host already has the files your site wants
There is a third kind of traffic, and it is worth knowing about before you decide
your sidecar only ever forwards live state. Most games keep **content on the host**
that a website wants to show: sprites, icons, portraits, localisation tables, map
or spawn definitions. It is static, it is large, and it changes only when an
operator patches the game.
The tempting answer is to make the operator's problem: export it on a desktop with
some third-party tool, upload the result, repeat after every patch. It works once
and rots immediately, because nothing reminds anyone to redo it.
The better answer costs less than it sounds like: **the game host already has those
files, and you already have a channel to the game host.** Route them over it.
Four design notes, all learned the expensive way in `uo-link`'s protocol 8 (the
"asset bridge", [`v8.md`][v8]):
- **This is request/reply, never events.** A sidecar that persists and broadcasts
every event would write megabytes of sprite into its own store and fan it out to
every connected client. Content must ride the same correlated round-trip a query
uses — see [chapter 5](05-events.md) for the shape.
- **Serve one at a time, and say so in the protocol.** Decoding assets costs the
game host real memory. One in-flight request with an explicit "busy" answer is
simpler and safer than a queue, and a caller that treats busy as flow control
rather than failure gets a working import out of it.
- **Two stages: what exists, then what changed.** A cheap call that returns a list
with a hash per item and no content, then a second that fetches only the hashes
that moved. The common case — a restart that changed nothing — must cost one
small round trip, not a re-download of everything.
- **Version your *derivation*, separately from the protocol.** If you improve how
you read a file, the bytes you produce change while the source file's hash does
not. `uo-link` carries an `EXTRACTOR_VERSION` for exactly that, and a consumer
treats a change in it like a changed hash.
And one operational note, because it is the part that surprises people: **do not
import on boot.** A patch is an event the operator knows about and your website does
not. Re-reading hundreds of megabytes on every restart to discover that nothing
changed pays for the rare case forever; a button an operator presses after they
patch costs nothing and is honest about who knows what.
## 3. The wire is a versioned contract, not a build dependency
Your sidecar and your module ship separately, on different schedules, to hosts you
@@ -132,28 +219,46 @@ forwarded, a lossy live feed, an authenticated read API with a version on it.
## "But my game already speaks a remote-control protocol"
Then your sidecar is **thin**, not absent.
Then your sidecar is **thin**, not absent — and be sure the surface you are
thinking of actually carries what your module needs, because that is where this
question usually goes wrong.
Rust — the survival game — is the worked example here, in
[`rust-dryrun.md`][dryrun]: a module designed on paper for a game chosen for how
little it shares with Ultima Online. Rust ships RCON over WebSocket, so a
`rust-link` has no protocol to invent and no game-side plugin to write at all. It
keeps:
A remote-control channel is built for an operator typing commands: it tells you
what you asked about, when you ask. What a website needs is what *happened* —
every kill, every join, every departure, delivered whether or not anyone was
listening at that moment. Those are different products, and a channel that
answers the first can only approximate the second by polling it, which turns
"someone left the clan at 14:02" into "the count was different at 14:03".
- the RCON connection, its credentials and its reconnect loop, **out of an Express
process** — where the failure mode is a wedged request handler;
- a store, so the site is not blank whenever the game is restarting, which for that
genre is a daily scheduled event;
- an HTTP + WS API with a version on it, so the module talks to one shape of thing
regardless of what the game speaks.
So the honest test is not *does my game expose a protocol* but **does it deliver
events**. If it does, your sidecar keeps that connection, its credentials and its
reconnect loop out of an Express process, keeps a store so the site is not blank
whenever the game restarts, and presents your module one versioned HTTP + WS
shape. That is what thin means: less code, the same architecture.
It drops the bespoke wire protocol and the plugin. That is what "thin" means: less
code, not a different architecture.
**Rust is the worked example, and it is not that case.** The dry run in
[`rust-dryrun.md`][dryrun] designs a module for it precisely because it shares so
little with Ultima Online — and its answer is an **Oxide plugin**: C# loaded by
the mod framework a modded Rust server already runs, hooking the game's events and
dialling out to a sidecar, exactly as the ServUO overlay does. The Rust server is a
*binary*, where ServUO is source a shard owner compiles, so the way in is a
published hook API rather than a file you edit. **The three-part shape survives
that unchanged**, which is the more useful finding: the plugin-dials-out
arrangement is not a property of having source access.
That document originally concluded the opposite — "no sidecar, the module dials
RCON directly" — and it carries a dated correction saying so, rather than having
been quietly rewritten. The value of a dry run is the record of what it found,
including where it was overruled.
It also pairs **one sidecar to one game server**, on that server's own host,
rather than one sidecar fronting a community's several — because those servers sit
on separate machines, and a shared sidecar would be reached across a network by
plugins that are supposed to talk to it over loopback. Worth knowing before you
design yours: if your game runs as a fleet, the question "how many sidecars" is
answered by where the loopback boundary is, not by how many processes you would
rather run. Your module holding several clients is the cheaper end of that trade,
and core never learns there is more than one.
That document reached the game over RCON until 2026-08-19, and before that
concluded there should be **no sidecar at all**. It carries both corrections,
dated, rather than having been quietly rewritten — the value of a dry run is the
record of what it found, including where it was overruled.
## Building yours
@@ -182,4 +287,5 @@ it wrong takes the game down rather than the website.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
[v8]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md

View File

@@ -4,10 +4,17 @@ The chapter with the least code and the highest stakes. Everything else in this
book fails by showing an operator a broken web page; this part fails by taking the
game down while people are playing it.
If your game already speaks a remote-control protocol, you may not need any of
this — see the end of [chapter 3](03-sidecar.md). If it does not, something has to
run inside the game and feed your sidecar, and the rules below are what keep that
something from being the reason the server froze.
If your game already **delivers events** on a surface of its own, you may not need
any of this — see the end of [chapter 3](03-sidecar.md), and read the test there
before deciding, because a channel that answers questions is not the same thing.
Otherwise something has to run inside the game and feed your sidecar, and the
rules below are what keep that something from being the reason the server froze.
**That something does not have to be source you compile.** The worked example
below is an overlay built into a server whose code you have; the dry run for Rust
is an **Oxide plugin**, C# loaded by a closed server's own mod framework and
hooking published events. Every rule in this chapter applies identically to both —
they are properties of being inside a game loop, not of how you got there.
The worked example is `servuo-plugins`, the Ultima Online shard plugin, whose link
layer is one file: `overlay/Scripts/Custom/Bridge/BridgeLink.cs`. It is C# against
@@ -96,6 +103,53 @@ escape into a game code path. `BridgeLink` wraps the inbound handler and logs
anything it throws, because the alternative is an exception unwinding somewhere in
the engine's main loop.
## A command that changes the world runs at most once
Skip this until you want [chapter 5](05-events.md). Everything above assumes an
inbound line either asks a question or is a one-off an operator typed. An **event**
is neither: it is unattended, it is retried, and what it does is permanent.
Three obligations, and they all live on this side of the wire because this is the
side that has the world.
**Keep a key store, and persist it.** Every command an event sends carries an
idempotency key — a function of the step's identity, never of the attempt, so a
retry carries the one the first attempt did. Before executing, look the key up:
- **not seen** — execute, then record the key *with the reply you are about to
send*;
- **seen and finished** — send that stored reply back, unchanged. Do not re-run;
- **seen and still running** — answer "busy". It is transient by construction, and
the caller will retry; running it concurrently with itself is the failure.
The stored reply matters as much as the guard. A repeat that re-ran and returned a
*new* serial would be two things in the world and one in the website's ledger,
which is the exact failure the key exists to prevent, arrived at by a longer route.
**Persist it in the world save, not in memory**, if what the command creates
survives a restart. The case this whole mechanism exists for is a game restarting
mid-event, and a key store that dies with the process is a store that is empty in
precisely that case.
**Own what an event made, and expire what it borrowed.**
An event-created thing has to be findable again later, because the website will ask
you to remove it after the event and may ask more than once. That means a registry
— a persisted map from the website's reference to the object — and it means
`remove` is idempotent: **removing something that is not there is a success.** The
website records a resource *before* it is confirmed, so it will ask you about
things that may never have existed, and neither end can tell the difference.
A borrowed value is the mirror. It arrives with a duration, and you arm a timer that
puts the baseline back when it expires. If the borrowed value lives in the save
file, persist the hold and **re-arm the timer at load** — a restart preserves the
change and destroys only the thing that would have undone it. Restore with a
compare-and-set against what you applied: if a staff member has moved it by hand
since, report that rather than overwriting them.
The through-line: **the game enforces the expiry, not the website.** If the website
is never heard from again, every borrowed value still comes back on its own.
## Reconnect, and what to send on connect
Your sidecar restarts independently of your game. It comes back with an empty
@@ -166,12 +220,25 @@ Two practical notes from that file, both general:
If all eight hold, the worst a broken sidecar can do to your game is nothing at
all — which is the entire point of the arrangement.
Three more, and only if you took commands (chapter 5):
9. A command's idempotency key is looked up before it is executed, and a repeat is
answered with the stored reply rather than re-run.
10. What an event made is in a persisted registry, and removing something absent is
a success.
11. A borrowed value's expiry is armed by this side, re-armed at load, and restored
with a compare-and-set.
---
That is the book. The three parts are a module core loads, a sidecar that owns the
game connection and the durable copy of what it said, and a plugin that feeds the
sidecar without ever waiting on it.
[Chapter 5](05-events.md) is the optional fifth part: what to declare if you want
the website to be able to change your world on a schedule, and the four mistakes
that make that unsafe.
If you got this far and built something, the places you got stuck are the most
valuable thing this repo can receive — [tell us][issues], and please say where you
left the kit and what you did next.

364
book/05-events.md Normal file
View File

@@ -0,0 +1,364 @@
# 5. Making your module event-capable
Chapters 1 to 4 got a game onto the platform: a module that reads, a sidecar that
stores, a plugin that tells it what happened. Everything in them moves one way —
out of the game and onto a page.
This chapter is about the other direction. The event system is core's engine for
**scheduled, bounded, audited changes to a live game world**: an operator writes an
event on the website — a phase that announces, a phase that spawns something, a
phase that waits for a condition, a phase that cleans up — publishes it, schedules
it, and it runs unattended at two in the morning. Your module is what lets any of
that touch your game.
It is also the first thing in this book that can do damage. A page that renders
wrong is embarrassing. An action that half-ran and was recorded as done is a
change to a live world with nothing coming back for it.
Nothing here is normative. [`EVENTS.md`][events] is the design of record and
[`MODULE_API.md`][api] is the contract; where this chapter and either of those
disagree, they are right and this chapter has a bug. What is here is the ordering,
the reasoning, and the four mistakes that are invisible until an outage.
---
## Everything in this chapter is optional
Stated first because it changes how you should read the rest.
A deployment with **no module at all** still has a working event engine. Core owns
verbs of its own — announce something, wait, cue a human to do the in-game part,
publish results — and an event composed only of those runs on bare core with zero
modules installed. That is not a degraded mode; it is a real product, and for many
games it is the whole of what you want.
So each of the four declarations below *adds* something an author can reach for.
Registering none of them costs your deployment a capability, never a boot — the
same posture as a module with no `onBoot`, which still reaches `started`.
Which means you can stop reading at any section boundary and ship what you have.
## The four declarations
```js
api.registerEventBudgets([...]) // dimensions core can COUNT and BOUND
api.registerEventOptionSources([...]) // what a dropdown on the form is FILLED from
api.registerEventLeases([...]) // values a run may BORROW, with a deadline
api.registerEventActions([...]) // verbs a run may PERFORM
```
Four separate id spaces, each namespaced under your module id. `examplegame.beacons`
as a budget and `examplegame.beacon.light` as an action are not a collision, and
reading them as one would forbid the most natural set of names you will ever
write. An action names a VERB, a budget a RESOURCE, a lease a VALUE, an option
source a CATALOG.
All four are in the template at
[`template/server/config/eventActions.js`](../template/server/config/eventActions.js),
one of each, with the four traps marked where they bite. Read that file beside
this chapter.
## Build the lease first
If you have time for one thing, build a lease, not an action. This is the kit
disagreeing with the obvious priority on purpose.
The obvious thing to build is spawning: an event that puts creatures at a landmark
is what a game event *looks* like. But spawning is a shape one genre happens to
have, and it is the harder half — something now exists that did not, and your
module owes core a way to take it away again on every terminal path, including
the ones where nobody is watching.
A lease is the other shape: **a value that already existed, changed for a while,
and put back.** "Double the gather rate for the weekend." "Turn the night length
down until Sunday." "Raise this spawner's population for the invasion." That is
the canonical community event in most games, and it is cheaper to make safe,
because the value you are replacing already exists and reading it first gives you
your baseline for nothing.
**The verb is core's, not yours.** You declare what can be held and how long; an
author puts `core.lease` in a step naming your lease, a value and a number of
minutes, and core reads the baseline, reserves the target, applies the value with
a deadline, and restores it at teardown through your own `restore()`. A lease verb
of your own would be that duration bound and that "two events cannot hold one
target" check re-implemented once per module — advisory everywhere, and wrong in
the first one that forgot it.
```js
api.registerEventLeases([{
id: 'examplegame.rate.gather',
label: 'Gather rate',
type: 'float', min: 0.5, max: 5,
maxDurationMs: 48 * 60 * 60 * 1000,
async read() { /* the live baseline */ },
async apply(value, until) { /* hold it, and send `until` down the wire */ },
async restore(baseline, { expected }) { /* put it back, or report drift */ },
async inForce() { /* optional — a FOURTH question, see below */ },
}])
```
Three things about that shape are worth more than their size.
**`until` goes down the wire and the far end honours it without being asked
again.** Core's copy of the deadline is for the console; the game's copy is the
fail-safe. A module that passes `until` and then relies on core coming back to
restore has built a lease that outlives an outage — which is the one thing a lease
exists to prevent. If the website is never heard from again, the value must still
come back.
**`restore()` reports drift rather than overwriting it.** `expected` is what core
believes is applied. If the live value differs, somebody moved it by hand during
your event, and answering `{ ok: true, drifted: true, value }` lands the row as
`drifted` with the current value beside it. Silently restoring over a human's edit
is the bug this exists to prevent.
**`inForce()` is a fourth question, not a fourth spelling of `read()`.** It asks
*"does the game side still have any record of this hold?"*, and none of the other
three answers it. A value that DIFFERS from what the run applied is drift, which
`restore()` reports; a reconcile that inferred absence from a changed value would
take the row out and tell an operator the lease vanished rather than that somebody
moved it. Optional — and `{ ok: true, held: false }` is the only thing that takes
a lease's ledger row out. A throw, a refusal, or no `inForce()` at all leaves the
row alone.
**Only advertise a lease you have verified takes effect.** A value your game reads
once at start-up and caches will apply cleanly, read back cleanly, and do nothing
at all. Core cannot catch that and neither can review — it is a capability that
lies. Apply it, observe it in the running game, restore it. Per key, as a test.
The UO module surveyed 156 config reads in its game and found roughly eight that
were live; the rest were cached at boot and would all have lied.
## Actions, and what "owning" something means
An action is a verb an author puts in a step. What it makes, the run OWNS until
teardown.
```js
api.registerEventActions([{
id: 'examplegame.beacon.light',
label: 'Light beacons',
risk: 'change', // notify | inspect | change | irreversible
reversible: 'ledger', // none | self | ledger | override
version: 1,
budgetMs: 15000,
cost: (p) => ({ 'examplegame.beacons': p.count }),
params: [ /* every one carries an `example` */ ],
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {},
async revert({ runId, resources, idempotencyKey }) {}, // required iff 'ledger'
async reconcile({ runId, resources }) {}, // optional
}])
```
**`reversible: 'ledger'` is a promise.** It says core may record what you made and
come back later to have it undone, and it makes `revert` required. Core's cleanup
is **derived, not authored**: there is no `on_teardown` field and no cleanup phase
in a spec, because an operator cannot be relied on to write the undo and an
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over
the ledger and it runs on every terminal path — completion, cancellation and abort
alike. Your only job is to answer `revert` correctly, however many times you are
asked.
**`verify: true` must change nothing and must answer honestly.** It is the dry
run, and it rides the same dispatcher a real run uses — because a dry run down a
second code path is a dry run of the second path. Validate everything you can
reach without writing, then stop. Answering `{ ok: true }` unconditionally makes
the dry run worthless in the one situation it exists for.
**`example` is required on every param, optional ones included.** It is the
authoring form's placeholder. It is one word at declaration time and it is
unreconstructable afterwards by anybody who did not write the action.
**A `source` on a param makes it a dropdown**, filled by an option source you (or
another module) registered. A source that refuses degrades its field to free text
with a warning and never blocks the form — so resolve from live data and return
`[]` on failure, rather than defending with a hardcoded list that will be wrong.
---
# The four things that are invisible until an outage
Everything above is ordinary. These four are the ones that look like they are
working, in every test you write and every demo you give, right up until the day
something is down.
## 1. The failure default is a retry, and `budgetMs` is what makes the other half reachable
**No shape a failure can take reads as success.** A rejected promise, a throw, a
budget timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`.
`retry` is opted OUT of: a module that means "this will never work" must say
`retry: false`.
That direction is deliberate, and it is `registerTeamProvider`'s default
*inverted*. A Team provider that refuses leaves core showing what it had, because
staleness is cheap. An action that half-ran and was recorded as done is a world
change nothing will ever come back for.
Now the part that is easy to miss. Core's dispatcher enforces `budgetMs`, and when
the budget expires it classifies the failure as **retry, unconditionally, without
asking you** — it cannot ask, your action is still awaiting a socket.
**So if your transport's timeout is longer than `budgetMs`, your own `retry: false`
is unreachable code.** Core's default `budgetMs` is 10 seconds. If your sidecar
client waits 12, core's deadline fires first on every slow game and the step is
retried no matter what your envelope says. The first module this project shipped
had exactly that pairing, and its one deliberately un-retryable verb was retried
anyway for a whole phase.
The rule generalises past that one pairing: **an action is the near end of a call
with a far end, and the near end has to outlive it.** Derive one constant from the
other rather than typing both, and assert the inequality in a test — the template
does both, because a number typed twice drifts the first time somebody tunes the
client and does not think to look at the other file.
**The reason a refusal gives goes in `error`.** Core reads exactly `ok`, `retry`
and `error` off a failure envelope; a message under any other name is dropped in
silence and the operator sees `"<action id> refused"`. Writing this chapter's
template is how that was found — its first draft used `detail`, and every refusal
it produced was anonymous.
## 2. Pass the idempotency key through, and put it on a command rather than a question
Core hands `perform()` an `idempotencyKey` derived from the step's identity — never
from the attempt number — so **every retry carries the same one**. The far end,
which is the only end that can tell a retry from a repeat, executes a key at most
once and answers a repeat with the ORIGINAL reply rather than running it again.
Pass it through unchanged. A module that invents its own key here, or drops it,
has an action that cannot be retried safely, and the cost of that is not a failed
step: it is a second set of everything on a socket hiccup. It looks correct in
every test you will write, because in every test the first attempt succeeds.
It is also what a lost acknowledgement is recovered from. Without a key, a command
that arrived, ran, and whose reply was lost is indistinguishable from one that
never arrived — so the only safe policy is never to retry, and a game restarting
mid-run writes the step off. With one, the retry collects the answer the first
attempt never delivered.
**And it belongs on a command, never on a question.** This is the correction
writing the template produced, and it is quiet and total: an at-most-once store
answers a key it has already seen with the first reply, forever. So a *read* that
carries a key returns the first read's value on every subsequent call — the lease
applied correctly, the game changed correctly, and the module could no longer see
any of it. `read()` reported the pre-run baseline and `inForce()` said nothing was
held. The template splits its client into `ask()` and `send()` for exactly this
reason.
The rule for which commands need a key is narrower than "all of them", too. A key
is for a write whose repetition would be a second EFFECT — creating, granting,
announcing. A write that SETS a value to X is idempotent by its own nature: doing
it twice is doing it once, and a key would only pin its reply.
Build the store on the **far end**, and persist it. A store in your module answers
nothing, because the case that matters is the one where the command arrived and
ran. See [chapter 4](04-game-plugin.md) for the game-side half.
## 3. Core records a resource BEFORE it is confirmed
This is one line in [`EVENTS.md`][events] §D and it decides the whole shape of your
`revert`.
Core writes a placeholder into its ledger, keyed by the step's idempotency key,
**before** dispatching — so a dispatch whose answer never came back is still
something cleanup can act on. Your `resources` are the refs core did not know until
the answer arrived, filled in afterwards.
Two consequences, and both are about what `revert` must tolerate:
**Reverting something that does not exist is a SUCCESS.** Cleanup will ask you
about rows for things that may never have existed. You must never have to tell
"I removed it" from "it was not there" — and you could not, because your game
cannot either. Answer `{ ok: true }`. This is also what a game with a monthly wipe
needs, where every ledgered resource is invalidated at once and "gone, and that is
fine" is the only useful answer.
**You will be called with NO resources and only a key.** That is the lost-answer
case stated exactly: core knows a dispatch went out under this key and never
learned what it made. A module that can undo by key answers honestly. One that
cannot answers `{ ok: false }`, and the row stays visible to an operator — which is
the correct outcome, not a silent one. Answering `{ ok: true }` to a question you
cannot answer is how something burns in a live world forever with core's ledger
reporting it cleaned up.
`revert` must also be idempotent, because core may ask more than once.
**`reconcile` is optional where `revert` is required, and the asymmetry is the
design.** A module that cannot say what the game still has is not broken — core
keeps believing its own ledger, which is the behaviour before any of this existed.
One that created something and cannot undo it has made a promise core has no way
to keep.
And when you do answer: **anything that is not an explicit
`{ ok: true, inForce: [...] }` leaves the ledger alone.** "I do not know" is never
read as "it is gone". A resource you report missing becomes `orphaned` rather than
`reverted`, because nobody asked for it to go.
**You say WHEN to reconcile, because core cannot.** Core has no concept of the game
being up — it sees `{ ok: false, retry: true }` and cannot tell a wedged sidecar
from a game that rebooted and lost everything an event made. So it asks once, at
its own boot, and otherwise waits to be told. `ctx.events.reconcile()` is being
told, and the thing that triggers it is your own watch on a boot id changing — which
is also how you tell a game restart from a sidecar reconnect. They are not the
same event; the second loses nothing.
## 4. Under-declaring `cost` turns every cap into a lie
`cost(params)` says what one invocation consumes. An operator sets caps per
dimension, and core refuses a step that would exceed one.
**Core prices `cost` before dispatch and never reconciles it against the resources
that come back.** It cannot — it does not know what a beacon is. So an action that
returns `{ 'examplegame.beacons': 1 }` while lighting twelve turns an operator's cap
of 30 into a cap of 360, the meter on the run console agrees with the lie, and
nothing anywhere goes red. The first symptom is a world with an order of magnitude
more in it than anyone authorised.
Count what you will actually make, from the params you were given, every time. **If
you cannot know until the answer comes back, declare the maximum**: a spend that is
too high refuses an event that would have fit, which an author can see and argue
with; one that is too low cannot be seen at all.
Two smaller rules ride with it:
- **You cannot spend a dimension no module declared.** A `cost()` naming an
unregistered one is refused at save, at the dry run and at dispatch, with its own
refusal code — because the fix is a module's declaration and not a deployment's
cap.
- **Declaring a dimension is not the same as bounding it.** A declared dimension
with no operator cap is counted and unbounded, which is useful on its own: the
run console then shows an author what their event actually spent.
---
## What core owns that you might think is yours
Four things a second module's author reaches for and should not.
| You might build | Core already owns it | Because |
| --- | --- | --- |
| A `myGame.lease` verb | `core.lease` | the duration bound and the two-events-one-target check belong in one place, or they are advisory everywhere |
| A cleanup phase, or `on_teardown` | the ledger sweep | an aborted run never reaches the phase somebody wrote the undo in |
| Deciding who is told about your event | rules and audiences | you declare what CAN happen; core decides who is told ([chapter 2](02-website-module.md)) |
| A second write path for participants | the `participants` envelope member | a second door into a run core is mid-tick on is a second thing that can race the step claim |
## What to build, in order
1. **Nothing.** Confirm an event composed of core's own verbs runs on your
deployment. If it does, the engine is working and everything below is additive.
2. **One budget dimension**, declared and uncapped. Costs nothing and makes the
next step legible.
3. **One lease**, verified live — apply, observe in the running game, restore.
This is the primitive that travels, and for many games it is the whole feature.
4. **One option source**, so the authoring form stops asking operators to type
identifiers from memory.
5. **One action that ledgers**, with `revert` and the four traps above. This is
where the work is, and where the damage is.
6. **`reconcile`**, and the boot-id watch that calls `ctx.events.reconcile()`.
Last, because it is the only one whose absence is merely a lower standard
rather than a broken promise.
Then read your own `revert` again, and ask what it answers when the game is down.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md

View File

@@ -1,6 +1,7 @@
# The book
Four chapters, in the order the work happens.
Five chapters, in the order the work happens. The first four are the job; the
fifth is optional and comes after you have one.
Read [the dry run][dryrun] before any of them — a complete module designed on
paper for a second game, and the shortest honest picture of the whole job.
@@ -11,12 +12,20 @@ paper for a second game, and the shortest honest picture of the whole job.
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
| 5 | [Making your module event-capable](05-events.md) | Optional, and the first thing here that can do damage: letting a scheduled event on the website change your live world, and get it back. |
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
purpose: those repositories move for their own reasons and a line number in a book
is wrong the moment they do.
Chapters 1, 2 and 5 quote `template/`, which CI builds against a pinned core, so
their code is a tree that is proved rather than prose that looks like one. Chapters
3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
line, on purpose: those repositories move for their own reasons and a line number in
a book is wrong the moment they do.
**Chapter 5 is the one you can stop before.** Chapters 1 to 4 get a game onto the
platform and everything in them moves one way — out of the game and onto a page.
Chapter 5 is the other direction, and a deployment that never reads it still has a
working event engine over core's own verbs. Chapters 3 and 4 each carry one section
that only matters if you are going there (§2a and *"A command that changes the
world runs at most once"*); both say so at the top.
## What is normative, and what is here
@@ -28,12 +37,14 @@ document is right and the chapter has a bug — [say so][issues]:
| [`MODULE_API.md`][api] | Everything a module may do. |
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, and what core owns. |
The chapters teach: the order to do things in, the reasoning, and the mistakes that
cost this project time.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md

View File

@@ -1,22 +1,60 @@
{
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"branch": "edge",
"ref": "1b692bf624404f9e4f924c231acfbfb7e9d0861a",
"branch": "main",
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
"why": [
"The core this kit is written against, pinned to a commit rather than a branch.",
"This one is the MODULE_API_VERSION 1.4.0 bump, which is the version",
"template/module.json declares - slice 0 pinned its parent, before 1.4.0",
"existed, and the check below could not have passed against it.",
"This one is the EVENT SYSTEM cutover, the commit MODULE_API_VERSION 1.10.0",
"reached `main` on (website#199), and 1.10.0 is what template/module.json",
"declares. It moved here from 66bb3b9a (1.9.0, the engagement cutover) because",
"the event contract expanded the book by a whole chapter: a module now declares",
"what its game can DO on request -- event actions, budget dimensions, leases and",
"option sources -- where every earlier chapter taught only a read path and a",
"thing to announce.",
"",
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
"version template/module.json declares still equals this core's",
"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.",
"",
"The branch is `edge`, not `main`, and that is not a mistake: the module system",
"has not cut over yet and core's `main` has no server/src/modules/ at all",
"(MODULE_SYSTEM.md decision 11). This pin is one of the things that cutover has",
"to revisit.",
"This pin move is a REPAIR as well as a date. Chapter 5 landed (#10) declaring",
"^1.10.0 while this file still named a 1.9.0 core, so `main` has been red on",
"checkCoreApi since it merged -- deliberately, and stated in that PR, but the",
"red belongs to the window and not to the repo. This is the commit that was",
"always going to close it, and it could not be written until the events sha",
"existed on `main`. Same shape Teams phase 11 used.",
"",
"The mechanism earned its keep again here, and twice. Writing chapter 5 against",
"the event contract found that an idempotency key on a QUESTION makes every",
"later read permanently stale -- an at-most-once store answers a repeated key",
"with the ORIGINAL reply, so the template's second read of a value returned the",
"first read's answer for ever, and the module could not see a change it had just",
"made. It also found that a refusal's reason goes in `error`: core's classifier",
"reads no other name, so a refusal reported under `detail` reached an author as",
"a bare \"refused\". Neither was found by writing prose. Both were found by",
"running the template's real declarations through core's real registry and its",
"real envelopes through core's real dispatcher.",
"",
"That 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. It was run at THIS ref: core's real registries accepted",
"the template's budget, option source, lease and event action, and apply()",
"accepted the set.",
"",
"The branch said `edge` until 2026-08-12, when the module system cut over and",
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
"`edge` of their own and this pin skipped both; the Event System cut a third,",
"and this pin skipped that too until it reached `main`. The kit is written",
"against what shipped, never against what is in flight. Nothing in CI reads the",
"branch field - it clones the repo and checks out the sha - which is why a wrong",
"label here would sit unnoticed. It is for the person deciding whether a newer",
"core is worth re-reading the book for.",
"",
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
"sha, reviewable in a diff."

View File

@@ -43,8 +43,9 @@ const versionFile = path.resolve(corePath, 'server/src/modules/version.js')
if (!fs.existsSync(versionFile)) {
console.error(`checkCoreApi: ${versionFile} does not exist.`)
console.error(' Either --core does not point at a website checkout, or the pin in')
console.error(' ci/core-ref.json names a ref with no module system in it (core `main`')
console.error(' has none until the cutover — see that file).')
console.error(' ci/core-ref.json names a core from before the module system existed')
console.error(' (it reached `main` at the 2026-08-12 cutover, so any ref older than')
console.error(' that on `main` has no server/src/modules/ at all).')
process.exit(1)
}

View File

@@ -24,8 +24,14 @@ const { stripFences } = require('./lib/markdown')
const ROOT = path.resolve(__dirname, '..')
const QUIET = process.argv.includes('--quiet')
// Directories that hold no prose we own.
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
// Directories that hold no prose we own. `.core` and `core` are core's own
// 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. */
function markdownFiles(dir = ROOT, out = []) {

View File

@@ -34,9 +34,23 @@ const TEMPLATE = path.join(ROOT, 'template')
const CHECKLIST = path.join(TEMPLATE, 'README.md')
// Anything a rename has to touch: the id (`examplegame`), the display name
// ("Example Game"), and the placeholder world ("Example World"). One pattern
// rather than three, because they are one decision.
const PLACEHOLDER = /example[ -]?(game|world)/i
// ("Example Game"), the placeholder world ("Example World"), and the two
// publishing placeholders in the Gitea release workflow (`gitea.example.com`,
// `your-org/your-module`). One pattern rather than four, because they are one
// decision — everything a reader must change before this template is theirs.
//
// **Every alternative has to be a string that cannot occur by accident**, which
// is the same rule that made the id `examplegame` rather than `example` (see the
// header). The publishing pair was added after the acceptance run found the
// release workflow carrying `# CHANGE THESE` placeholders that the checklist did
// not list and this pattern could not see — CI silent by construction
// (docs/modules/kit-acceptance.md, F3).
//
// The near-miss is worth keeping: the obvious widening is `example\.com`, and it
// is WRONG. `server/test/checkImports.test.js` uses `https://example.com/x` as a
// fixture — a URL in a string, testing that a URL in a string is not an import —
// and it is not a rename site. The host is matched in full instead.
const PLACEHOLDER = /example[ -]?(game|world)|gitea\.example\.com|your-(org|module)/i
// Directories with nothing of ours in them. `dist` and `node_modules` are build
// output — a chunk full of the placeholder is not a rename site, it is the

View File

@@ -61,6 +61,10 @@ test('the placeholder pattern matches every form a rename touches', () => {
"worldName: 'Example World'",
'examplegame_world_status',
'example-game',
// The publishing pair, added after the acceptance run found the release
// workflow unlisted and unmatchable (kit-acceptance.md F3).
' GITEA_HOST: gitea.example.com',
' REPO: your-org/your-module',
]) {
assert.ok(PLACEHOLDER.test(text), `should match: ${text}`)
}
@@ -75,6 +79,14 @@ test('the placeholder pattern does not fire on ordinary prose', () => {
'an example of what to catch',
'exampleValue',
'the game world',
// A real span from server/test/checkImports.test.js, and the reason the
// publishing host is matched in full rather than as `example.com`: it is a
// fixture URL inside a string, in a test about URLs inside strings, and it is
// not a rename site. The obvious widening would have failed the build on it.
"const url = 'https://example.com/x'",
// Prose about the reader's own module, which is not the hyphenated token.
'copy your module directory onto the volume',
'your org will need a release token',
]) {
assert.ok(!PLACEHOLDER.test(text), `should not match: ${text}`)
}

22
template/.gitattributes vendored Normal file
View File

@@ -0,0 +1,22 @@
# Check every text file out with LF, on every platform.
#
# This exists because of a real failure, reported by the kit's acceptance run
# (docs/modules/kit-acceptance.md, finding F1): on a default Windows clone,
# `npm run check:swagger` failed on a PRISTINE, unedited template. The check
# compares the committed swagger-fragment.json against what the generator writes;
# the generator writes LF, and core.autocrlf had handed the reader CRLF. The
# message blamed "the routes or their annotations", which is the first command the
# kit tells a reader to run telling them a false thing about their own work.
#
# The check itself now normalises line endings before comparing, so this file is
# the belt to that pair of braces: it also stops a CRLF blob ever being COMMITTED
# by a reader who copies this template, which would break the same check for
# everyone who cloned their repo afterwards.
#
# It travels with the template on purpose — a copied module directory keeps its
# own attributes, and this is one of the things the copier should not have to know.
* text=auto eol=lf
# Nothing here is binary today. If your module ships an image or a font, mark it,
# because `text=auto` guesses and a wrong guess corrupts the file:
# *.png binary

View File

@@ -19,19 +19,45 @@
# install downloads the tarball, verifies it against the `sha256` in the manifest,
# and unpacks it onto the volume. Nothing runs `npm` on the way.
#
# ── The version is DECLARED, not derived ──────────────────────────────────
# ── The version is DERIVED, and `module.json` is a floor ──────────────────
#
# Your module already has one authoritative version: `module.json`'s. It is what
# core records in `installed_modules`, what the admin screen shows, and it sits
# beside the `coreApi` range you have to consider a bump against. Two sources for
# one number is how they drift — so **a release happens when a push to `main`
# leaves `module.json` at a version that has no release yet.** Bumping the version
# is an ordinary reviewed pull request; publishing is this file's business.
# **Every push to `main` carrying a releasable commit publishes a bundle.** The
# next version is computed from conventional-commit subjects since the newest
# `v*` tag:
#
# It follows that this workflow never writes to a branch. It tags and publishes,
# so a protected `main` needs no push exception — which matters, because a release
# engine that has to push to `main` stops working the day someone tightens the
# rule. Re-running on an already-released version is a no-op.
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# The obvious alternative is to let `module.json`'s version decide — you already
# have that number, it is what core records in `installed_modules` and what the
# admin screen shows, and two sources for one number is how they drift. This
# repository's reference module shipped that way and moved off it, which is worth
# knowing before you copy either shape: the cost of a declared version is paid on
# **every** release, and the drift it prevents is something review catches anyway.
# A week of merged work there produced no bundle at all, because none of it
# happened to touch that line.
#
# **The declaration is kept as a floor.** Name a version in `module.json` above
# the newest tag and that version is what releases — which is the declared model
# exactly, surviving as the special case it always was, and still the natural way
# to say "this one is a minor" when a `coreApi` bump forces the question.
#
# So the number that ships is the **tag**, and this job writes it into the
# `module.json` inside the bundle. Your committed `module.json` is a floor and a
# starting point, not a record of the last release.
#
# ── The backdoor ──────────────────────────────────────────────────────────
#
# `workflow_dispatch` publishes on demand, for the case the rules cannot reach: a
# `module.json` change worth shipping — a widened `coreApi`, a new mount, a new
# capability — with no releasable code behind it. Leave `version` blank to bump
# the newest tag by `bump`, or name an exact version to publish that.
#
# This workflow never writes to a branch. It tags and publishes, so a protected
# `main` needs no push exception — which matters, because a release engine that
# has to push to `main` stops working the day someone tightens the rule.
# Re-running on an already-released version is a no-op.
#
# ── Before this can run ───────────────────────────────────────────────────
#
@@ -44,6 +70,16 @@ name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.2.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
concurrency:
group: release-module
@@ -67,34 +103,157 @@ jobs:
with:
node-version: 20
- name: Decide whether this commit releases
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
ID="$(node -p "require('./module.json').id")"
VERSION="$(node -p "require('./module.json').version")"
echo "module.json: ${ID} ${VERSION}"
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json: ${ID}, declaring ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
# Does a release already exist for this version? 404 means no, 200 means
# yes, and anything else — a network failure, a bad token — is not
# evidence of absence. Guessing "no" would publish over a good release,
# so refuse instead.
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
case "$HTTP" in
404) RELEASE=true ;;
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line — used rather than a hand-rolled compare because 0.10.0 vs 0.9.0
# is exactly what a plain string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable still releases — that is what the
# button is for. Where the log does say something, the LARGER of the
# two wins: pressing the button on a log full of `feat:` would
# otherwise publish the `patch` default over a minor's worth of work.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
else
echo "dispatch: the log says ${BUMP}, publishing a ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor: a module.json above the newest tag releases at that
# version, whatever the subjects say.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing, and standing down on the tag alone would make that state
# permanent — every later run sees the tag and stands down, so the
# release never appears. 404 means no release, 200 means yes, and
# anything else — a network failure, a bad token — is not evidence of
# absence: guessing "no" would publish over a good release, so refuse.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
case "$HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains: previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
# Before anything is built or tagged, so a repo without secrets fails
# legibly rather than half-publishing: the tag push can succeed on the
@@ -142,21 +301,40 @@ jobs:
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/${ID}-${VERSION}"
rm -rf dist && mkdir -p "$OUT"
# `$OUT`, not `dist` — the changelog is already sitting in `dist/` from
# the plan step, and the publish step reads it back.
rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads, the OpenAPI fragment, and the licence the
# code is under — a bundle shipping GPL code without its licence is not
# distributable.
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
# The manifest core reads — with the RELEASED version written into it.
# Your committed `module.json` is a floor, not a record of the last
# release, so copying it verbatim would ship a bundle whose
# `installed_modules` row and admin screen disagree with the tag it came
# from. This is where the derived number becomes the module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The OpenAPI fragment, and the licence the code is under — a bundle
# shipping GPL code without its licence is not distributable.
cp swagger-fragment.json LICENSE.md README.md "$OUT/"
# The server half, minus everything that never runs inside core's
# process: no `test/`, no `scripts/`, no `swagger/`.
#
# An EXCLUSION list, not an include list, and that is the whole point.
# This was `for d in boot.js core.js index.js db model router`, which
# meant adding `server/utils/` — an ordinary thing to do — silently
# dropped it from every release: the bundle check below only resolves
# the five paths module.json names, so nothing failed here, and the
# module died on an operator's box as a `startup_failed` row instead
# (docs/modules/kit-acceptance.md, F4). Excluding is the safe default
# because the failure mode inverts: forget to exclude something and you
# ship a harmless extra file, rather than omitting a required one.
mkdir -p "$OUT/server"
for d in boot.js core.js index.js db model router; do
cp -r "server/$d" "$OUT/server/"
for e in server/*; do
case "$(basename "$e")" in
test|scripts|swagger|package-lock.json) continue ;;
esac
cp -r "$e" "$OUT/server/"
done
cp server/package.json "$OUT/server/"
[ -d server/node_modules ] && cp -r server/node_modules "$OUT/server/" || true
# The client half is the BUILT chunk only. `client/src` is source an
# operator has no use for and core will never read.
@@ -166,17 +344,23 @@ jobs:
# Prove the bundle is loadable before publishing it: these are the exact
# paths core's loader resolves out of module.json. A release whose entry
# point is missing otherwise fails on an operator's box, as a
# `startup_failed` row, instead of here.
# `startup_failed` row, instead of here. The version assertion guards the
# rewrite above — a bundle still carrying the declared version would
# install under a number that is not the one it was released as.
node -e '
const fs = require("fs"), path = require("path");
const root = process.argv[1];
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client && m.client.entry, "swagger-fragment.json"]) {
if (!p) continue;
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
}
console.log("bundle contents check: ok");
' "$OUT"
' "$OUT" "$VERSION"
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
rm -rf "$OUT"
@@ -202,38 +386,10 @@ jobs:
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/${ID}-${VERSION}.json"
- name: Write the changelog
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
echo "### Changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
git log --no-merges --format='- %s' $RANGE || true
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
# Skipped on a recovery run: the tag is already there, and is the thing
# being published against.
- name: Tag the release
if: ${{ steps.plan.outputs.release == 'true' }}
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"

View File

@@ -23,13 +23,33 @@
# downloads the tarball, verifies it against the `sha256` in the manifest, and
# unpacks it onto the volume. Nothing runs `npm` on the way.
#
# ── The version is DECLARED, not derived ──────────────────────────────────
# ── The version is DERIVED, and `module.json` is a floor ──────────────────
#
# Your module already has one authoritative version: `module.json`'s. It is what
# core records in `installed_modules` and what the admin screen shows. Two sources
# for one number is how they drift — so **a release happens when a push to `main`
# leaves `module.json` at a version that has no release yet.** Bumping the version
# is an ordinary reviewed pull request; publishing is this file's business.
# **Every push to `main` carrying a releasable commit publishes a bundle.** The
# next version is computed from conventional-commit subjects since the newest
# `v*` tag:
#
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# The obvious alternative is to let `module.json`'s version decide — you already
# have that number, and two sources for one number is how they drift. This
# repository's reference module shipped that way and moved off it, which is worth
# knowing before you copy either shape: the cost of a declared version is paid on
# **every** release, and the drift it prevents is something review catches anyway.
# A week of merged work there produced no bundle at all, because none of it
# happened to touch that line.
#
# **The declaration is kept as a floor.** Name a version in `module.json` above
# the newest tag and that version is what releases — the declared model surviving
# as the special case it always was, and still the natural way to say "this one
# is a minor" when a `coreApi` bump forces the question.
#
# So the number that ships is the **tag**, and this job writes it into the
# `module.json` inside the bundle. `workflow_dispatch` is the backdoor for a
# `module.json` change worth shipping with no releasable code behind it: leave
# `version` blank to bump the newest tag by `bump`, or name an exact version.
#
# This workflow never writes to a branch, so a protected `main` needs no push
# exception. Re-running on an already-released version is a no-op.
@@ -44,6 +64,16 @@ name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.2.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
permissions:
contents: write
@@ -65,36 +95,162 @@ jobs:
with:
node-version: 20
- name: Decide whether this commit releases
- name: Plan the release (version + changelog)
id: plan
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
ID="$(node -p "require('./module.json').id")"
VERSION="$(node -p "require('./module.json').version")"
echo "module.json: ${ID} ${VERSION}"
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json: ${ID}, declaring ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
# `gh release view` exits non-zero when the release does not exist — but
# it also exits non-zero when the API is unreachable, and those two are
# not the same answer. Ask for the status code instead: 404 means no,
# 200 means yes, anything else is not evidence of absence, and guessing
# "no" would publish over a good release.
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" || echo 000)"
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
case "$HTTP" in
404) RELEASE=true ;;
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line — used rather than a hand-rolled compare because 0.10.0 vs 0.9.0
# is exactly what a plain string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable still releases — that is what the
# button is for. Where the log does say something, the LARGER of the
# two wins: pressing the button on a log full of `feat:` would
# otherwise publish the `patch` default over a minor's worth of work.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
else
echo "dispatch: the log says ${BUMP}, publishing a ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor: a module.json above the newest tag releases at that
# version, whatever the subjects say.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# A tag with no release behind it is NOT "nothing to do": it means a
# previous run tagged and then died before publishing, and standing down
# on the tag alone makes that state permanent. `gh release view` exits
# non-zero when the release does not exist — but it also exits non-zero
# when the API is unreachable, and those two are not the same answer.
# Ask for the status code instead: 404 means no, 200 means yes, anything
# else is not evidence of absence, and guessing "no" would publish over
# a good release.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" || echo 000)"
case "$HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains: previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
echo "The website only installs from hosts on its \`MODULE_SOURCE_HOSTS\` allowlist —"
echo "an operator installing this needs \`github.com\` on theirs."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
- name: Build the client chunk
if: ${{ steps.plan.outputs.release == 'true' }}
@@ -126,21 +282,40 @@ jobs:
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/${ID}-${VERSION}"
rm -rf dist && mkdir -p "$OUT"
# `$OUT`, not `dist` — the changelog is already sitting in `dist/` from
# the plan step, and the publish step reads it back.
rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads, the OpenAPI fragment, and the licence the
# code is under — a bundle shipping GPL code without its licence is not
# distributable.
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
# The manifest core reads — with the RELEASED version written into it.
# Your committed `module.json` is a floor, not a record of the last
# release, so copying it verbatim would ship a bundle whose
# `installed_modules` row and admin screen disagree with the tag it came
# from. This is where the derived number becomes the module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The OpenAPI fragment, and the licence the code is under — a bundle
# shipping GPL code without its licence is not distributable.
cp swagger-fragment.json LICENSE.md README.md "$OUT/"
# The server half, minus everything that never runs inside core's
# process: no `test/`, no `scripts/`, no `swagger/`.
#
# An EXCLUSION list, not an include list, and that is the whole point.
# This was `for d in boot.js core.js index.js db model router`, which
# meant adding `server/utils/` — an ordinary thing to do — silently
# dropped it from every release: the bundle check below only resolves
# the five paths module.json names, so nothing failed here, and the
# module died on an operator's box as a `startup_failed` row instead
# (docs/modules/kit-acceptance.md, F4). Excluding is the safe default
# because the failure mode inverts: forget to exclude something and you
# ship a harmless extra file, rather than omitting a required one.
mkdir -p "$OUT/server"
for d in boot.js core.js index.js db model router; do
cp -r "server/$d" "$OUT/server/"
for e in server/*; do
case "$(basename "$e")" in
test|scripts|swagger|package-lock.json) continue ;;
esac
cp -r "$e" "$OUT/server/"
done
cp server/package.json "$OUT/server/"
[ -d server/node_modules ] && cp -r server/node_modules "$OUT/server/" || true
# The client half is the BUILT chunk only.
mkdir -p "$OUT/client/dist"
@@ -152,14 +327,18 @@ jobs:
# `startup_failed` row, instead of here.
node -e '
const fs = require("fs"), path = require("path");
const root = process.argv[1];
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client && m.client.entry, "swagger-fragment.json"]) {
if (!p) continue;
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
}
console.log("bundle contents check: ok");
' "$OUT"
' "$OUT" "$VERSION"
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
rm -rf "$OUT"
@@ -185,39 +364,6 @@ jobs:
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/${ID}-${VERSION}.json"
- name: Write the changelog
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
echo "The website only installs from hosts on its \`MODULE_SOURCE_HOSTS\` allowlist —"
echo "an operator installing this needs \`github.com\` on theirs."
echo
echo "### Changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
git log --no-merges --format='- %s' $RANGE || true
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
- name: Tag and publish
if: ${{ steps.plan.outputs.release == 'true' }}
env:
@@ -228,10 +374,14 @@ jobs:
TAG="${{ steps.plan.outputs.tag }}"
VERSION="${{ steps.plan.outputs.version }}"
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git tag -a "$TAG" -m "${ID} ${TAG}"
git push origin "$TAG"
# Skipped on a recovery run — the tag is already there, and is the
# thing being published against.
if [ "${{ steps.plan.outputs.reuse_tag }}" != "true" ]; then
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git tag -a "$TAG" -m "${ID} ${TAG}"
git push origin "$TAG"
fi
gh release create "$TAG" \
--title "$TAG" \

36
template/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# The template's own ignore rules, so they travel with a copy.
#
# The kit repo's root .gitignore covers these paths too, but that file stays
# behind: copy `template/` out, `git init`, and you inherit nothing — `node_modules/`
# included. Reported by the acceptance run (docs/modules/kit-acceptance.md, F6).
# dependencies
node_modules/
# ── The built client chunk ────────────────────────────────────────────────
#
# Ignored HERE and shipped in the RELEASE, which is not a contradiction: a module
# is installed prebuilt (an operator never builds anything), but the artifact is
# built by CI from the source next to it, and a chunk committed by hand goes stale
# beside fresh source without anything saying so.
#
# Your release workflow builds it before packing the bundle. If you would rather
# commit it, delete this line and accept that you now have to remember.
client/dist/
# env / secrets — a module's env vars are the operator's, never the repo's
.env
*.env
!.env.example
# release staging, produced by .gitea/workflows/release.yml
/dist/
*.tar.gz
# logs / os / editor
*.log
npm-debug.log*
.DS_Store
Thumbs.db
.vscode/
.idea/

View File

@@ -5,11 +5,19 @@ rename it, and you have a running module before you have read a chapter.
Installed into a core, it adds:
- **one public page** at `/examplegame/status`, and a nav row pointing at it;
- **one API route**, `GET /api/v1/public/world/status`, described in an OpenAPI
fragment core merges into its own `/api/docs`;
- **one table**, `examplegame_world_status`, created by an idempotent schema
- **three public pages** — `/examplegame/status`, `/examplegame/clans` and one
clan at `/examplegame/clans/:externalId` — and nav rows pointing at the first two;
- **three API routes** under `/api/v1/public/world` and `/api/v1/public/clans`,
described in an OpenAPI fragment core merges into its own `/api/docs`;
- **three tables**, prefixed `examplegame_`, created by an idempotent schema
fragment and dropped by a purge file;
- **a Team provider**, which makes this module the authoritative source of Teams
for the deployment — the one registration where core calls YOU and waits;
- **three inverted extension slots**, declared by this module on the clan page for
core to fill;
- **four event declarations** — a budget dimension, an option source, a lease and
one action that ledgers what it makes — so an event authored on the website can
reach the game and be undone afterwards;
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
That is deliberately less than your module will do. What it is *complete* about is
@@ -23,21 +31,25 @@ module.json what core reads first — id, version, coreApi, moun
server/
index.js register(ctx, api) — the entire server-side handshake
core.js the lazy accessors over ctx; read this second
boot.js onBoot / onShutdown
boot.js onBoot / onShutdown, and the game-restart watch
sidecarClient.js the one file that talks to your sidecar — transport simulated
config/eventActions.js budgets, option sources, leases and actions — read chapter 5
db/schema.sql idempotent, replayed every boot
db/purge.sql destructive, run only by an explicit admin purge
model/worldStatus/ the .db.js / .model.js pair
router/public/ one router, one controller, the #swagger annotations
model/clans/ the Team provider, its SQL, and the audience rule
router/public/ two routers, two controllers, the #swagger annotations
swagger/doc.js tags and schemas the annotations refer to
scripts/checkImports.js the module boundary, enforced
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
test/ the suites — start with entry.test.js
test/eventActions.test.js the four traps chapter 5 is about, each as a failing test
client/
vite.config.js the library build: anchored aliases, external: []
src/entry.jsx registers routes and nav at evaluation time
src/core.js what core hands you: the seven-member UI kit
src/entry.jsx registers routes, nav and declared slots at evaluation time
src/core.js what core hands you: the UI kit (nine exports)
src/shim/ the four shared dependencies, re-exported from core
src/routes/public/ the page
src/routes/public/ the pages — Clan.jsx is the one with slots in it
scripts/checkExternals.js asks the BUILT chunk whether a bare import survived
test/ build.test.js and registration.test.js
.gitea/workflows/release.yml packaging CI — Gitea
@@ -103,23 +115,34 @@ backticking table names**.
| File | What to change |
| --- | --- |
| `module.json` | `id`, `name`, `version`, the `mounts` prefix, `capabilities` |
| `.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/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/config/eventActions.js` | the budget, option-source, lease and action ids — four separate id spaces, each namespaced with your module id — and every command name the client sends |
| `server/boot.js` | the placeholder world name |
| `server/db/schema.sql` | every table name — the prefix must be your id |
| `server/db/purge.sql` | the same table names |
| `server/model/worldStatus/worldStatus.db.js` | the `TABLE` constant |
| `server/model/clans/clanProvider.db.js` | the `CLANS` and `MEMBERS` table constants |
| `server/model/clans/clanProvider.model.js` | `pageUrlTemplate` — it must match the route `client/src/entry.jsx` registers |
| `server/router/public/world.router.js` | the `#swagger.tags` name |
| `server/router/public/clans.router.js` | the `#swagger.tags` name |
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
| `server/test/_fakes.js` | `ctx.moduleId` |
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
| `server/test/eventActions.test.js` | the action and lease ids it looks up, and the clan fixture |
| `server/test/worldStatus.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` |
| `client/package.json` | package `name` and `description` |
| `client/vite.config.js` | the guard plugin's `name` |
| `client/src/core.js` | the console tag on the identity check |
| `client/src/shim/rg.js` | the console tag on the missing-global error |
| `client/src/entry.jsx` | `ID`, and every route path and nav `to` |
| `client/src/entry.jsx` | `ID`, every route path and nav `to`, and the three `declareModuleSlot` names — core enforces that a slot is namespaced under your id |
| `client/src/routes/public/Clans.jsx` | the link to the clan page |
| `client/src/routes/public/Clan.jsx` | the three `<Slot name>` values and their `moduleId` |
| `client/test/registration.test.js` | the example path in the comment |
| `client/package-lock.json` | **regenerated** — `npm install --prefix client` |
| `swagger-fragment.json` | **regenerated** — `npm run swagger --prefix server` |
@@ -132,11 +155,21 @@ placeholder and is not listed fails the build, and so does a listed file with
nothing left to rename. A checklist nobody verifies is a checklist that is wrong
by the second edit.
Two things you do **not** rename: the mount prefix `/world` need not be your id
(the server's prefix namespace is shared with core's, and `/status`, `/settings`,
`/version` and `/contact` are already taken), and the `world` / `worldStatus`
**`server/sidecarClient.js` is not on that list and is not an oversight.** It
carries no placeholder id — its vocabulary is the game's, not the module's — so
the checker has nothing to hold it to. It is still the file you have the most work
in: replace `deliver()` with one request to your sidecar, replace the fake game's
verbs with your game's, and set `TIMEOUT_MS` to what your transport actually
waits. Chapter 5 is mostly about that file.
Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
not be your id (the server's prefix namespace is shared with core's — `/status`,
`/settings`, `/version`, `/contact` and `/teams` are already taken, which is why
the clan router is not mounted at the obvious name), and the `world` / `clan`
naming throughout is ordinary vocabulary you should replace with your own domain's
when you replace the feature.
when you replace the feature. **`clan` in particular is the point rather than the
placeholder:** core's word is Team, yours is whatever your game says, and the
provider exists because core cannot pick one.
## Licence

View File

@@ -27,8 +27,16 @@ export const world = {
status: () => req('/public/world/status'),
}
// The module's own clan surface. Core serves its own view of the same things as
// Teams, at `/public/teams` — which is why the prefix here is `/clans` and could
// not be `/teams`; see `server/router/public/clans.router.js`.
export const clans = {
list: () => req('/public/clans'),
get: (externalId) => req(`/public/clans/${encodeURIComponent(externalId)}`),
}
// Exported for the rare caller that needs the base itself — an `<img src>`, a
// download link, an EventSource. Reach for `request` first.
export { BASE }
export default { world, BASE }
export default { world, clans, BASE }

View File

@@ -45,14 +45,21 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
)
}
// The curated kit (§3.4). Seven members, and it is CLOSED: layout, headings, the
// three data-page states, the fetch hook, and read-only access to the session and
// the site's settings. Anything else your pages need — tables, tabs, an editor —
// you bundle yourself, in a `components/` directory of your own.
// The curated kit (§3.4). Nine exports, and it is CLOSED: layout, headings, the
// three data-page states, the fetch hook, read-only access to the session and the
// site's settings, and `Slot`. Anything else your pages need — tables, tabs, an
// editor — you bundle yourself, in a `components/` directory of your own.
//
// `Slot` is the one that is not a widget. It renders a place THIS module declared
// for core to fill (`entry.jsx`, and `routes/public/Clan.jsx` where two are used):
// the inverted direction of the extension-slot mechanism, added in 1.6.0. It is in
// the shared kit rather than reimplementable for the reason the whole kit exists —
// a second error boundary with different behaviour would be a second bug, and what
// this one contains is CORE's content failing inside YOUR page.
//
// Closed is a real constraint and it is the price of the boundary being worth
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing a
// kit component's props is a major one. Use them, though. A module page that
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing an
// existing prop on a kit component is a major one. Use them, though. A module page that
// ships its own layout is a page that stops looking like the site it is installed
// in, and drifts further every time core changes.
export const {
@@ -64,6 +71,7 @@ export const {
useAsync,
useAuth,
useSite,
Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.

View File

@@ -19,6 +19,8 @@
import { registry, coreApiVersion } from './core.js'
import WorldStatus from './routes/public/WorldStatus.jsx'
import Clans from './routes/public/Clans.jsx'
import Clan from './routes/public/Clan.jsx'
// Your module id, exactly as `module.json` spells it. Core keys the registry by
// it and prefixes every route path with it.
@@ -42,6 +44,13 @@ const ID = 'examplegame'
registry.registerRoutes(ID, {
public: [
{ path: 'status', element: <WorldStatus /> },
{ path: 'clans', element: <Clans /> },
// A parameter, and the name matters twice: `useParams()` in the page reads
// `externalId`, and the server's `pageUrlTemplate` substitutes `{externalId}`
// into this same path so core's notification email can link here. Nothing
// checks those three against each other — this is the seam to get right by
// hand, and the cost of getting it wrong is mail linking at a page that 404s.
{ path: 'clans/:externalId', element: <Clan /> },
],
})
@@ -68,9 +77,58 @@ registry.registerNav(ID, {
area: 'public',
items: [
{ label: 'World', to: '/examplegame/status' },
// The clan PAGE gets no nav row: rows point at pages a visitor can reach
// without knowing an id, and `/examplegame/clans/:externalId` is not one.
// `registration.test.js` checks every row against a route this module
// registered, which is the agreement that rots quietly.
{ label: 'Clans', to: '/examplegame/clans' },
],
})
// ── The inverted slot: this module DECLARES, core fills ───────────────────
//
// Everywhere else, core declares a place and a module fills it
// (`registry.registerExtension`). This is the mirror, added in MODULE_API 1.6.0
// for Teams: **a module declares a place on its own page and core fills it.**
//
// Teams are a core primitive with no core surface — core owns the tables, the
// membership sync, the access rules, the forum and the feed, and does not own the
// word "clan" — so the page is this module's and core contributes into it.
//
// Each declaration says two things: WHERE, in this module's own vocabulary, and
// WHICH of core's contributions belongs there. **Core offers a contribution and
// never names a slot** — it cannot, since it does not know what you called your
// page — so the second argument is the whole of what gets core's content onto it.
// Core's three, as of 1.6.0:
//
// `team.activity` the Team activity feed
// `team.forum` the Team forum panel
// `team.notify` the per-Team notification control
//
// Four things about these three lines:
//
// • **The name must be namespaced under this module's id**, and core enforces
// that rather than trusting it. It is what keeps two modules from claiming one
// name, and it makes the owner readable at the point of use in `Clan.jsx`.
// • **One slot per PLACE, not one per page.** A slot holds one component, so
// three contributions need three declarations — and this module then decides
// where each one sits, which is the freedom it declared them for.
// • **Asking for a contribution core does not offer THROWS here**, unlike almost
// everything else in the registry, which fails open. Core's catalogue is fixed
// at build time and your `coreApi` range has already been checked, so an
// unknown one is always a typo or a version skew — and the alternative failure
// is a page that renders empty forever with nothing logged.
// • **`{ core }` is optional.** A slot that asks for nothing stays empty, which
// is what you want for a place you intend to fill yourself.
//
// Declaring costs nothing on a core that offers none of them: core's fills are
// applied after every module chunk has evaluated, and a contribution nothing asks
// for is a no-op rather than an error. Both directions of that are silent on
// purpose — neither side may assume the other is there.
registry.declareModuleSlot(ID, 'examplegame.clan.header', { core: 'team.notify' })
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
registry.declareModuleSlot(ID, 'examplegame.clan.forum', { core: 'team.forum' })
// `module.json`'s `coreApi` range was checked by the loader before this file was
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
// between the core that validated your manifest and the core that published this

View File

@@ -0,0 +1,113 @@
// ── One clan — and the page that inverts the extension-slot direction ─────
//
// Everywhere else in this template, core owns a page and this module contributes
// to it. Here it is the other way round: **this module owns the page and core
// contributes to it**, through slots this module declared in `entry.jsx`.
//
// **Why it has to be this way round.** A Team is a core primitive — core owns the
// tables, the membership sync, the access rules, the forum and the activity feed
// — but core has no word for one. This game says clan, the next will say company,
// and a core-rendered `/teams` page would publish a noun core invented, beside
// this module's own page for the same thing. So the page is the module's, and the
// parts core cannot hand over are contributed into it.
//
// What core cannot hand over is worth being concrete about, because it is the
// test for whether something belongs in a slot: the activity feed's public/members
// split can only be resolved by the thing that owns membership, which is core.
// This module could render a feed; it could not decide who sees which half of it.
//
// **Three properties of `Slot` to know before you use one:**
//
// • It renders NOTHING when nothing fills it. A core that knows no Teams, a
// deployment with the forum switched off, a viewer with no membership — all
// of them are an empty slot and none of them is an error. Design the page to
// read correctly with every slot empty, because on some deployment it will.
// • **First fill wins**, and this module could fill its own declared slot. It
// does not, and that is the point of declaring one — but the rule is there so
// that a module can override core's contribution on a page it owns.
// • `externalId` is what core resolves the Team from, in THIS module's terms.
// Core maps its own Team from `(moduleId, externalId)`; the module never
// learns core's Team id and does not need to.
import { useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
import api from '../../api.js'
export default function Clan() {
const { externalId } = useParams()
const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
return (
<PublicLayout shell="narrow">
{loading && <Loading />}
{error && <ErrorState error={error} />}
{data && (
<>
<PageHeader
title={data.name}
lead={`${data.memberCount} members${data.abbr ? ` · ${data.abbr}` : ''}`}
/>
{/* Core's per-Team notification control lands here — ABOVE the roster,
deliberately. Muting a clan is an action ON this page, so it belongs
beside the heading rather than after the content. That placement is
this module's decision to make, and it is the whole reason for
declaring three slots rather than one: a single slot would hand core
the choice of where each of its contributions sits on a page core
does not own. */}
<Slot name="examplegame.clan.header" externalId={externalId} moduleId="examplegame" />
{data.members.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}>
<thead>
<tr style={{ textAlign: 'left', opacity: 0.7 }}>
<th style={{ padding: '0.4rem 0.5rem' }}>Name</th>
<th style={{ padding: '0.4rem 0.5rem' }}>Rank</th>
<th style={{ padding: '0.4rem 0.5rem' }}>Status</th>
</tr>
</thead>
<tbody>
{data.members.map((m) => (
<tr key={`${m.displayName}-${m.rankLabel}`}>
<td style={{ padding: '0.4rem 0.5rem' }}>
{m.displayName}{m.leader ? ' ★' : ''}
</td>
<td style={{ padding: '0.4rem 0.5rem' }}>{m.rankLabel || '—'}</td>
<td style={{ padding: '0.4rem 0.5rem' }}>{m.online ? 'online' : 'offline'}</td>
</tr>
))}
</tbody>
</table>
) : (
// Three quite different things produce an empty roster, and the server
// says which: a clan with nobody in it, an audience rule that excludes
// this viewer, and a rule nobody could resolve. A page that cannot tell
// them apart reports the last as the first.
<p style={{ opacity: 0.7, marginTop: '1rem' }}>
{data.projected
? 'No roster has been reported for this clan yet.'
: 'The roster is not available to you right now.'}
</p>
)}
{/* Core's Team activity feed. It is core's because only core can
resolve the public/members split on it — this module owns who is in
the clan, core owns what being in one entitles you to see. */}
<Slot name="examplegame.clan.detail" externalId={externalId} moduleId="examplegame" />
{/* And core's Team forum, in its own place below the feed. Core resolves
who may read and post; this module renders the room and never its
door policy. Empty on a deployment with forums switched off, which is
the default. */}
<Slot name="examplegame.clan.forum" externalId={externalId} moduleId="examplegame" />
<p style={{ marginTop: '1.5rem' }}>
<Link to="/examplegame/clans">← All clans</Link>
</p>
</>
)}
</PublicLayout>
)
}

View File

@@ -0,0 +1,56 @@
// ── The clan list ─────────────────────────────────────────────────────────
//
// An ordinary index page, here mostly so the clan page below it has somewhere to
// be linked from. The interesting file is `Clan.jsx`.
//
// `Link` comes from `react-router-dom`, which resolves through this module's shim
// to core's router — so a click navigates inside the SPA rather than reloading
// the site. An `<a href>` here would work and would cost a full page load and the
// session-shaped flash that comes with it.
import { Link } from 'react-router-dom'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import api from '../../api.js'
export default function Clans() {
const { data, loading, error } = useAsync(() => api.clans.list(), [])
return (
<PublicLayout shell="narrow">
<PageHeader title="Clans" lead="The companies, orders and warbands of the world" />
{loading && <Loading />}
{error && <ErrorState error={error} />}
{/* `EmptyState` renders its CHILDREN and takes no other prop. Pass the
sentence as `message=` and React drops it without a word: the panel
renders as an empty box. module-rust shipped six of those for four
phases, learned from this line when it said `message=`. */}
{data && data.clans.length === 0 && (
<EmptyState>No clans have been reported yet.</EmptyState>
)}
{data && data.clans.length > 0 && (
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: '0.5rem' }}>
{data.clans.map((clan) => (
<li key={clan.externalId}>
<Link to={`/examplegame/clans/${clan.externalId}`}>
{clan.name}{clan.abbr ? ` [${clan.abbr}]` : ''}
</Link>
<span style={{ opacity: 0.7 }}>
{' '}— {clan.memberCount} member{clan.memberCount === 1 ? '' : 's'}
</span>
</li>
))}
</ul>
)}
{data && data.stale && (
<p style={{ opacity: 0.7, marginTop: '1rem' }}>
The game has not reported recently, so this list may be out of date.
</p>
)}
</PublicLayout>
)
}

View File

@@ -3,13 +3,24 @@
// An ordinary React component. Nothing about being inside a module changes how
// you write one — the only differences are where React comes from (core, via the
// aliases in vite.config.js, so the import below looks completely normal and is
// not) and where the chrome comes from (`../../core.js`, the seven-member kit).
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
//
// **Render `PublicLayout` yourself.** Core wraps your public routes in its
// maintenance gate and nothing else, so a page that omits the layout renders
// bare — no header, no footer, no site chrome — which looks like a bug and is
// the contract (§3.3). Admin and player routes are the other way round: core
// wraps those in their layouts for you.
//
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
// centred column, the vertical padding, and the thing that holds the footer at
// the bottom of the viewport. Without it your content starts hard against the
// left edge of the window and the footer rides up underneath it, which reads as
// a CSS bug in your module and is not one. Widths are 'narrow', 'mid' and
// 'wide'; name a width, never a class, because the classes belong to core's
// stylesheet and it is free to rename them (§3.4, MODULE_API_VERSION 1.5.0).
//
// This is here because the kit's acceptance run got it wrong by following the
// kit: a module built to the letter of chapter 2 rendered outside the site.
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import api from '../../api.js'
@@ -34,10 +45,15 @@ export default function WorldStatus() {
const { data, loading, error } = useAsync(() => api.world.status(), [])
return (
<PublicLayout>
<PublicLayout shell="narrow">
<PageHeader
title="World status"
subtitle="What the game server last told us about itself"
// `lead`, not `subtitle`. PageHeader takes `eyebrow`, `title`, `lead` and
// `center`, and an unknown prop on a React component is silently dropped —
// so a page written with `subtitle` renders its title and nothing else, on
// a site where every core page has a line under its heading. Nothing warns.
// Found by installing this template into a real core and looking at it.
lead="What the game server last told us about itself"
/>
{loading && <Loading />}

View File

@@ -35,6 +35,12 @@ const HERE = path.dirname(fileURLToPath(import.meta.url))
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
// Core's contribution catalogue, as of MODULE_API 1.6.0 (§3.7a). Written down
// rather than imported: this suite runs against the BUILT chunk with no core in
// the process, so it is a claim about core that has to be re-read when core's list
// changes — the same trade the rest of this fake makes.
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
// A component, as far as the registry cares. The kit's real members are core's;
// nothing renders here, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
@@ -44,6 +50,7 @@ function fakeRg() {
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = []
return {
version: manifest.coreApi.replace(/^\D+/, ''),
react,
@@ -54,7 +61,7 @@ function fakeRg() {
// this object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
@@ -72,10 +79,22 @@ function fakeRg() {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
// The INVERTED direction (1.6.0): the module declares, core fills. Core
// enforces the namespace AND the contribution name at this call, which is why
// the fake does too — either one core would reject is a slot that renders
// nothing on a real install and everything in a suite that shrugged.
declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`"${name}" is not namespaced under "${id}"`)
const wants = options.core ?? null
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
throw new Error(`"${name}" asks for core contribution "${wants}", which core does not offer`)
}
declaredSlots.push({ id, name, wants })
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions }),
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
@@ -166,12 +185,38 @@ it('every slot module.json declares is one the chunk fills', () => {
}
})
it('every declared slot is namespaced under this module and rendered by a page', () => {
// Two halves that nothing else holds together. The namespace is core's rule and
// the fake enforces it at the call; what a test has to check is the OTHER end —
// a slot declared and never rendered is a promise to core that no page keeps,
// and it fails silently, because an unrendered slot looks exactly like an
// unfilled one.
const pages = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Clan.jsx'), 'utf8')
for (const { id, name } of registered.declaredSlots) {
assert.equal(id, manifest.id)
assert.ok(name.startsWith(`${manifest.id}.`), `slot "${name}" is not under the module namespace`)
assert.ok(pages.includes(`name="${name}"`), `slot "${name}" is declared and never rendered`)
}
})
it('every declared slot names a core contribution core actually offers', () => {
// The fake throws on an unknown one, exactly as core does, so this asserts the
// other half: that the slots asked for something at all. A slot with no `core`
// is legal and stays empty — which is right for a place you fill yourself and
// wrong for one you are waiting on core for, and only you know which it is.
for (const { name, wants } of registered.declaredSlots) {
assert.ok(wants, `slot "${name}" asks for no core contribution, so nothing will ever fill it`)
assert.ok(CORE_CONTRIBUTIONS.includes(wants))
}
})
it('registers under exactly one module id, matching the manifest', () => {
const owners = new Set([
...Object.values(registered.routes).flat().map((r) => r.moduleId),
...Object.values(registered.nav).flat().map((r) => r.moduleId),
...[...registered.extensions.values()].map((e) => e.id),
...[...registered.providers.values()].map((p) => p.id),
...registered.declaredSlots.map((s) => s.id),
])
assert.deepEqual([...owners], [manifest.id])
})

View File

@@ -2,13 +2,13 @@
"id": "examplegame",
"name": "Example Game",
"version": "0.1.0",
"coreApi": "^1.4.0",
"coreApi": "^1.10.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/world"]
"public": ["/world", "/clans"]
},
"capabilities": ["world-status"]
"capabilities": ["world-status", "clans"]
}

View File

@@ -30,6 +30,8 @@
const core = require('./core')
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
const clanDb = require('./model/clans/clanProvider.db')
const sidecar = require('./sidecarClient')
const log = core.logger('boot')
@@ -38,6 +40,10 @@ const log = core.logger('boot')
// so that there is something for the shutdown hook to actually do.
let refreshTimer = null
// The last boot id the game reported. `null` means "never observed", which is not
// the same as "changed" — see `checkForRestart`.
let lastBootId = null
const REFRESH_MS = 30 * 1000
/**
@@ -51,12 +57,125 @@ async function refresh() {
try {
// 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.
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)
// Same poll, different question: did the thing we lit beacons in restart?
checkForRestart()
// `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) {
log.warn('could not refresh world status', { error: err.message })
}
}
/**
* Notice that the game restarted, and tell core.
*
* **Core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
* from a dispatch and cannot tell a wedged sidecar from a game that rebooted and
* lost every beacon an event lit. Only this module knows, because only this
* module watches the feed the boot id arrives on — which is also how you tell a
* game restart from a sidecar reconnect, and they are not the same event: the
* second loses nothing.
*
* So core asks once, at its own boot — the one reconnect it can see — and
* otherwise waits to be told. `core.reconcileEvents()` is being told. It returns
* at once and core sweeps its resource ledger on its own time, putting the
* question back to this module as `reconcile({ runId, resources })` in
* `config/eventActions.js`.
*
* Called from the same poll as everything else here, because a boot id is just
* another thing the feed carries. In a real module this is a frame handler rather
* than a comparison against a remembered value.
*/
function checkForRestart() {
const bootId = sidecar.currentBootId()
if (lastBootId === null) {
// First observation is not a restart. Recording it as one would ask core to
// reconcile every ledgered resource on every website deploy, which is a sweep
// that costs a round trip per action for no news.
lastBootId = bootId
return
}
if (bootId === lastBootId) return
lastBootId = bootId
log.info('game restarted, asking core to reconcile', { bootId })
core.reconcileEvents()
}
/**
* Two clans, so that the Team provider has something to be authoritative about.
*
* A real module fills these tables from its sidecar — the roster arriving on its
* own frames, separately from the clan itself. That separation is why
* `member_count` is written from what the game SAYS the size is rather than from
* the rows: the provider needs both numbers to tell an empty clan from one whose
* roster has not landed, and a seeder that derives the count from its own array
* quietly removes the case the provider's most important guard exists for.
*
* **Core is not called here and does not have to be.** Registration is a claim;
* core reconciles on its own schedule, after `onBoot`, by calling the provider.
* A module that tried to push Teams into core would be a module racing core's
* reconciler for a table it does not own.
*/
async function seedClans() {
try {
await clanDb.replaceClan({
externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 3,
members: [
{ memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', leader: true, online: true },
{ memberKey: 'char-002', displayName: 'Bryn', rankLabel: 'Member', online: false },
{ memberKey: 'char-003', displayName: 'Cass', rankLabel: 'Member', online: true },
],
})
await clanDb.replaceClan({
externalId: 'clan-2', name: 'Ash and Ember', abbr: 'A&E', memberCount: 1,
members: [
{ memberKey: 'char-101', displayName: 'Dael', rankLabel: 'Warlord', leader: true, online: false },
],
})
} catch (err) {
log.warn('could not seed clans', { error: err.message })
}
}
/**
* Runs once, after the schema and before the listener binds.
*
@@ -66,6 +185,7 @@ async function refresh() {
*/
async function onBoot() {
await refresh()
await seedClans()
refreshTimer = setInterval(refresh, REFRESH_MS)
// Node keeps the process alive for a pending timer. Core's own intervals are
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
@@ -86,7 +206,8 @@ async function onBoot() {
async function onShutdown() {
if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = null
lastBootId = null
log.info('shut down')
}
module.exports = { onBoot, onShutdown, refresh, REFRESH_MS }
module.exports = { onBoot, onShutdown, refresh, seedClans, checkForRestart, REFRESH_MS }

View File

@@ -0,0 +1,447 @@
// ── What an event author can reach for ────────────────────────────────────
//
// MODULE_API.md 1.10.0 and `website/EVENTS.md` §F. Four declarations, all
// optional, and together they are how a scheduled event on the website reaches
// into your game and comes back out again.
//
// **All of it is optional, and that is the contract's own posture, not a hedge.**
// A deployment with none of this installed still has a working event engine: it
// can announce, wait, cue a human and publish results, over core's own verbs.
// What these four add is the ability for an event to reach the GAME. A module
// that registers none of them costs its deployment a capability, never a boot.
//
// ── The order to read this file in ────────────────────────────────────────
//
// A BUDGET names a resource dimension core can bound. An OPTION SOURCE answers a
// dropdown on the authoring form. A LEASE is a value a run may BORROW, with a
// deadline. An ACTION is a verb a run may perform, and what it makes it OWNS
// until teardown.
//
// Four separate id spaces, each namespaced with your module id. `examplegame.beacons`
// as a budget and `examplegame.beacon.light` as an action are not a collision;
// reading them as one would forbid the most natural set of names you will ever write.
//
// ── Own versus borrow, and which one to build first ───────────────────────
//
// This file declares one of each on purpose, and if you only have time for one,
// **build the lease.** `EVENTS.md` §H is blunt about it: the lease is the
// primitive that travels and object creation is the special case. "Double the
// gather rate for the weekend" is the canonical community event in almost every
// game — set a value, hold it, put it back — while spawning creatures at a
// landmark is a shape one genre happens to have. A lease is also the cheaper
// thing to make safe, because the value you are replacing already existed and
// reading it first gives you a baseline for free.
//
// ── The four things that are invisible until an outage ────────────────────
//
// Everything below is ordinary except four rules, and all four are the kind that
// look like they are working right up until the day something is down. They are
// marked TRAP 1..4 where they bite. In short:
//
// 1. **No shape a failure can take reads as success**, and `retry: true` is the
// default — so `budgetMs` must EXCEED your transport's own timeout or your
// own `retry: false` is unreachable code. The reason a refusal gives goes in
// `error`; core reads no other name.
// 2. **Pass `idempotencyKey` through, unchanged, on every attempt** — and put
// it on a COMMAND, never on a question. It is the only thing standing
// between a retry and a second world change, and the only thing that can
// make a read permanently stale.
// 3. **Core records a resource BEFORE it is confirmed**, so `revert` will be
// called about things that may never have existed — and about nothing at
// all, with only a key.
// 4. **`cost` is priced before dispatch and never reconciled against what came
// back**, so an action that under-declares turns every cap into a lie.
const core = require('../core')
const sidecar = require('../sidecarClient')
const clanDb = require('../model/clans/clanProvider.db')
const log = core.logger('events')
// ── TRAP 1 ────────────────────────────────────────────────────────────────
//
// Core's dispatcher enforces `budgetMs`. When it expires the dispatcher stops
// waiting and classifies the failure as **retry**, unconditionally, without
// asking the action — it cannot ask, the action is still awaiting a socket.
//
// So an action whose own client gives up AFTER core's deadline never gets to
// classify its own failure, and every `retry: false` it might return is
// unreachable code. Core's default `budgetMs` is 10s; this module's client waits
// 12s; on the default the deadline would fire first on every slow game and the
// step would be retried by core no matter what this file says.
//
// Hence: strictly greater than `sidecar.TIMEOUT_MS`, derived from it rather than
// typed beside it, and asserted in `test/eventActions.test.js`. Deriving it is
// the part worth copying — a constant typed twice drifts the first time somebody
// tunes the client and does not think to look here.
const BUDGET_MS = sidecar.TIMEOUT_MS + 3000
// How many beacons one step may ask for. A bound in the module, in front of the
// operator's cap rather than instead of it: this one is what the GAME can stand,
// and the cap is what this deployment allows. Pre-checking here is what lets a
// dry run show an author the refusal rather than a run meeting it at 3am.
const MAX_BEACONS = 25
// Statuses the far end uses to mean "this will never work". Everything else —
// including a timeout, a transport error and anything unrecognised — is left to
// the default, which is a retry. That direction is deliberate: see the envelope
// note on `classify` below.
const PERMANENT = new Set(['unknown-command', 'no-idempotency-key'])
/**
* One place that turns a client reply into an envelope core understands.
*
* Worth having as a function even with two callers. The rule it encodes —
* "unrecognised means retry" — is the one you want stated once, because the
* failure mode of getting it wrong per-action is a verb that quietly stops
* retrying and nobody notices until a shard reboots mid-event.
*/
function classify(answer) {
// **The field is `error`, not `detail`.** Core's dispatcher reads exactly two
// things off a failure envelope — `ok` and `retry` — and passes `error`
// through as the message an operator sees on the run console and an author
// sees on a dry run. Anything under another name is dropped in silence, so an
// action that puts its reason in `detail` produces a refusal that reads
// "<action id> refused" and tells nobody why. Writing this template is how
// that was found: `EVENTS.md` §H names a `detail` member in passing and core
// has never read one.
return { ok: false, retry: !PERMANENT.has(answer.status), error: answer.status }
}
// ══ BUDGETS ═══════════════════════════════════════════════════════════════
//
// A dimension core can count and bound. Core never learns what a beacon is: it
// holds `{ dimension, consumed, cap }` and the vocabulary stays here. That is the
// whole of what makes the engine game-agnostic at this seam.
//
// **Declaring a dimension is not the same as bounding it.** A declared dimension
// with no operator cap is counted and unbounded — which is useful on its own,
// because the run console then shows an author what their event actually spent.
const BUDGETS = [
{ id: 'examplegame.beacons', label: 'Beacons lit', unit: 'count' },
]
// ══ OPTION SOURCES ════════════════════════════════════════════════════════
//
// What a dropdown on the authoring form is filled from. A fourth registration
// rather than a field on the action, because a catalog usually has more than one
// consumer — this one answers both the action's `clanId` param and the lease's
// target would, if the lease were targeted — and two actions declaring it
// separately would be two allowlists that can disagree.
//
// **A source that refuses degrades its field to free text with a warning.** It
// never blocks the form and it never raises, so this resolver may read the
// database and may fail. Do not defend against that by returning a hardcoded
// list; an empty answer with a log line is more honest than a stale one.
const OPTION_SOURCES = [
{
id: 'examplegame.options.clans',
label: 'Clans',
// Core passes `q` to EVERY source and requires it of none, so a resolver
// written before search existed behaves identically. Declare
// `searchable: true` when the term actually narrows the answer — the form
// reads it to choose between a typeahead and a select. Do not infer it from
// the length of the list: that reads correctly right up until a small
// deployment's list happens to fit in a dropdown.
async resolve() {
try {
const clans = await clanDb.listClans()
return clans.map((c) => ({ value: c.externalId, label: c.name }))
} catch (err) {
log.warn('option source failed', { source: 'examplegame.options.clans', error: err.message })
return []
}
},
},
]
// ══ LEASES ════════════════════════════════════════════════════════════════
//
// A value a run BORROWS and gives back. The module declares what can be held and
// how long; **the verb is core's** — an author puts `core.lease` in a step, and
// core reads the baseline, reserves the target in its resource ledger, applies
// the value with a deadline, and restores it at teardown through `restore()`
// below. A lease verb of your own would be that duration bound and that
// two-events-one-target check re-implemented once per module, advisory
// everywhere, and wrong in the first one that forgot it.
//
// **Only advertise a lease you have verified takes effect.** A value your game
// reads once at start-up and caches will apply cleanly, read back cleanly and do
// nothing — a capability that lies, which no amount of core-side checking can
// catch. Apply it, observe it, restore it, as a test, per key.
const LEASES = [
{
id: 'examplegame.rate.gather',
label: 'Gather rate',
type: 'float',
min: 0.5,
max: 5,
// The longest core will let a run hold it. A weekend, here. The bound is
// core's to enforce and yours to choose, and it should be the longest you
// would be comfortable finding still applied after everything else broke.
maxDurationMs: 48 * 60 * 60 * 1000,
/** The baseline, read live. Core stores what this answers and restores to it. */
async read() {
// `ask`, not `send`. A read carrying an idempotency key would be answered
// with the FIRST read's value forever — see `sidecarClient.js`'s header.
const answer = await sidecar.ask('rate.gather.read')
return answer.ok ? { ok: true, value: answer.data.value } : classify(answer)
},
/**
* Hold the value until `until`.
*
* **`until` goes down the wire and the far end honours it without being asked
* again.** Core's copy of the deadline is for the console; the game's copy is
* the fail-safe. A module that passes it and then relies on core to come back
* and restore has built a lease that outlives an outage — which is the one
* thing a lease exists to prevent.
*/
async apply(value, until) {
// No idempotency key, and that is deliberate rather than an omission:
// setting a value to X twice is setting it to X. A key here would buy
// nothing and cost the reply's freshness.
const answer = await sidecar.send('rate.gather.apply', {
value,
until: until instanceof Date ? until.toISOString() : until,
})
return answer.ok ? { ok: true } : classify(answer)
},
/**
* Put it back.
*
* `expected` is what core believes is currently applied. Answering that the
* live value differs is how a lease lands `drifted` with the current value
* beside it, rather than core silently overwriting whatever a human changed
* mid-event. Restoring must be idempotent for the same reason `revert` must:
* core may ask more than once.
*/
async restore(baseline, { expected } = {}) {
const live = await sidecar.ask('rate.gather.read')
if (!live.ok) return classify(live)
if (expected !== undefined && Number(live.data.value) !== Number(expected)) {
return { ok: true, drifted: true, value: live.data.value }
}
const answer = await sidecar.send('rate.gather.restore', { value: baseline })
return answer.ok ? { ok: true } : classify(answer)
},
/**
* A FOURTH question, not a fourth spelling of `read()`.
*
* "Does the game side still have any record of this hold?" A value that
* DIFFERS from what the run applied is drift, which `restore()` reports; a
* reconcile that inferred absence from a changed value would take the row out
* and tell an operator the lease vanished rather than that somebody moved it.
*
* Optional, and answering `{ ok: true, held: false }` is the only thing that
* takes a lease's ledger row out. Everything else — a throw, a refusal, no
* `inForce` at all — leaves the row alone, which is the same
* "I do not know is never it is gone" rule the actions below follow.
*/
async inForce() {
const live = await sidecar.ask('rate.gather.read')
if (!live.ok) return classify(live)
return { ok: true, held: Number(live.data.value) !== 1.0 }
},
},
]
// ══ ACTIONS ═══════════════════════════════════════════════════════════════
const ACTIONS = [
{
id: 'examplegame.beacon.light',
label: 'Light beacons',
description: "Lights signal beacons at a clan's hall for the length of this event.",
// Both are closed sets core interprets, and neither is decoration: `risk`
// decides which role may put this in a step and whether it is off by default,
// and `reversible` decides whether core will ever call `revert`.
risk: 'change', // notify | inspect | change | irreversible
reversible: 'ledger', // none | self | ledger | override
version: 1,
budgetMs: BUDGET_MS, // TRAP 1 — see the constant
// ── TRAP 4 ──────────────────────────────────────────────────────────
//
// What ONE invocation consumes. A function, because it depends on the params.
//
// **Core prices this BEFORE dispatch and never reconciles it against what
// came back.** There is no check that the `resources` you return match what
// you said you would spend — there cannot be, since core does not know what a
// beacon is. So an action that returns `{ 'examplegame.beacons': 1 }` while
// lighting twelve turns an operator's cap of 30 into a cap of 360, and the
// meter on the run console agrees with the lie. Nothing goes red. The first
// symptom is a world with an order of magnitude more in it than anyone
// authorised.
//
// Count what you will actually make, from the params you were given, every
// time. If you cannot know until the answer comes back, declare the maximum:
// a spend that is too high refuses an event that would have fit, which an
// author can see and argue with, and one that is too low cannot be seen at all.
//
// A `cost()` naming a dimension no module declared is REFUSED — at save, at
// the dry run and at dispatch — because the fix is a module's declaration and
// not a deployment's cap.
cost: (p) => ({ 'examplegame.beacons': Number(p.count) || 0 }),
params: [
{
name: 'clanId',
type: 'string',
required: true,
// `example` is required on every param, optional ones included. It is the
// authoring form's placeholder, it is one word at declaration time, and
// it is unreconstructable afterwards by anybody who did not write the action.
example: 'clan-1',
source: 'examplegame.options.clans',
},
{ name: 'count', type: 'int', required: true, example: 6 },
],
/**
* Do it.
*
* @param {object} env
* @param {string} env.runId
* @param {string} env.stepId
* @param {string} env.idempotencyKey a function of identity, never of attempt
* @param {*} env.scope opaque to core; may be null
* @param {object} env.params
* @param {object} env.actor
* @param {boolean} env.verify dry run: validate, change NOTHING
*/
async perform({ idempotencyKey, params, verify }) {
const count = Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > MAX_BEACONS) {
// A refusal the second attempt would repeat verbatim, so `retry: false`.
// This is the arm TRAP 1 exists to keep reachable.
return { ok: false, retry: false, error: `count must be 1..${MAX_BEACONS}` }
}
// **`verify` must change nothing and must answer honestly.** It rides this
// same dispatcher a real run uses, because a dry run down a second code
// path is a dry run of the second path. Validate everything you can reach
// without writing — the params above, and a lookup below — then stop.
if (verify) {
const clan = await clanDb.findClan(params.clanId)
return clan
? { ok: true }
: { ok: false, retry: false, error: `no such clan: ${params.clanId}` }
}
// ── TRAP 2 ────────────────────────────────────────────────────────
//
// The key goes through, unchanged. Core derives it from the step's identity
// and never from the attempt number, so every retry carries the same one —
// and the far end, which is the only end that can tell a retry from a
// repeat, answers a key it already executed with the ORIGINAL reply rather
// than running it again.
//
// A module that generates its own key here, or drops it, has an action that
// cannot be retried safely, and the cost of that is not a failed step: it
// is a second world change on a socket hiccup. It looks like it works in
// every test, because in every test the first attempt succeeds.
const answer = await sidecar.send(
'beacon.light',
{ clanId: params.clanId, count },
{ idempotencyKey },
)
if (!answer.ok) return classify(answer)
// What core writes into its ledger. `kind` is yours; `ref` is whatever you
// will need to undo it. The boot stamp rides along because `reconcile`
// below is the only thing that reads it — see its note.
return {
ok: true,
resources: answer.data.refs.map((ref) => ({
kind: 'beacon',
ref,
meta: { bootId: answer.data.bootId },
})),
}
},
/**
* Undo it. **Required, because `reversible` is `'ledger'`.**
*
* Called by core's cleanup sweep at teardown, over the rows this action's
* `resources` produced — a LIST, so twelve beacons are one round trip rather
* than twelve. Cleanup is derived rather than authored: there is no
* `on_teardown` and no cleanup phase in a spec, because an operator cannot be
* relied on to write the undo and an aborted run never reaches the phase they
* wrote it in. It runs on every terminal path — completion, cancellation and
* abort alike.
*
* ── TRAP 3 ──────────────────────────────────────────────────────────
*
* Two things follow from `EVENTS.md` §D rule 1, *core records a resource
* BEFORE it is confirmed*:
*
* • **Reverting something that does not exist is a SUCCESS.** A dispatch
* whose answer was lost leaves a ledger row for something that may never
* have existed, and cleanup will ask about it. You must never have to
* tell "I removed it" from "it was not there" — and you could not, because
* the far end cannot either. Answer `{ ok: true }`.
*
* • **You will be called with NO resources and only a key.** That is the
* lost-answer case stated exactly: core knows a dispatch went out under
* this key and never learned what it made. A module that can undo by key
* answers honestly. One that cannot answers `{ ok: false }`, and the row
* stays visible to an operator — which is the correct outcome, not a
* silent one. Answering `{ ok: true }` to a question you cannot answer is
* how a beacon burns forever with core's ledger reporting it cleaned up.
*
* And it must be idempotent, because core may ask more than once.
*/
async revert({ resources, idempotencyKey }) {
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
if (refs.length === 0) {
// The lost-answer case. This module CAN answer it, because the far end
// stores what each key produced — so asking it to undo the key is a real
// question with a real answer. If yours cannot, say `{ ok: false }` here
// and let a human see the row.
const byKey = await sidecar.send('beacon.douse', { refs: [] }, { idempotencyKey })
return byKey.ok ? { ok: true } : classify(byKey)
}
const answer = await sidecar.send('beacon.douse', { refs }, { idempotencyKey })
if (!answer.ok) return classify(answer)
// `{ ok: true }` reverts the whole group. Name the ones that did not come
// back in `failed: [...]` and core keeps exactly those rows.
return { ok: true }
},
/**
* Which of these does the game still have? **Optional.**
*
* Asked after something outside core restarted — core's own boot, or this
* module calling `core.reconcileEvents()` because it saw the boot id change.
*
* The asymmetry with `revert` is the design: a module that cannot say what the
* game still has is not broken, and core keeps believing its own ledger. One
* that created something and cannot undo it has made a promise core has no way
* to keep. So `revert` is required and this is not.
*
* **Anything that is not an explicit `{ ok: true, inForce: [...] }` leaves the
* ledger alone.** "I do not know" is never read as "it is gone", and a
* resource reported missing becomes `orphaned` rather than `reverted` —
* because nobody asked for it to go.
*/
async reconcile({ resources }) {
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
// A question, so `ask`. Keying this one would have pinned the answer to
// whatever was in force the first time core ever swept — which is the exact
// opposite of what a reconcile is for.
const answer = await sidecar.ask('beacon.inForce', { refs })
if (!answer.ok) return classify(answer)
return { ok: true, inForce: answer.data.refs }
},
},
]
module.exports = { BUDGETS, OPTION_SOURCES, LEASES, ACTIONS, BUDGET_MS, MAX_BEACONS, classify }

View File

@@ -91,6 +91,38 @@ module.exports = {
// stack. Routers are built inside `register()`, so `ctx` is set by then.
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),
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
// creature an event spawned. Only this module knows, because only this module
// watches the feed the boot id arrives on.
//
// Calling it asks core to sweep its resource ledger and put the question back
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
// forget: it returns at once and the sweep happens on core's own time.
//
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
// the answer. Named longer than the `ctx` member it wraps because this object
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
// would be worse, since a module has more than one thing it could reconcile.
reconcileEvents: () => need().events.reconcile(),
// 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
// core's and the module's location is the loader's business.

View File

@@ -10,10 +10,11 @@
-- remove it — so core refuses to load a module that declares one without the
-- other.
--
-- **Drop in the reverse of creation order.** With one table it does not matter;
-- with a parent and its children it does, because dropping a parent first fails
-- on the constraint and a purge that fails halfway is worse than one that never
-- ran — it leaves exactly the orphaned data this file exists to remove.
-- **Drop in the reverse of creation order**, which this file now actually
-- depends on: `examplegame_clan_members` carries a foreign key into
-- `examplegame_clans`, so dropping the parent first fails on the constraint, and
-- a purge that fails halfway is worse than one that never ran — it leaves
-- exactly the orphaned data this file exists to remove.
-- `IF EXISTS` on every line, so a partially-installed module still tears down.
--
-- **What does NOT belong here: rows you wrote into core's tables.** Notification
@@ -21,4 +22,6 @@
-- a module does not DELETE from core's tables. Core prunes what it knows you
-- registered, because it is the one that knows which registrant owned what.
DROP TABLE IF EXISTS examplegame_clan_members;
DROP TABLE IF EXISTS examplegame_clans;
DROP TABLE IF EXISTS examplegame_world_status;

View File

@@ -68,3 +68,57 @@ CREATE TABLE IF NOT EXISTS examplegame_world_status (
-- again on every boot, and the second run must be a no-op rather than a
-- duplicate-key error that fails the whole replay.
INSERT IGNORE INTO examplegame_world_status (id, online, players) VALUES (1, 0, 0);
-- ── Clans, and who is in them ─────────────────────────────────────────────
-- The module's half of Teams (MODULE_API.md — `api.registerTeamProvider`, and
-- TEAMS.md §2.3). A **Team** is core's word and a core table; a **clan** is this
-- game's word for the same thing, and these two tables are what the module knows
-- about them. Core never reads either — it asks the provider in
-- `model/clans/clanProvider.model.js`, which reads these.
--
-- **That separation is the point of the whole primitive, and it is worth being
-- concrete about.** Core owns `teams`, `team_members`, the reconciler that syncs
-- them, the access rules, the forum and the activity feed. This module owns what
-- a clan IS, which members exist, and who may look. Nothing here is prefixed
-- `team_` because nothing here is core's; §2.6's prefix rule would refuse it
-- anyway, and the rule is doing real work in this direction — a module that
-- wrote into `team_members` would be a module racing core's reconciler.
--
-- In a real module both tables are filled by your sidecar ingest. Here `boot.js`
-- seeds two clans so the pages render and the seam is visible.
CREATE TABLE IF NOT EXISTS examplegame_clans (
external_id VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
abbr VARCHAR(16) NULL,
-- What the game says the clan's roster size is, which is NOT the number of
-- rows next door. The two arrive separately in every real ingest, and the
-- provider needs both to tell "this clan is empty" from "its roster has not
-- landed yet" — the distinction that decides whether it answers or refuses.
member_count INT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- One row per character in a clan.
--
-- `member_key` is the game's own stable id for a character — a serial, a UUID,
-- whatever your game keeps — and it is what core stores as the member's identity.
-- It must survive a rename, because core reads a changed name as a rename and a
-- changed key as a different person.
--
-- `user_id` is the site account behind that character, resolved **by this
-- module**: the game↔site link table is yours, and a core that resolved it would
-- be core reading a module's table by name. NULL is the ordinary case — most
-- characters are not linked to an account.
CREATE TABLE IF NOT EXISTS examplegame_clan_members (
clan_id VARCHAR(64) NOT NULL,
member_key VARCHAR(64) NOT NULL,
display_name VARCHAR(120) NULL,
rank_label VARCHAR(60) NULL,
is_leader TINYINT(1) NOT NULL DEFAULT 0,
is_online TINYINT(1) NOT NULL DEFAULT 0,
user_id INT UNSIGNED NULL,
PRIMARY KEY (clan_id, member_key),
CONSTRAINT fk_examplegame_clan_members_clan
FOREIGN KEY (clan_id) REFERENCES examplegame_clans (external_id) ON DELETE CASCADE
);

View File

@@ -48,6 +48,9 @@ module.exports = function register(ctx, api) {
/* 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 */
@@ -74,9 +77,217 @@ module.exports = function register(ctx, api) {
// 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 },
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 `<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,
},
],
},
],
})
// ── 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.
@@ -93,6 +304,6 @@ module.exports = function register(ctx, api) {
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/world',
routes: 'public:/world,/clans',
})
}

View File

@@ -0,0 +1,106 @@
// ── SQL for the clan tables ───────────────────────────────────────────────
//
// The same `.db.js` / `.model.js` split as `model/worldStatus/`, for the same
// reason: the file with the queries in it has no branching to test, and the file
// with the branching in it has no database to stand up.
//
// Everything here reads this module's OWN tables. **Nothing in a module ever
// reads or writes `teams`, `team_members`, `team_forum_*` or any other core
// table** — core owns the Team, this module owns the clan, and the whole of the
// traffic between them is the provider next door answering three questions
// (MODULE_API.md §2.6's prefix rule, and §2.7).
const core = require('../../core')
const CLANS = 'examplegame_clans'
const MEMBERS = 'examplegame_clan_members'
/** Every clan the game has told us about. */
async function listClans() {
return core.query(
`SELECT external_id AS externalId, name, abbr, member_count AS memberCount
FROM ${CLANS}
ORDER BY name`,
)
}
/** One clan, or `undefined`. */
async function findClan(externalId) {
const rows = await core.query(
`SELECT external_id AS externalId, name, abbr, member_count AS memberCount
FROM ${CLANS}
WHERE external_id = ?`,
[externalId],
)
return rows[0]
}
/**
* One clan's roster.
*
* Ordered so that a page rendering it directly does not have to sort: leaders
* first, then by name. Ordering in SQL rather than in the model is a judgement
* call and this is the case for it — the database is doing it on an index, and
* the alternative is every caller remembering to.
*/
async function listMembers(clanId) {
return core.query(
`SELECT member_key AS memberKey, display_name AS displayName, rank_label AS rankLabel,
is_leader AS isLeader, is_online AS isOnline, user_id AS userId
FROM ${MEMBERS}
WHERE clan_id = ?
ORDER BY is_leader DESC, display_name`,
[clanId],
)
}
/**
* Replace what we know about one clan, in one transaction-shaped pair of writes.
*
* Called by whatever ingests from your sidecar; here, by `boot.js`. Delete-then-
* insert rather than an upsert, because a roster is a SET and the members who
* left are as much a part of the update as the ones who joined — an upsert leaves
* departed characters on the roster forever, and core would keep syncing them
* into a Team as present members.
*/
async function replaceClan({ externalId, name, abbr, memberCount, members }) {
await core.query(
`INSERT INTO ${CLANS} (external_id, name, abbr, member_count, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE name = VALUES(name), abbr = VALUES(abbr),
member_count = VALUES(member_count), updated_at = CURRENT_TIMESTAMP`,
[externalId, name, abbr || null, memberCount],
)
await core.query(`DELETE FROM ${MEMBERS} WHERE clan_id = ?`, [externalId])
for (const m of members) {
await core.query(
`INSERT INTO ${MEMBERS} (clan_id, member_key, display_name, rank_label, is_leader, is_online, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[externalId, m.memberKey, m.displayName || null, m.rankLabel || null,
m.leader ? 1 : 0, m.online ? 1 : 0, m.userId || null],
)
}
}
/**
* 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 }

View File

@@ -0,0 +1,334 @@
// ── The Team provider ─────────────────────────────────────────────────────
//
// A **Team** is a core platform entity: core owns the tables, the reconciler that
// keeps them in step, the access rules, the forum and the activity feed. What
// core does not own is the word. This game calls them clans, the next will call
// them companies, and a core that picked one would be publishing a noun it
// invented. So core asks, and this file is the whole of the answer.
//
// Registered from `index.js` with `api.registerTeamProvider(...)` (MODULE_API 1.6.0).
//
// ── Why this registration is unlike every other one ───────────────────────
//
// It is the first place **core calls the module and waits**. `registerRoutes`
// hands core a router to mount, `registerNav` hands it a row to draw,
// `registerPostHook` asks to be told when something happens. This hands over
// something core will pick up and call — from its reconciler, and (for
// `projectRoster`) on a request path with someone waiting on the other end.
//
// That inversion is what every rule below follows from:
//
// • **Core's budget is 10 seconds** and it is core's, not yours. Past it the
// call is a refusal, whatever your function eventually returns.
// • **Every method returns an ENVELOPE, never a bare array.** A rejected
// promise, a synchronous throw, a timeout, a non-object, a missing `ok`, a
// malformed row — core reads every one of them as `{ ok: false }`. There is
// no shape a failure can take that core reads as "zero teams", which is the
// entire argument for the envelope: a bare array has exactly one such shape,
// `[]`, and it is the one a module returns while its sidecar is connecting.
// • **Refusing is normal.** `{ ok: false }` is an ordinary answer and not an
// error you failed to handle. Core keeps the projection it already has,
// records your reason and shows it to an operator. Nothing empties.
// • **`projectRoster` is the exception, and it fails CLOSED** — see it below.
//
// ── The one that is easy to get wrong ─────────────────────────────────────
//
// Answering `{ ok: true, teams: [] }` because the game is unreachable. It reads
// as "this deployment has no clans", which is an authoritative statement, and core
// acts on authoritative statements: it archives Teams that have stopped existing
// and departs members who have left. A cold start would empty every roster on the
// site, and the module would have done it by being helpful.
//
// So the guard is the first line of three of the four methods, and it is
// deliberately conservative: an unreachable game refuses, even though the tables
// below still hold a perfectly readable snapshot. Core cannot tell a snapshot
// five minutes old from one five days old, and it makes destructive decisions
// from a complete answer.
const core = require('../../core')
const db = require('./clanProvider.db')
const settings = require('./clanSettings')
const worldStatus = require('../worldStatus/worldStatus.model')
const log = core.logger('clans')
/** A refusal, in the shape core reads. */
const refuse = (reason) => ({ ok: false, reason })
/**
* Is what these tables hold current enough to answer with?
*
* The template has no sidecar, so it asks the freshness the rest of it already
* tracks: if nothing has reported in longer than the world-status window, the
* clan tables are a snapshot of unknown age. In a real module this is "is my
* sidecar socket connected", asked of the socket rather than of a status column —
* a process that has just started has not transitioned yet, so a persisted
* `connected` can be left over from the last run.
*/
async function gameIsReachable() {
const status = await worldStatus.getPublicStatus()
if (status.stale) return { ok: false, reason: 'the game has not reported recently; clan data may be stale' }
if (!status.online) return { ok: false, reason: 'the game is offline' }
return { ok: true }
}
/**
* `getTeams()` — every clan this deployment has.
*
* `externalId` is the game's own stable id, and choosing it is the one genuinely
* load-bearing decision in this file. **It must survive a rename**: core reads a
* known id with a new name as a rename and keeps the Team, its forum and its
* history; it reads an unknown id as a new Team and archives the old one. Handing
* over the clan's NAME as its id turns every rename into "the clan was deleted
* and a different one appeared", taking the forum with it.
*
* `meta` is an opaque object core stores and displays and never branches on. It
* is how a concept core has no word for — an alliance, a faction, a season —
* reaches a Team page without core acquiring an opinion about it.
*/
async function getTeams() {
const ready = await gameIsReachable()
if (!ready.ok) return refuse(ready.reason)
try {
const rows = await db.listClans()
return {
ok: true,
// `complete: true` says "this is every clan there is", which is what
// licenses core to archive the ones missing from it. A module that can only
// answer about some of them — a paged source, a partial cache — must leave
// it off, and core then adds and updates without ever archiving.
complete: true,
teams: rows.map((row) => ({
externalId: String(row.externalId),
name: row.name,
abbr: row.abbr || null,
meta: null,
})),
}
} catch (err) {
// The catch is not decoration. An unhandled rejection here would reach core's
// reconciler as a rejected promise, which it reads as a refusal anyway — but
// then nothing has logged your side of it, and the operator sees a Team sync
// that stopped with core blamed for it.
log.warn('getTeams failed', { message: err.message })
return refuse(`clan list unreadable: ${err.message}`)
}
}
/**
* `getTeamMembers(externalId)` — one clan's roster.
*
* **An empty roster is refused unless the game says the clan is empty.** The
* clan row and its members arrive on separate frames in any real ingest, so there
* is a window — a clan created seconds ago, a website that connected between the
* two — where core would otherwise be told authoritatively that a 40-member clan
* has nobody in it, and would depart all forty. `member_count` is what
* distinguishes "empty" from "not here yet", and it is the only thing that can:
* this is why the schema keeps a count the rows cannot supply.
*/
async function getTeamMembers(externalId) {
const ready = await gameIsReachable()
if (!ready.ok) return refuse(ready.reason)
try {
const clan = await db.findClan(externalId)
if (!clan) return refuse(`clan ${externalId} is unknown`)
const rows = await db.listMembers(externalId)
if (!rows.length && clan.memberCount > 0) {
return refuse(`roster for clan ${externalId} has not arrived yet (the game says ${clan.memberCount})`)
}
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.memberKey,
displayName: row.displayName || null,
rankLabel: row.rankLabel || null,
leader: Boolean(row.isLeader),
online: Boolean(row.isOnline),
// Resolved by THIS module, from this module's own link table. Core does
// not resolve it and could not: the game↔site mapping is yours, and a
// core that read it would be core reading a module's table by name.
userId: row.userId || null,
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, message: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/**
* `getTeamLeaders(externalId)` — every member who leads, by member key.
*
* **Plural, and answer it plurally.** Core treats multiple leaders as the normal
* case; a provider that can only name one is a provider whose deployment has one,
* not a shape core assumes. Leadership is what core grants forum moderation and
* Team-management rights from, so a leader missing here is a leader locked out of
* their own clan's forum.
*
* Keys, not rows: core already has the roster and only needs to know which of
* those keys lead. A key that is not in the roster is ignored rather than
* inventing a member.
*/
async function getTeamLeaders(externalId) {
const ready = await gameIsReachable()
if (!ready.ok) return refuse(ready.reason)
try {
const clan = await db.findClan(externalId)
if (!clan) return refuse(`clan ${externalId} is unknown`)
const rows = await db.listMembers(externalId)
return { ok: true, leaders: rows.filter((r) => r.isLeader).map((r) => r.memberKey) }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, message: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* May this viewer see this clan's roster? The audience model itself.
*
* **One rule, two callers**, and keeping it that way is the point of the split.
* `projectRoster` below answers the question for CORE's roster; the module's own
* `/public/clans/:externalId` route answers it for its own page. A second copy of
* the rule is a copy that drifts, and the drift is silent in the direction that
* matters — the page publishing what core is withholding.
*
* `viewer` is `{ userId, role }` or `null` for an anonymous caller. Core never
* hands over the `users` row, which would make every column of that table part of
* the contract.
*
* Throws rather than guessing when it cannot decide; both callers treat a throw
* as "withhold".
*/
async function rosterVisibleTo(externalId, viewer) {
const audience = await settings.getRosterAudience()
if (audience === 'public') return true
// **Anonymous is an ANSWER, not a failed lookup.** Core hands over `null` for a
// viewer with no session, and treating that as "I could not work out who this
// is" would refuse — serving an empty roster to every visitor on a deployment
// whose clans are public.
if (!viewer) return false
if (audience === 'staff') return viewer.role === 'admin' || viewer.role === 'moderator'
// `'members'`: someone whose account is behind a character in this clan.
// Resolved from this module's own roster, the only place that mapping exists.
const roster = await db.listMembers(externalId)
return roster.some((r) => r.userId && r.userId === viewer.userId)
}
/**
* `projectRoster(externalId, members, viewer)` — who may see this roster.
*
* Optional, and the only method core calls on a REQUEST path. Core holds the
* roster and its public shape; the question that is yours is *who is allowed to
* look*, because the audience model is yours and core does not have one.
*
* **This one fails CLOSED, and the asymmetry is the point.** For the other three,
* an unanswered call must change nothing — core keeps what it has. For this one,
* "keep what you have" means serving the roster unprojected to whoever asked,
* which is a leak. So core distinguishes two refusals, and you get the right one
* without doing anything:
*
* • **no provider, or no `projectRoster`** — there is no audience model to
* consult and nothing is being withheld, so core serves the roster whole at
* its own public shape. That is what makes this member genuinely optional:
* omit it and a deployment with no rungs of its own still renders.
* • **a `projectRoster` that refused, threw, timed out or answered malformed**
* — core serves an EMPTY roster and says so (`projected: false`,
* `projectionUnavailable: true`). You said you had an opinion and then did
* not give it.
*
* **Note what it does not gate on: whether the game is reachable.** Visibility is
* a question about this deployment's configuration, not about the game — and
* refusing here because a socket is down would blank a public roster every time
* the game restarted.
*
* **Withhold rows; do not strip fields.** Core's public roster shape already
* omits the member key and the site account id, so there is nothing here to
* redact. Return every key or none — and "every key or none" is the honest
* translation of an audience model that is a property of the FEATURE rather than
* of a member.
*/
async function projectRoster(externalId, members, viewer) {
try {
const visible = await rosterVisibleTo(externalId, viewer)
return { ok: true, members: visible ? members.map((m) => m.member_key) : [] }
} catch (err) {
// Refusing is what withholds the roster. The tempting alternative — return
// every key, because the lookup failed and the rows are right there —
// publishes a roster an operator may have gated to staff.
log.warn('projectRoster could not resolve visibility; withholding the roster', {
externalId, message: err.message,
})
return refuse(`visibility could not be resolved: ${err.message}`)
}
}
// Where core should point a link at a clan.
//
// **Data, not a method**, and the fifth member of the provider. Core cannot work
// this out for itself and is not supposed to: Teams have no core surface, so the
// module that owns the vocabulary owns the page, and the one thing core needs
// back is where that page lives. A notification email about a forum reply that
// cannot take you to the thread is most of the way to useless.
//
// Core substitutes `{externalId}` and `{slug}` and does nothing else with it. A
// **relative path only** — a template naming its own host is refused at
// registration, protocol-relative `//host/x` with it, because there is no reason
// for a module to redirect the site's outbound mail.
//
// It must match the route `client/src/entry.jsx` registers, and nothing checks
// that for you across the two halves. Omit the member and the deployment loses
// clickable links in Team notification email; omit the ROUTE and it gets links to
// a page that does not exist, which is worse.
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 = {
getTeams,
getTeamMembers,
getTeamLeaders,
projectRoster,
rosterVisibleTo,
pageUrlTemplate,
gameIsReachable,
listClanMemberUserIds,
}

View File

@@ -0,0 +1,22 @@
// ── Who may see a roster ──────────────────────────────────────────────────
//
// The audience model, in its own file because it is its own thing: **core has no
// audience model at all**, does not know what your rungs are called, and cannot
// invent one — which is the entire reason `projectRoster` exists. A module that
// has no such model omits that method and core serves rosters whole; a module
// that has one answers with it.
//
// A constant here, and an async function returning it, because in a real module
// this reads an operator setting — whether rosters are public is a deployment's
// decision, not a module author's. Keeping the read behind a function is also
// what makes the rule testable: the provider's fail-closed path is only reachable
// if the audience lookup can fail, and a bare constant can never fail.
/** `'public'` · `'members'` (accounts behind a character in the clan) · `'staff'`. */
const ROSTER_AUDIENCE = 'public'
async function getRosterAudience() {
return ROSTER_AUDIENCE
}
module.exports = { getRosterAudience, ROSTER_AUDIENCE }

View File

@@ -0,0 +1,83 @@
// ── The module's own view of its clans ────────────────────────────────────
//
// What `/api/v1/public/clans` serves. Separate from `clanProvider.model.js`
// because the two answer to different consumers: the provider answers CORE, in
// core's vocabulary, under core's envelope contract; this answers this module's
// own page, in the game's vocabulary, under the ordinary rules of an HTTP route.
//
// **They share the audience rule and nothing else.** `rosterVisibleTo` lives in
// the provider and is imported here, because a second copy is a copy that drifts
// — and it drifts in the direction that matters, this page publishing a roster
// core is withholding.
const clanProvider = require('./clanProvider.model')
const db = require('./clanProvider.db')
/**
* Every clan, with its size and nothing else.
*
* **A list is not a sync, so this does not refuse.** The provider's guard exists
* because core makes destructive decisions from a complete answer; a page makes
* none. An unreachable game here means the list is as old as it is, and saying so
* is `stale` — the same shape `worldStatus` already answers with, for the same
* reason.
*/
async function listPublic() {
const [rows, reachable] = await Promise.all([db.listClans(), clanProvider.gameIsReachable()])
return {
stale: !reachable.ok,
clans: rows.map((row) => ({
externalId: String(row.externalId),
name: row.name,
abbr: row.abbr || null,
memberCount: Number(row.memberCount) || 0,
})),
}
}
/**
* One clan and its roster, or `null`.
*
* **What is deliberately not here: `memberKey` and `userId`.** Both are in the
* tables and both go to core on the provider's envelope, because core needs an
* identity to reconcile against and an account to notify. Neither belongs on a
* public page: the member key is the game's internal handle for a character, and
* the account id maps a character to a person. Core's own public roster withholds
* both whatever `projectRoster` answers — a module route that published them
* would route around its own visibility rules while looking like it respected
* them.
*/
async function getPublic(externalId, viewer = null) {
const clan = await db.findClan(externalId)
if (!clan) return null
let members = []
let projected = true
try {
if (await clanProvider.rosterVisibleTo(externalId, viewer)) {
members = (await db.listMembers(externalId)).map((row) => ({
displayName: row.displayName || null,
rankLabel: row.rankLabel || null,
leader: Boolean(row.isLeader),
online: Boolean(row.isOnline),
}))
}
} catch {
// Withhold, exactly as the provider does. `projected: false` says which of
// the three reasons an empty roster has — no members, an audience that
// excludes you, or a question nobody could answer — and a page that cannot
// tell them apart will report the last as the first.
projected = false
}
return {
externalId: String(clan.externalId),
name: clan.name,
abbr: clan.abbr || null,
memberCount: Number(clan.memberCount) || 0,
projected,
members,
}
}
module.exports = { listPublic, getPublic }

View File

@@ -0,0 +1,51 @@
// ── Public · Clans — the handlers ─────────────────────────────────────────
//
// Thin, like `world.controller.js`, and for the same reason: everything worth
// testing is in the model, which needs no express and no database to test.
//
// The one thing these two do differently is read the caller.
// `core.auth.getUserFromRequest` is READ-ONLY access to who is asking — minting a
// session is core's job, and a module that needs an identity needs to read one,
// never to issue one. It is awaited and it never throws for an anonymous caller;
// it answers `null`, which is an answer the model expects.
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const log = core.logger('clans')
/**
* The viewer core's contract describes: `{ userId, role }`, or `null`.
*
* Built here rather than passed as a request, so the model takes the same shape
* core hands `projectRoster` and one audience rule can serve both. Handing a
* model the whole `req` is what makes a rule impossible to reuse from a call that
* has no request — and the provider's call has none.
*/
async function viewerFrom(req) {
const user = await core.auth.getUserFromRequest(req)
return user ? { userId: user.id, role: user.role } : null
}
async function list(req, res) {
try {
res.json(await clans.listPublic())
} catch (err) {
log.error('failed to list clans', { error: err.message })
res.status(500).json({ error: 'Failed to list clans' })
}
}
async function detail(req, res) {
try {
const clan = await clans.getPublic(req.params.externalId, await viewerFrom(req))
if (!clan) return res.status(404).json({ error: 'No such clan' })
return res.json(clan)
} catch (err) {
log.error('failed to read clan', { externalId: req.params.externalId, error: err.message })
return res.status(500).json({ error: 'Failed to read clan' })
}
}
module.exports = { list, detail }

View File

@@ -0,0 +1,52 @@
// ── Public · Clans ────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/public/clans`. The module's own surface for the things
// core calls Teams — the list and one clan's roster, in this game's vocabulary,
// served from this module's tables.
//
// ── Why the prefix is `/clans` and could not be `/teams` ──────────────────
//
// **Core mounts `/api/v1/public/teams` itself.** Teams are a core primitive, so
// core answers the platform-level questions about them; what this module adds is
// the same clans in its own words, with the fields core has no schema for. The
// loader would refuse `/teams` outright at registration — a prefix collision it
// CAN see, unlike the tier-root routes `world.router.js` warns about — so the
// failure here is loud, immediate, and a boot that never happens.
//
// Which raises the question worth answering before you copy this: **does your
// module need this router at all?** Core already serves `/public/teams` and
// `/public/teams/:slug/roster`, projected through your `projectRoster`. A module
// wants its own only when it has something core does not model — here the game's
// rank labels and who is online, which are this game's ideas and not Teams. If
// what you would serve is what core already serves, do not.
const core = require('../../core')
const express = core.express
const clans = require('./clans.controller')
const { siteMode } = core.middleware
const clansRouter = express.Router()
clansRouter.get(
'/',
// #swagger.tags = ['Public · Example Game']
// #swagger.summary = 'Every clan the game has reported'
// #swagger.description = 'The clans this deployment knows about, in the game’s own vocabulary. Core calls these Teams and serves its own view of them at `/public/teams`; this route adds what core has no schema for. Answers with an empty list rather than failing when the game is unreachable — the list is a page, not a sync.'
/* #swagger.responses[200] = { description: 'The clans', content: { "application/json": { schema: { $ref: "#/components/schemas/ExamplegameClanList" } } } } */
siteMode,
clans.list,
)
clansRouter.get(
'/:externalId',
// #swagger.tags = ['Public · Example Game']
// #swagger.summary = 'One clan and its roster'
// #swagger.description = 'A clan by the game’s own id, with the roster as the game reported it. This is the module’s unprojected view of its OWN data and it deliberately withholds the member key and any linked account id — the roster core serves at `/public/teams/{slug}/roster` is the one that runs through `projectRoster`, and a module route that published more than core’s would route around its own visibility rules.'
/* #swagger.responses[200] = { description: 'The clan', content: { "application/json": { schema: { $ref: "#/components/schemas/ExamplegameClan" } } } } */
/* #swagger.responses[404] = { description: 'No such clan', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
clans.detail,
)
module.exports = clansRouter

View File

@@ -19,13 +19,21 @@
// fragment is generated from — `npm run swagger` parses this file (§2.8). Two
// rules that cost this project real time:
//
// • swagger-autogen reads these as JavaScript literals it evaluates. It
// re-quotes `"` and a backtick to `'` first, so either one inside a
// single-quoted description ends the string early — and when it cannot parse
// an annotation it drops that annotation, prints an error, and then reports
// success. Use a typographic apostrophe (’) in prose. `swaggerFragment.js`
// captures those errors and makes them fatal, which is the only reason you
// will find out.
// • swagger-autogen reads these as JavaScript literals it evaluates, so a
// QUOTE CHARACTER inside a single-quoted description ends the string early.
// Both `'` and `"` — use a typographic apostrophe (’) in prose, and rewrite
// a quoted phrase without the quotes. A backtick is fine: Markdown spans like
// `online: false` below survive verbatim, and the fragment shows them.
//
// **The failure is silent, and this is the part worth remembering.** It is
// not always a parse error you get told about. A `"` in the middle of a
// description truncates the value at that character — `'A "quoted" status'`
// becomes `A "` — while swagger-autogen prints `Success` in green and the
// error capture below sees nothing to capture, because nothing threw. The
// only signal is `npm run check:swagger` reporting the fragment stale, whose
// message will blame your routes. When it does and your routes did not
// change, look for a quote in an annotation before you look anywhere else.
// (Measured, not inferred: docs/modules/kit-acceptance.md, F5.)
// • A `\'` escape is valid JavaScript and wrong here: the annotation is never
// evaluated as JS by the reader, so Swagger UI renders the backslash.

View File

@@ -234,7 +234,17 @@ async function main() {
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
process.exit(1)
}
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
// Compared with line endings normalised, and that is not fussiness. A default
// Windows clone checks this file out as CRLF while the generator above writes
// LF, so a byte comparison failed on a PRISTINE template and told the reader
// their routes had changed — the kit's acceptance run lost ten minutes to it
// before reaching for `od -c` (docs/modules/kit-acceptance.md, F1). A check may
// only fail for the reason it names; this one names a diagnosis, so it has to
// be right about it. `.gitattributes` stops the CRLF from arriving in the first
// place, and this stops it mattering if it does.
const lf = (s) => s.replace(/\r\n/g, '\n')
if (lf(fs.readFileSync(FRAGMENT, 'utf8')) !== lf(json)) {
process.stderr.write(
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +

View File

@@ -0,0 +1,285 @@
// ── The near end of a call whose far end is your game ─────────────────────
//
// Every other file in this module reads its own tables. This one is different in
// kind: it is the only place that *asks the game to do something* and waits for
// an answer. That makes it the file chapter 5 is mostly about, and the file a
// reviewer should read hardest.
//
// **The transport is simulated and everything around it is not.** `deliver()` at
// the bottom is the one function you replace, and until you do, this module talks
// to a fake game that lives in this process. What is real is the shape: a
// declared timeout, an idempotency key that goes down the wire, a far end that
// executes a key at most once, a reply that says which of those two happened, and
// a call that answers rather than throwing. Those are the parts the event
// contract depends on, and simulating them is how the kit's CI can prove them at
// all — there is no game server on a runner.
//
// ── Why this file is not called `gameClient` ──────────────────────────────
//
// The website process never opens a connection to a game server (MODULE_API.md
// §2.7). It opens one to YOUR SIDECAR, which owns the socket to the game — see
// chapter 3. `test/noGameConnection.test.js` enforces the narrow, decidable half
// of that rule and its header names this exact filename as the one you allow when
// you replace `deliver()`:
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// So the moment this file grows a real transport, that test fails correctly, and
// the fix is one line in a file whose whole job is to name what may reach the
// network. Do not delete the check to make it pass.
//
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
//
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
// the budget expires it stops waiting and classifies the failure as **retry**,
// unconditionally, without asking the action — it cannot ask, the action is still
// awaiting a socket.
//
// So if core's deadline is shorter than this one, your action never gets to
// classify its own failure, and `{ ok: false, retry: false }` in your envelope is
// unreachable code. `budgetMs` must EXCEED the timeout of whatever the action
// talks to. This constant is exported so `config/eventActions.js` can be written
// against it rather than beside it, and so a test can assert the ordering — which
// it does, because the first module this project shipped got it the wrong way
// round and retried a verb it had explicitly refused.
//
// ── The at-most-once store belongs to the FAR end ─────────────────────────
//
// The simulation below keeps a map of keys it has already executed, and that map
// stands in for state on the game side, not for state here. A store on this side
// would be a module remembering what it sent, which answers nothing: the case
// that matters is the one where the command arrived, ran, and the acknowledgement
// was lost. Only the end that ran it can tell a retry from a repeat.
//
// Your sidecar and your plugin are where that store goes; chapter 4 is about
// building it. What this file owes the contract is narrower and is the thing
// modules get wrong: **pass the key through, unchanged, on every attempt.**
//
// ── `ask` and `send` are two functions because a key is not for a question ─
//
// This file offers `ask()` for a read and `send()` for a write, and the split is
// not tidiness — it is the correction that writing this template produced.
//
// The first draft had one function and every call carried a key, including the
// reads. That is wrong in a way that is quiet and total: the far end answers a
// key it has already executed with the ORIGINAL reply, so the second read of a
// value returns the first read's answer, and the third, and every one after it
// forever. The lease applied correctly, the game changed correctly, and this
// module could no longer see any of it — `read()` reported the baseline it had
// found before the run started and `inForce()` said nothing was held.
//
// **An idempotency key makes a COMMAND safe to repeat. It makes a QUESTION
// permanently stale.** Anything that only asks must go through `ask`.
//
// The rule for which commands need one is narrower than "all of them", too. A key
// is for a write whose repetition would be a second EFFECT — creating something,
// granting something, announcing something. A write that SETS a value to X is
// idempotent by its own nature: doing it twice is doing it once, and a key would
// only pin its reply. So the lease's `apply` and `restore` send no key, and the
// beacon verbs send core's.
const core = require('./core')
const log = core.logger('sidecar')
/**
* How long this client waits before giving up on the far end.
*
* Read the header. Every action in `config/eventActions.js` declares a `budgetMs`
* strictly greater than this, and `test/eventActions.test.js` asserts it.
*/
const TIMEOUT_MS = 12000
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
return { ok, status, data }
}
/**
* Ask the game a question.
*
* **Never carries an idempotency key**, and the reason is the header's last
* section: a key would make the far end answer every future call with the first
* one's answer. A read is cheap to repeat and there is nothing to make safe.
*/
async function ask(command, payload = {}) {
return roundTrip(command, payload, null)
}
/**
* Tell the game to do something and wait for its answer.
*
* @param {string} command
* @param {object} payload
* @param {object} [options]
* @param {string} [options.idempotencyKey] core's key for this step. Pass it
* through unchanged on every attempt. Omit it only for a write that is
* idempotent by its own nature — setting a value to X.
*/
async function send(command, payload = {}, { idempotencyKey = null } = {}) {
return roundTrip(command, payload, idempotencyKey)
}
/**
* One round trip, with this client's own deadline on it.
*
* **Never throws.** A module that let a socket failure escape into core's dispatch
* would be handing core an exception where the contract asked for a verdict — and
* core would classify it as a retry, which is the safe default but not always the
* right one. Answer, and let the action decide.
*/
async function roundTrip(command, payload, idempotencyKey) {
let timer = null
try {
return await Promise.race([
deliver(command, payload, idempotencyKey),
new Promise((resolve) => {
timer = setTimeout(() => resolve(reply(false, 'timeout')), TIMEOUT_MS)
}),
])
} catch (err) {
// Everything the far end can do to us, reduced to one verdict. The status is
// the thing an action's `classify` reads; the stack goes to the log, where a
// human can find it, and never into a reply core would store.
log.error('command failed', { command, error: err.message })
return reply(false, 'transport-error')
} finally {
if (timer) clearTimeout(timer)
}
}
// ══════════════════════════════════════════════════════════════════════════
// Everything below this line is the FAKE GAME. Delete it, and make `deliver()`
// one request to your sidecar carrying `command`, `payload` and the key.
// ══════════════════════════════════════════════════════════════════════════
/**
* The far end's at-most-once store: key → the reply the first attempt produced.
*
* On the game side this is persisted, because the case it exists for is a restart
* mid-run. Here it is a Map, and losing it on restart is exactly what makes
* `bootId` below meaningful.
*/
const executed = new Map()
/** What the fake game currently holds. A restart resets both. */
let bootId = `boot-${Date.now()}`
let gatherRate = 1.0
const lit = new Set()
/**
* Stand-in for one round trip to your sidecar.
*
* **REPLACE THIS FUNCTION AND NOTHING ELSE.** Its contract is the whole of what
* the rest of this module assumes:
*
* • it resolves rather than rejecting, with `{ ok, status, data }`;
* • it is given the idempotency key and sends it unchanged;
* • a key it has already executed answers with the ORIGINAL reply, restamped —
* never by running the command again;
* • a key it is still working on answers `busy`, which is transient by
* construction: the work is happening.
*/
async function deliver(command, payload, idempotencyKey) {
// **The far end refuses an unkeyed command it cannot safely repeat.** This is
// the game side protecting itself rather than trusting every caller to have
// read the contract, and it is worth building: the module that forgets to pass
// the key is not punished on the first attempt, which succeeds, but on the
// retry six weeks later that makes a second set of everything.
if (CREATES.has(command) && !idempotencyKey) return reply(false, 'no-idempotency-key')
if (idempotencyKey && executed.has(idempotencyKey)) {
// The whole point. A retry of a command whose acknowledgement was lost
// collects the answer the first attempt never delivered, and the world is
// changed once. Note it is the same `data`, not a fresh execution: a repeat
// that re-ran and returned a NEW serial would be two creatures in the world
// and one in core's ledger.
return { ...executed.get(idempotencyKey), repeat: true }
}
const answer = execute(command, payload)
if (answer.ok && idempotencyKey) executed.set(idempotencyKey, answer)
return answer
}
/**
* The commands whose repetition would be a second effect.
*
* Everything else here either asks a question or sets a value, and both are
* idempotent without help. Your game's list is the verbs that CREATE, GRANT or
* ANNOUNCE — the ones where doing it twice is visible in the world.
*/
const CREATES = new Set(['beacon.light'])
/** The fake game's verbs. Yours are your game's, and none of them are these. */
function execute(command, payload) {
switch (command) {
case 'beacon.light': {
const refs = []
for (let i = 0; i < payload.count; i += 1) {
const ref = `beacon:${payload.clanId}:${lit.size + 1}`
lit.add(ref)
refs.push(ref)
}
return reply(true, 'ok', { refs, bootId })
}
case 'beacon.douse': {
// Dousing something that is not lit is a SUCCESS. See the revert rule in
// `config/eventActions.js`: core records a resource before it is confirmed,
// so cleanup will ask about things that may never have existed, and a
// module must never have to tell "I removed it" from "it was not there".
for (const ref of payload.refs || []) lit.delete(ref)
return reply(true, 'ok', {})
}
case 'beacon.inForce':
// Which of these does the game still have? Answered from live state, which
// is why a restart (`lit` empty again) reports honestly rather than
// repeating what the caller already believed.
return reply(true, 'ok', { refs: (payload.refs || []).filter((r) => lit.has(r)) })
case 'rate.gather.read':
return reply(true, 'ok', { value: gatherRate })
case 'rate.gather.apply':
// `until` arrives and the far end is responsible for it WITHOUT being asked
// again. A real plugin arms a timer that restores the baseline when the
// deadline passes, and re-arms it at load if the value is in the world save.
// A far end that treats `until` as advisory has produced a lease that
// outlives an outage, which is the one thing a lease exists to prevent.
gatherRate = payload.value
return reply(true, 'ok', { value: gatherRate, until: payload.until })
case 'rate.gather.restore':
gatherRate = payload.value
return reply(true, 'ok', { value: gatherRate })
default:
// An unknown command is the far end's judgement that this will never work,
// and it is the one status an action turns into `retry: false`.
return reply(false, 'unknown-command')
}
}
/**
* Pretend the game restarted. Test seam, and the only reason it is exported.
*
* A real module learns this from its sidecar — a boot id on the feed that changed,
* which is how you tell a game restart from a sidecar reconnect. `boot.js` is
* where that watch lives, and `ctx.events.reconcile()` is what it calls.
*/
function simulateRestart() {
bootId = `boot-${Date.now()}-${Math.random().toString(16).slice(2)}`
executed.clear()
lit.clear()
gatherRate = 1.0
return bootId
}
/** The boot id the far end is currently reporting. */
function currentBootId() {
return bootId
}
module.exports = { TIMEOUT_MS, ask, send, simulateRestart, currentBootId }

View File

@@ -31,7 +31,7 @@ module.exports = {
tags: [
{
name: 'Public · Example Game',
description: 'Live world data, as last reported by the game server',
description: 'Live world data and the game’s clans, as last reported by the game server',
},
],
components: {
@@ -51,6 +51,55 @@ module.exports = {
},
},
},
ExamplegameClanList: {
type: 'object',
description: 'Every clan the game has reported (GET /public/clans).',
properties: {
stale: { type: 'boolean', example: false },
clans: {
type: 'array',
items: { $ref: '#/components/schemas/ExamplegameClanSummary' },
},
},
},
ExamplegameClanSummary: {
type: 'object',
properties: {
externalId: { type: 'string', example: 'clan-1' },
name: { type: 'string', example: 'The Gilded Company' },
abbr: { type: 'string', nullable: true, example: 'GC' },
memberCount: { type: 'integer', example: 3 },
},
},
ExamplegameClan: {
type: 'object',
description: 'One clan and the roster this viewer may see (GET /public/clans/{externalId}).',
properties: {
externalId: { type: 'string', example: 'clan-1' },
name: { type: 'string', example: 'The Gilded Company' },
abbr: { type: 'string', nullable: true, example: 'GC' },
memberCount: { type: 'integer', example: 3 },
projected: {
type: 'boolean',
description: 'Was the audience rule answered? False means the roster was withheld because the question could not be resolved — which is a different thing from a clan with no members.',
example: true,
},
members: {
type: 'array',
description: 'Deliberately carries no member key and no linked account id. Both exist and both go to core on the Team provider’s envelope; neither belongs on a public page.',
items: { $ref: '#/components/schemas/ExamplegameClanMember' },
},
},
},
ExamplegameClanMember: {
type: 'object',
properties: {
displayName: { type: 'string', nullable: true, example: 'Aldric' },
rankLabel: { type: 'string', nullable: true, example: 'Warlord' },
leader: { type: 'boolean', example: true },
online: { type: 'boolean', example: true },
},
},
},
},
}

View File

@@ -49,6 +49,15 @@ function fakeCtx(overrides = {}) {
return log
},
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.
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
// `ctx`, because an action is called BY core and is handed what it needs in
// the envelope. Only the module knows when the game restarted, so only the
// module can ask for the sweep.
events: { emit: spy(undefined), reconcile: spy(undefined) },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
@@ -85,7 +94,11 @@ function fakeCtx(overrides = {}) {
* an operator's install.
*/
function fakeApi() {
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {} }
const record = {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null,
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
@@ -97,6 +110,22 @@ function fakeApi() {
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook },
// `once` here is not the general rule restated — it is a DIFFERENT rule that
// happens to look the same. The others may not be called twice by ONE module;
// this one holds a single value across the whole deployment, so a second
// 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.
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 },
// The event contract (1.10.0). `once` on all four: a batch is a module's
// COMPLETE statement about what it declares, so a second call is a module
// changing its mind halfway through `register()` rather than adding to it.
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -0,0 +1,207 @@
// ── The Team provider, with no core and no database ───────────────────────
//
// The provider is the one part of a module that CORE calls, which makes it the
// one part whose failures reach further than its own pages: a wrong answer here
// is not a broken screen, it is core archiving Teams or departing members on your
// authority. So it gets the most tests in the template, and they are mostly about
// what it says when things are wrong.
//
// Everything is stubbed at the `.db.js` seam, the same way `worldStatus.test.js`
// does it. There is no database and no `ctx` — the provider only reaches core for
// its logger, and the one path that logs is exercised by installing a fake `ctx`.
const test = require('node:test')
const assert = require('node:assert')
const core = require('../core')
const db = require('../model/clans/clanProvider.db')
const settings = require('../model/clans/clanSettings')
const worldStatus = require('../model/worldStatus/worldStatus.model')
const provider = require('../model/clans/clanProvider.model')
const { fakeCtx } = require('./_fakes')
const CLAN = { externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 2 }
const ROSTER = [
{ memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', isLeader: 1, isOnline: 1, userId: 7 },
{ memberKey: 'char-002', displayName: 'Bryn', rankLabel: 'Member', isLeader: 0, isOnline: 0, userId: null },
]
/** Swap out the db seam and the world-status read for one test. */
function withGame({ online = true, stale = false, clan = CLAN, roster = ROSTER, throws = null }, fn) {
const real = {
getPublicStatus: worldStatus.getPublicStatus,
findClan: db.findClan,
listClans: db.listClans,
listMembers: db.listMembers,
}
core._reset()
core.init(fakeCtx())
worldStatus.getPublicStatus = async () => ({ online, stale, players: 0, worldName: 'Example World', updatedAt: null })
db.findClan = async () => { if (throws) throw new Error(throws); return clan }
db.listClans = async () => { if (throws) throw new Error(throws); return clan ? [clan] : [] }
db.listMembers = async () => { if (throws) throw new Error(throws); return roster }
return Promise.resolve(fn()).finally(() => {
Object.assign(worldStatus, { getPublicStatus: real.getPublicStatus })
Object.assign(db, { findClan: real.findClan, listClans: real.listClans, listMembers: real.listMembers })
core._reset()
})
}
test('getTeams answers an envelope, not an array', () =>
withGame({}, async () => {
const answer = await provider.getTeams()
assert.strictEqual(answer.ok, true)
assert.strictEqual(answer.complete, true)
assert.strictEqual(answer.teams[0].externalId, 'clan-1')
// A bare array has exactly one shape for "I cannot answer" — `[]` — and it is
// the same shape as "there are none". The envelope exists to keep those two
// apart, so the array must never be the return value itself.
assert.ok(!Array.isArray(answer))
}))
test('an unreachable game REFUSES rather than reporting no clans', () =>
withGame({ online: false }, async () => {
// The most important assertion in this file. `{ ok: true, teams: [] }` reads
// as an authoritative "this deployment has no clans", and core acts on
// authoritative answers: it archives the Teams that are missing from one. A
// cold start would empty the site.
for (const answer of [
await provider.getTeams(),
await provider.getTeamMembers('clan-1'),
await provider.getTeamLeaders('clan-1'),
]) {
assert.strictEqual(answer.ok, false)
assert.ok(answer.reason, 'a refusal without a reason is what an operator has to debug from')
assert.strictEqual(answer.teams, undefined)
}
}))
test('stale data refuses too, even though the rows are readable', () =>
withGame({ online: true, stale: true }, async () => {
// The tables still hold a perfectly good snapshot, which is what makes this
// tempting to get wrong. Core cannot tell a snapshot five minutes old from one
// five days old, so an answer it would act on must be current.
assert.strictEqual((await provider.getTeams()).ok, false)
}))
test('a database error is caught and becomes a refusal', () =>
withGame({ throws: 'connection lost' }, async () => {
// Core reads a rejected promise as a refusal anyway. Catching it is what puts
// the module's own name on the log line, instead of an operator seeing core
// blamed for a fault in a module.
const answer = await provider.getTeams()
assert.strictEqual(answer.ok, false)
assert.match(answer.reason, /connection lost/)
}))
test('an empty roster is refused when the game says the clan is not empty', () =>
withGame({ roster: [] }, async () => {
// The clan row and the roster arrive on separate frames in any real ingest, so
// there is a window where this module knows a clan exists and not who is in
// it. Answering "nobody" there would have core depart every member.
const answer = await provider.getTeamMembers('clan-1')
assert.strictEqual(answer.ok, false)
assert.match(answer.reason, /has not arrived/)
}))
test('a genuinely empty clan is answered, not refused', () =>
withGame({ clan: { ...CLAN, memberCount: 0 }, roster: [] }, async () => {
// The other half of the rule above, and the reason `member_count` is in the
// schema at all: without a count from the game there is no way to tell these
// two cases apart, and a provider that refuses both can never report a clan
// emptying.
const answer = await provider.getTeamMembers('clan-1')
assert.strictEqual(answer.ok, true)
assert.deepStrictEqual(answer.members, [])
}))
test('members carry the contract shape, with userId resolved by this module', () =>
withGame({}, async () => {
const { members } = await provider.getTeamMembers('clan-1')
assert.deepStrictEqual(members[0], {
memberKey: 'char-001',
displayName: 'Aldric',
rankLabel: 'Warlord',
leader: true,
online: true,
userId: 7,
})
// Not linked to a site account is the ordinary case and must be `null` rather
// than absent or `0`: core stores it, and `0` is a user id.
assert.strictEqual(members[1].userId, null)
}))
test('getTeamLeaders answers keys, plurally', () =>
withGame({ roster: [...ROSTER, { ...ROSTER[0], memberKey: 'char-003', isLeader: 1 }] }, async () => {
const answer = await provider.getTeamLeaders('clan-1')
assert.deepStrictEqual(answer.leaders, ['char-001', 'char-003'])
// Core grants forum moderation and Team management from this list, so a
// provider that can only name one leader locks the others out of their own
// clan.
assert.ok(answer.leaders.length > 1)
}))
test('projectRoster returns member keys the caller supplied, in core’s snake_case', () =>
withGame({}, async () => {
// Core hands back the rows as IT stores them — this is the module's own data
// coming home — so the key is `member_key` and not the `memberKey` the
// provider sent out. Reading the wrong one silently answers with a list of
// `undefined`, which core filters to nothing: an empty roster with `ok: true`.
const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null)
assert.deepStrictEqual(answer, { ok: true, members: ['char-001'] })
}))
test('projectRoster fails CLOSED when it cannot resolve the question', () =>
withGame({}, async () => {
// The asymmetry that matters. The other three methods refuse and core keeps
// what it has; this one refuses and core serves an EMPTY roster, because for a
// visibility question "keep what you have" means publishing it. So a provider
// that cannot answer must say so rather than falling back to "show everything".
const real = settings.getRosterAudience
settings.getRosterAudience = async () => { throw new Error('settings unreadable') }
try {
const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null)
assert.strictEqual(answer.ok, false)
// Not `{ ok: true, members: [...everything] }`, which is the tempting
// fallback — the rows are right there and the lookup is the only thing that
// failed. That publishes a roster an operator may have gated to staff.
assert.strictEqual(answer.members, undefined)
} finally {
settings.getRosterAudience = real
}
}))
test('a members-only audience withholds from an anonymous viewer and answers for one inside', () =>
withGame({}, async () => {
const real = settings.getRosterAudience
settings.getRosterAudience = async () => 'members'
try {
const rows = [{ member_key: 'char-001' }, { member_key: 'char-002' }]
// Anonymous is an ANSWER — `{ ok: true }` with nothing visible — and not a
// refusal. A provider that refuses here tells core its rule broke, and core
// reports the roster as unavailable rather than as private.
const anon = await provider.projectRoster('clan-1', rows, null)
assert.deepStrictEqual(anon, { ok: true, members: [] })
// Aldric's account, resolved from this module's own roster — the only place
// the game↔site mapping exists.
const inside = await provider.projectRoster('clan-1', rows, { userId: 7, role: 'user' })
assert.deepStrictEqual(inside.members, ['char-001', 'char-002'])
// All or none. The audience is a property of the FEATURE, not of a member;
// there is no configuration in which half a roster is public.
const outside = await provider.projectRoster('clan-1', rows, { userId: 99, role: 'user' })
assert.deepStrictEqual(outside.members, [])
} finally {
settings.getRosterAudience = real
}
}))
test('pageUrlTemplate is a relative path carrying the substitution core makes', () => {
// Core substitutes `{externalId}` and does nothing else with it. A template
// naming its own host is refused at registration — there is no reason for a
// module to redirect the site's outbound mail — and so is a protocol-relative
// `//host/x`.
assert.match(provider.pageUrlTemplate, /^\/[^/]/)
assert.ok(provider.pageUrlTemplate.includes('{externalId}'))
})

View File

@@ -71,6 +71,54 @@ test('registers both lifecycle hooks', () => {
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
})
test('registers a Team provider, with the three methods core requires', () => {
const { api } = register()
const provider = api.record.teamProvider
assert.ok(provider, 'no Team provider was registered')
// All three are required. A provider that could list Teams but not their
// members would leave core holding Teams it can never populate — which is not
// the same as a call that fails, and core refuses the registration rather than
// discovering it at the first sync.
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `provider.${method} is missing`)
}
// Optional, and asserted because THIS module supplies them. Delete the members
// and delete these two lines with them; do not leave a test claiming a contract
// you no longer meet.
assert.strictEqual(typeof provider.projectRoster, 'function')
assert.strictEqual(typeof provider.pageUrlTemplate, 'string')
})
test('the Team provider is claimed, not called, at registration time', () => {
const ctx = fakeCtx()
const { api } = register(ctx)
// Registration may not touch the database (§2.2) and every provider method
// reads one. That is legal precisely because core does not call any of them
// until it reconciles, which is after `onBoot` — so holding the object is the
// whole of what happens here.
assert.deepStrictEqual(ctx.db.query.calls, [])
assert.ok(api.record.teamProvider)
})
test('pageUrlTemplate points at a route this module registers', () => {
const { api } = register()
const template = api.record.teamProvider.pageUrlTemplate
// A relative path — core refuses one naming its own host, since there is no
// reason for a module to redirect the site's outbound mail.
assert.match(template, /^\/[^/]/)
assert.ok(template.includes('{externalId}'), 'core substitutes {externalId}; nothing else is a link')
// And it must be under this module's own namespace, because that is where core
// mounts every route this module registers. Nothing checks the two halves
// against each other — the client registers the route, the server declares the
// link — so this is the seam where a wrong answer becomes mail linking at a 404.
assert.ok(template.startsWith(`/${manifest.id}/`), 'the template is not under this module’s route namespace')
})
test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
@@ -82,3 +130,224 @@ test('the manifest declares what the loader requires', () => {
// 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')
})
// ── 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, [])
})
test('the four event declarations are registered, each exactly once', () => {
const { api } = register()
// Every one of the four is optional (§F), so this asserts what THIS module
// chose rather than what core requires. What it is really checking is that
// `index.js` still hands core the arrays `config/eventActions.js` exports —
// the failure it catches is a rename on one side and not the other, which
// costs a deployment a capability with nothing red anywhere.
assert.ok(Array.isArray(api.record.eventBudgets))
assert.ok(Array.isArray(api.record.eventOptionSources))
assert.ok(Array.isArray(api.record.eventLeases))
assert.ok(Array.isArray(api.record.eventActions))
// `once` on all four: a batch is a module's COMPLETE statement about what it
// declares. `fakeApi` throws on a second call, so registering twice fails here.
assert.ok(api.record.eventActions.length > 0)
})
test('an action may only spend a budget dimension some module declared', () => {
const { api } = register()
// Core refuses a `cost()` naming an undeclared dimension at save, at the dry
// run and at dispatch, because the fix is a module's declaration rather than a
// deployment's cap. This module declares everything it spends, so the check is
// local; a module spending another module's dimension would have to loosen it.
const declared = new Set(api.record.eventBudgets.map((b) => b.id))
for (const action of api.record.eventActions) {
const sample = Object.fromEntries(action.params.map((p) => [p.name, p.example]))
for (const dimension of Object.keys(action.cost(sample))) {
assert.ok(declared.has(dimension), `${action.id} spends undeclared ${dimension}`)
}
}
})
test('a game restart asks core to reconcile, and a first sighting does not', () => {
const boot = require('../boot')
const sidecar = require('../sidecarClient')
const ctx = fakeCtx()
require('../core')._reset()
require('../core').init(ctx)
// First observation is not a restart. Treating it as one would sweep every
// ledgered resource on every website deploy, for no news.
boot.checkForRestart()
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
// Same boot id: still nothing.
boot.checkForRestart()
assert.deepStrictEqual(ctx.events.reconcile.calls, [])
// The game came back as something else. Core cannot see this and must be told.
sidecar.simulateRestart()
boot.checkForRestart()
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
// And only once for one restart.
boot.checkForRestart()
assert.strictEqual(ctx.events.reconcile.calls.length, 1)
})

View File

@@ -0,0 +1,399 @@
// ── The four traps, as tests ──────────────────────────────────────────────
//
// `config/eventActions.js` marks four rules TRAP 1..4 and says all four are
// invisible until an outage. That is a bad property for a rule to have and a good
// reason to test it, because the alternative is finding out in production once.
//
// Each of the four gets a test that FAILS if the rule is broken — not one that
// asserts the current value. Trap 1 in particular is asserted as an inequality
// between two constants that live in different files, which is the only form that
// survives somebody tuning the client.
//
// Everything here runs without core, without a database and without a game: the
// declarations are plain objects and the client's transport is simulated. What it
// cannot prove is that core accepts these declarations — a fake that agreed with
// a mistake is exactly how a module ships green and refuses to load. That check
// is `checkCoreApi.js` plus a run against a real core, and the kit's
// `ci/core-ref.json` is where its date is written down.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
const core = require('../core')
core.init(fakeCtx())
/* eslint-disable global-require */
const events = require('../config/eventActions')
const sidecar = require('../sidecarClient')
const clanDb = require('../model/clans/clanProvider.db')
/* eslint-enable global-require */
// Stubbed at the `.db.js` seam, the same way `clanProvider.test.js` does it:
// there is no database here, and an action's `verify` reads one.
const CLANS = [{ externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 3 }]
clanDb.listClans = async () => CLANS
clanDb.findClan = async (externalId) => CLANS.find((c) => c.externalId === externalId)
const action = events.ACTIONS.find((a) => a.id === 'examplegame.beacon.light')
const lease = events.LEASES.find((l) => l.id === 'examplegame.rate.gather')
/** A fresh key per call, the way core's is a function of a step's identity. */
let keyCounter = 0
const nextKey = () => `test-key-${(keyCounter += 1)}`
// ══ Shape ═════════════════════════════════════════════════════════════════
test('every declaration is namespaced with the module id', () => {
const ids = [
...events.BUDGETS.map((b) => b.id),
...events.OPTION_SOURCES.map((s) => s.id),
...events.LEASES.map((l) => l.id),
...events.ACTIONS.map((a) => a.id),
]
for (const id of ids) {
assert.ok(id.startsWith('examplegame.'), `${id} is not namespaced — core refuses it`)
}
})
test('every param declares an example, optional ones included', () => {
for (const a of events.ACTIONS) {
for (const p of a.params) {
assert.ok(p.example !== undefined, `${a.id}.${p.name} has no example`)
}
}
})
test("an action's `source` names an option source this module registers", () => {
// Core resolves this across every module, so a source another module owns is
// legal. Checking the local case is still worth doing: a typo in your own id is
// the overwhelmingly likely mistake, and it degrades the field to free text in
// silence rather than failing.
const sources = new Set(events.OPTION_SOURCES.map((s) => s.id))
for (const a of events.ACTIONS) {
for (const p of a.params) {
if (p.source && p.source.startsWith('examplegame.')) {
assert.ok(sources.has(p.source), `${a.id}.${p.name} names an unregistered source`)
}
}
}
})
test("an action that ledgers declares `revert`", () => {
for (const a of events.ACTIONS) {
if (a.reversible === 'ledger') {
assert.strictEqual(typeof a.revert, 'function', `${a.id} ledgers but cannot undo`)
}
}
})
// ══ TRAP 1 — the failure default, and the budget that makes it reachable ══
test('TRAP 1: budgetMs strictly exceeds the client timeout', () => {
// The inequality, not the value. Core classifies a budget timeout as a retry
// WITHOUT asking the action, so if this ever inverts, every `retry: false`
// below becomes unreachable code and nothing else in this suite would notice —
// the action would still return it, and core would still retry.
for (const a of events.ACTIONS) {
assert.ok(
a.budgetMs > sidecar.TIMEOUT_MS,
`${a.id}: budgetMs ${a.budgetMs} must exceed the client's ${sidecar.TIMEOUT_MS}`,
)
}
})
test('TRAP 1: an unrecognised failure is a RETRY', () => {
// The default direction. A module that listed the transient statuses and
// defaulted the rest to terminal would stop retrying the moment its sidecar
// grew a status nobody here had heard of.
const verdict = events.classify({ ok: false, status: 'something-new' })
assert.strictEqual(verdict.ok, false)
assert.strictEqual(verdict.retry, true)
})
test('TRAP 1: a timeout is a retry and an unknown command is not', () => {
assert.strictEqual(events.classify({ ok: false, status: 'timeout' }).retry, true)
assert.strictEqual(events.classify({ ok: false, status: 'unknown-command' }).retry, false)
})
test('a refusal says WHY, in the field core actually reads', async () => {
// Core's dispatcher carries `error` off a failure envelope and nothing else.
// A reason under any other name — `detail`, `message`, `reason` — is dropped in
// silence and the operator sees "<action id> refused". This test exists because
// the first draft of this template used `detail`, on the strength of the one
// place `EVENTS.md` mentions it, and every refusal it produced was anonymous.
const answer = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 0 },
})
assert.strictEqual(answer.ok, false)
assert.strictEqual(typeof answer.error, 'string')
assert.ok(answer.error.length > 0, 'a refusal with no `error` tells an author nothing')
// And the same for a failure this module classified rather than authored.
assert.strictEqual(typeof events.classify({ ok: false, status: 'timeout' }).error, 'string')
})
test('TRAP 1: a refusal the second attempt would repeat says retry: false', async () => {
// The arm the inequality above exists to keep reachable. A count core would
// hand back identically on a retry is not worth a second round trip.
const answer = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 9999 },
})
assert.strictEqual(answer.ok, false)
assert.strictEqual(answer.retry, false)
})
// ══ TRAP 2 — the idempotency passthrough ═════════════════════════════════
test("TRAP 2: perform passes core's key through, unchanged", async () => {
const seen = []
const realSend = sidecar.send
// Wrapping the module's own client rather than a fake one: what is under test
// is that the key reaches the call, and a fake client would only prove the
// test passed it to itself.
sidecar.send = async (command, payload, options) => {
seen.push(options && options.idempotencyKey)
return realSend(command, payload, options)
}
try {
const key = nextKey()
await action.perform({ idempotencyKey: key, params: { clanId: 'clan-1', count: 2 } })
assert.deepStrictEqual(seen, [key], 'the key core gave us is not the key that went down the wire')
} finally {
sidecar.send = realSend
}
})
test('TRAP 2: a retry under the same key changes the world once', async () => {
// The property the passthrough buys, stated as behaviour rather than as a
// parameter. Two attempts, one key: the second collects the answer the first
// already produced, and the refs are identical.
const key = nextKey()
const params = { clanId: 'clan-1', count: 3 }
const first = await action.perform({ idempotencyKey: key, params })
const second = await action.perform({ idempotencyKey: key, params })
assert.strictEqual(first.ok, true)
assert.strictEqual(second.ok, true)
assert.deepStrictEqual(
second.resources.map((r) => r.ref),
first.resources.map((r) => r.ref),
'the repeat produced NEW refs — that is two sets of beacons and one ledger',
)
})
test('TRAP 2: a fresh key on the same params is a second, real change', async () => {
// The control for the test above. If this passed identically, the far end
// would be deduplicating on the params rather than on the key, and the test
// above would be proving nothing.
const params = { clanId: 'clan-1', count: 3 }
const first = await action.perform({ idempotencyKey: nextKey(), params })
const second = await action.perform({ idempotencyKey: nextKey(), params })
assert.notDeepStrictEqual(
second.resources.map((r) => r.ref),
first.resources.map((r) => r.ref),
)
})
test('TRAP 2: a call with no key is refused rather than sent', async () => {
const answer = await sidecar.send('beacon.light', { clanId: 'clan-1', count: 1 }, {})
assert.strictEqual(answer.ok, false)
assert.strictEqual(answer.status, 'no-idempotency-key')
})
// ══ TRAP 3 — core records a resource BEFORE it is confirmed ══════════════
test('TRAP 3: reverting something that was never made is a SUCCESS', async () => {
const answer = await action.revert({
idempotencyKey: nextKey(),
resources: [{ kind: 'beacon', ref: 'beacon:never-existed:1' }],
})
// The message avoids the words `from "..."` on purpose: `checkImports.js` is
// deliberately textual and reads that shape as an import specifier, prose or not.
assert.strictEqual(answer.ok, true, 'removing something absent must be a success')
})
test('TRAP 3: revert is idempotent — core may ask more than once', async () => {
const made = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 2 },
})
const first = await action.revert({ idempotencyKey: nextKey(), resources: made.resources })
const again = await action.revert({ idempotencyKey: nextKey(), resources: made.resources })
assert.strictEqual(first.ok, true)
assert.strictEqual(again.ok, true)
})
test('TRAP 3: revert is called with NO resources and only a key', async () => {
// The lost-answer case: core knows a dispatch went out under this key and never
// learned what it made. This module CAN answer it. One that cannot must say
// `{ ok: false }` and let a human see the row — never `{ ok: true }`, which is
// how a resource burns forever with the ledger reporting it cleaned up.
const answer = await action.revert({ idempotencyKey: nextKey(), resources: [] })
assert.strictEqual(answer.ok, true)
})
test('TRAP 3: reconcile reports what is gone and never guesses', async () => {
const made = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 2 },
})
const before = await action.reconcile({ resources: made.resources })
assert.strictEqual(before.ok, true)
assert.deepStrictEqual(before.inForce.sort(), made.resources.map((r) => r.ref).sort())
sidecar.simulateRestart()
const after = await action.reconcile({ resources: made.resources })
assert.strictEqual(after.ok, true)
assert.deepStrictEqual(after.inForce, [], 'a restart lost them; reconcile must say so')
})
// ══ TRAP 4 — the cost that is priced and never reconciled ════════════════
test('TRAP 4: cost counts what one invocation actually makes', async () => {
// The failure this catches is `() => ({ 'examplegame.beacons': 1 })`, which
// would pass every other test in this file and turn an operator's cap of 30
// into a cap of 750. Core prices `cost` before dispatch and NEVER reconciles it
// against the resources that come back, so nothing else can catch it.
const params = { clanId: 'clan-1', count: 7 }
const priced = action.cost(params)
const made = await action.perform({ idempotencyKey: nextKey(), params })
assert.strictEqual(
priced['examplegame.beacons'],
made.resources.length,
'the action declared a different number than it made — every cap on this dimension is a lie',
)
})
test('TRAP 4: cost only names dimensions this module declared', () => {
// A `cost()` naming an undeclared dimension is REFUSED at save, at the dry run
// and at dispatch, because the fix is a module's declaration rather than a
// deployment's cap. Cheaper to find here.
const declared = new Set(events.BUDGETS.map((b) => b.id))
for (const a of events.ACTIONS) {
const sample = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
for (const dimension of Object.keys(a.cost(sample))) {
assert.ok(declared.has(dimension), `${a.id} spends ${dimension}, which no module here declares`)
}
}
})
// ══ verify ════════════════════════════════════════════════════════════════
test('verify changes nothing', async () => {
const before = await action.reconcile({ resources: [] })
const dry = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'clan-1', count: 5 },
verify: true,
})
assert.strictEqual(dry.ok, true)
assert.strictEqual(dry.resources, undefined, 'a dry run must not report resources it did not make')
// Nothing was lit, so nothing new is in force. The assertion is weak on its own
// and strong beside the TRAP 3 reconcile test above, which proves the same call
// does see what `perform` makes.
const after = await action.reconcile({ resources: [] })
assert.deepStrictEqual(after.inForce, before.inForce)
})
test('verify answers honestly rather than always true', async () => {
const dry = await action.perform({
idempotencyKey: nextKey(),
params: { clanId: 'no-such-clan', count: 1 },
verify: true,
})
assert.strictEqual(dry.ok, false)
assert.strictEqual(dry.retry, false)
})
// ══ The lease ═════════════════════════════════════════════════════════════
test('a lease reads a baseline, holds a value, and gives it back', async () => {
sidecar.simulateRestart()
const baseline = await lease.read()
assert.strictEqual(baseline.ok, true)
assert.strictEqual(baseline.value, 1.0)
const until = new Date(Date.now() + 60_000)
assert.strictEqual((await lease.apply(2.5, until)).ok, true)
assert.strictEqual((await lease.read()).value, 2.5)
const back = await lease.restore(baseline.value, { expected: 2.5 })
assert.strictEqual(back.ok, true)
assert.strictEqual((await lease.read()).value, 1.0)
})
test('a lease reports DRIFT rather than overwriting what somebody changed', async () => {
sidecar.simulateRestart()
const baseline = await lease.read()
await lease.apply(3, new Date(Date.now() + 60_000))
// Somebody moved it by hand, mid-event.
await lease.apply(4, new Date(Date.now() + 60_000))
const back = await lease.restore(baseline.value, { expected: 3 })
assert.strictEqual(back.ok, true)
assert.strictEqual(back.drifted, true, 'restoring over a hand-edit silently is the bug')
assert.strictEqual(Number(back.value), 4)
})
test('inForce is a different question from read', async () => {
sidecar.simulateRestart()
// Nothing held: the live value is the default.
assert.strictEqual((await lease.inForce()).held, false)
await lease.apply(2, new Date(Date.now() + 60_000))
assert.strictEqual((await lease.inForce()).held, true)
// A restart takes the hold with it, and `inForce` is the only thing that says
// so — `read()` would answer 1.0, which is also what an un-held lease reads.
sidecar.simulateRestart()
assert.strictEqual((await lease.inForce()).held, false)
})
test('the lease declares a duration bound core can enforce', () => {
for (const l of events.LEASES) {
assert.ok(l.maxDurationMs > 0, `${l.id} has no duration bound`)
assert.strictEqual(typeof l.read, 'function')
assert.strictEqual(typeof l.apply, 'function')
assert.strictEqual(typeof l.restore, 'function')
}
})
// ══ The option source ═════════════════════════════════════════════════════
test('an option source answers from live data', async () => {
const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans')
const options = await source.resolve()
assert.ok(Array.isArray(options))
assert.strictEqual(options.length, CLANS.length)
for (const option of options) {
assert.strictEqual(typeof option.value, 'string')
assert.strictEqual(typeof option.label, 'string')
}
})
test('an option source that fails degrades rather than raising', async () => {
// Core turns a refusal into a free-text field with a warning; it never blocks
// the authoring form. A resolver that threw would be a screen this module's
// outage takes away, for a field whose value the operator very often knows.
const real = clanDb.listClans
clanDb.listClans = async () => { throw new Error('database is down') }
try {
const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans')
assert.deepStrictEqual(await source.resolve(), [])
} finally {
clanDb.listClans = real
}
})

View File

@@ -0,0 +1,124 @@
// ── §2.7's last rule, given the CI it does not have ───────────────────────
//
// `book/02-website-module.md` is explicit that "the website process never opens a
// connection to a game server" is the **one boundary rule with no CI behind it**:
// an outbound socket is not statically detectable the way an internal `require`
// is, so in general the rule is held up by review and by understanding it.
//
// True of the general case, and not a reason to check nothing. A module can state
// a narrower, completely decidable property about **itself**, and this one says:
// the shipped server half references no networking primitive at all. Everything
// it knows arrives from its own tables, which its sidecar writes.
//
// Adopted from the kit's acceptance run (`docs/modules/kit-acceptance.md`), where
// a reader building a Rust module wrote it unprompted after reading that the rule
// had no CI — and observed that for Rust in particular, which ships RCON over
// WebSocket, `new WebSocket(rconUrl)` in `boot.js` is about ten lines away.
//
// ── WHEN YOU ADD A SIDECAR CLIENT, NARROW THIS. DO NOT DELETE IT. ─────────
//
// Talking to *your sidecar* over HTTP is the expected shape and is not what §2.7
// forbids — the rule is about the **game server**. So the moment your module
// grows, say, `server/sidecarClient.js`, this test starts failing correctly and
// the fix is to allow that one file:
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// and keep the rest of the tree under the ban. What you get for that is a test
// that names the *one* file allowed to reach the network — which is exactly the
// file a reviewer should be reading closely, and exactly the place a game-server
// URL would appear if the rule were ever broken.
//
// Scope: SHIPPED code only. `test/` and `scripts/` never run inside core's process.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const SERVER_ROOT = path.resolve(__dirname, '..')
const NOT_SHIPPED = new Set(['test', 'scripts', 'node_modules', 'swagger'])
/** Every shipped `.js` file under `server/`. */
function shippedFiles(dir = SERVER_ROOT, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (dir === SERVER_ROOT && NOT_SHIPPED.has(entry.name)) continue
if (entry.name === 'node_modules') continue
shippedFiles(path.join(dir, entry.name), out)
} else if (entry.isFile() && entry.name.endsWith('.js')) {
out.push(path.join(dir, entry.name))
}
}
return out
}
/**
* Blank comments, so prose ABOUT the rule does not trip the rule.
*
* This file is itself the proof that it is needed: the paragraphs above say
* "WebSocket" several times. `scripts/checkImports.js` documents hitting exactly
* this on its own documentation, and it is the third time in this project's
* history that a boundary check has failed on the text explaining it.
*
* Blanked rather than deleted, so line numbers in a failure still point at the
* right line.
*/
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
.replace(/^[ \t]*\/\/.*$/gm, '')
}
// Each is a way a Node process opens a socket. Matched as identifiers, so a
// column named `websocket_url` inside a SQL string would not fire.
const NETWORKING = [
/\brequire\(\s*['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]\s*\)/,
/\bfrom\s+['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]/,
/\brequire\(\s*['"](?:ws|socket\.io-client|undici|axios|node-fetch|got)['"]\s*\)/,
/\bnew\s+WebSocket\b/,
/\bfetch\s*\(/,
/\bXMLHttpRequest\b/,
/\bEventSource\b/,
]
test('no shipped file references a networking primitive (§2.7)', () => {
const offenders = []
for (const file of shippedFiles()) {
const code = stripComments(fs.readFileSync(file, 'utf8'))
for (const pattern of NETWORKING) {
if (pattern.test(code)) {
offenders.push(`${path.relative(SERVER_ROOT, file)} matches ${pattern}`)
}
}
}
assert.deepStrictEqual(
offenders,
[],
'the website process must never open a connection to a game server. If this is ' +
'your sidecar client, allow that one file rather than removing the check — see ' +
`the header of this file.\n ${offenders.join('\n ')}`,
)
})
test('the check can actually fail — it is pointed at a real violation', () => {
// A check that has never been shown to fail is a check nobody knows the state
// of. This is the game-server dial the rule exists to stop.
const violation = "const socket = new WebSocket('ws://10.0.0.5:28016/' + rconPassword)"
assert.ok(
NETWORKING.some((p) => p.test(stripComments(violation))),
'the guard would not have caught a direct game-server dial',
)
})
test('prose describing the rule does not trip it', () => {
const prose = [
'// A game shipping RCON over WebSocket means a module COULD write',
"// const s = new WebSocket(url); require('net')",
'// in about ten lines. It must not.',
'const x = 1',
].join('\n')
for (const pattern of NETWORKING) {
assert.ok(!pattern.test(stripComments(prose)), `${pattern} fired on a comment`)
}
})

View File

@@ -1,5 +1,73 @@
{
"paths": {
"/api/v1/public/clans": {
"get": {
"tags": [
"Public · Example Game"
],
"summary": "Every clan the game has reported",
"description": "The clans this deployment knows about, in the game’s own vocabulary. Core calls these Teams and serves its own view of them at `/public/teams`; this route adds what core has no schema for. Answers with an empty list rather than failing when the game is unreachable — the list is a page, not a sync.",
"responses": {
"200": {
"description": "The clans",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExamplegameClanList"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/clans/{externalId}": {
"get": {
"tags": [
"Public · Example Game"
],
"summary": "One clan and its roster",
"description": "A clan by the game’s own id, with the roster as the game reported it. This is the module’s unprojected view of its OWN data and it deliberately withholds the member key and any linked account id — the roster core serves at `/public/teams/{slug}/roster` is the one that runs through `projectRoster`, and a module route that published more than core’s would route around its own visibility rules.",
"parameters": [
{
"name": "externalId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The clan",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExamplegameClan"
}
}
}
},
"404": {
"description": "No such clan",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/world/status": {
"get": {
"tags": [
@@ -28,7 +96,7 @@
"tags": [
{
"name": "Public · Example Game",
"description": "Live world data, as last reported by the game server"
"description": "Live world data and the game’s clans, as last reported by the game server"
}
],
"components": {
@@ -127,6 +195,300 @@
}
}
}
},
"ExamplegameClanList": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Every clan the game has reported (GET /public/clans)."
},
"properties": {
"type": "object",
"properties": {
"stale": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"clans": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/ExamplegameClanSummary"
}
}
}
}
}
}
},
"ExamplegameClanSummary": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "clan-1"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Gilded Company"
}
}
},
"abbr": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "GC"
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
}
}
}
}
},
"ExamplegameClan": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One clan and the roster this viewer may see (GET /public/clans/{externalId})."
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "clan-1"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Gilded Company"
}
}
},
"abbr": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "GC"
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
},
"projected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Was the audience rule answered? False means the roster was withheld because the question could not be resolved — which is a different thing from a clan with no members."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"members": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Deliberately carries no member key and no linked account id. Both exist and both go to core on the Team provider’s envelope; neither belongs on a public page."
},
"items": {
"$ref": "#/components/schemas/ExamplegameClanMember"
}
}
}
}
}
}
},
"ExamplegameClanMember": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"displayName": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Aldric"
}
}
},
"rankLabel": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Warlord"
}
}
},
"leader": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"online": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
}
}
}
}
}
}
}