Compare commits
27 Commits
1156a89509
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7692abcd9 | |||
| 43308c3f7f | |||
| e852e5574d | |||
| ad37cade6e | |||
| 7f746aee3d | |||
| 5dc14fa626 | |||
| a72b002f75 | |||
| f89044b42e | |||
| e9ca759227 | |||
| a8fa524263 | |||
| 39736f8448 | |||
| 3979fa5abf | |||
| 67aff992a4 | |||
| d497a3b09a | |||
| 744e5b7944 | |||
| 622abe9ed5 | |||
| 1c7d6151c7 | |||
| 4093293009 | |||
| 7875848ee7 | |||
| 77418aaef5 | |||
| 8fa4210477 | |||
| abf9344189 | |||
| f8f7014d53 | |||
| d5a8520ce0 | |||
| f41ff92c67 | |||
| 24b9d30a16 | |||
| 1ed617736e |
@@ -4,11 +4,25 @@
|
||||
#
|
||||
# ── What each job is really asking ───────────────────────────────────────────
|
||||
#
|
||||
# • `links` — every relative link resolves, and no link pins a reader to a
|
||||
# commit snapshot of a document that moves. Nothing is fetched: this project's
|
||||
# Gitea is self-hosted, so an HTTP check would fail on a runner without
|
||||
# credentials and teach everyone to ignore red. What breaks in practice is a
|
||||
# relative path after a file moves, and that is answerable offline.
|
||||
# • `prose` — the documentation, checked as far as documentation can be. Every
|
||||
# relative link resolves, and no link pins a reader to a commit snapshot of a
|
||||
# document that moves. Nothing is fetched: this project's Gitea is self-hosted,
|
||||
# so an HTTP check would fail on a runner without credentials and teach
|
||||
# everyone to ignore red. What breaks in practice is a relative path after a
|
||||
# file moves, and that is answerable offline.
|
||||
#
|
||||
# It also holds `template/README.md`'s rename checklist against the template
|
||||
# tree, in both directions — an unlisted file that still carries the
|
||||
# placeholder, and a listed file that no longer does, are both failures. That
|
||||
# checklist is the only instruction a reader has for the first thing they do
|
||||
# with the template, and it is prose, so it rots the way prose does.
|
||||
#
|
||||
# And it checks that every path the book names in backticks still exists. The
|
||||
# chapters teach out of `template/`, none of those mentions is a markdown link,
|
||||
# and nothing else in this repo would ever look at them — so renaming one
|
||||
# template file would leave four chapters quietly pointing at nothing. That is
|
||||
# the cheap half of "is the book still true"; the other half is a reviewer's.
|
||||
# All three checks in `scripts/` have their own unit tests, run in the same job.
|
||||
#
|
||||
# • `template` — the interesting one, and the anti-rot mechanism of the whole
|
||||
# repo (MODULE_SYSTEM.md §2.11.1 d2). It clones CORE at the ref pinned in
|
||||
@@ -60,7 +74,7 @@ env:
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||
|
||||
jobs:
|
||||
links:
|
||||
prose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -70,11 +84,26 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
# No dependencies on purpose — this has to run on a clone with nothing
|
||||
# installed, which is also how a reader will run it.
|
||||
# No dependencies on purpose — every step in this job has to run on a clone
|
||||
# with nothing installed, which is also how a reader will run them.
|
||||
- name: Check every link in the book
|
||||
run: node scripts/checkLinks.js
|
||||
|
||||
- name: Check the rename checklist against the template
|
||||
run: node scripts/checkRenameSites.js
|
||||
|
||||
- name: Check every path the book names still exists
|
||||
run: node scripts/checkChapterPaths.js
|
||||
|
||||
# The checks, checked. A check that has never been shown to fail is a check
|
||||
# nobody knows the state of — and these gate the instructions for the first
|
||||
# thing a reader does. Named file by file rather than `node --test scripts/`:
|
||||
# directory mode is not portable across the Node versions people run this on.
|
||||
- name: Test the checks themselves
|
||||
run: |
|
||||
node --test scripts/checkRenameSites.test.js
|
||||
node --test scripts/checkChapterPaths.test.js
|
||||
|
||||
template:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -136,3 +165,19 @@ jobs:
|
||||
- name: Run the template's tests
|
||||
if: steps.guard.outputs.present == 'true'
|
||||
run: npm test --prefix template/server
|
||||
|
||||
# After the build, and that ordering is the point: two of the client tests
|
||||
# read the BUILT chunk and SKIP when there is none. Run before the build,
|
||||
# this job would report green while asking nothing about the artifact that
|
||||
# ships — which is exactly how the first real module's two artifact tests sat
|
||||
# green and inert.
|
||||
- name: Run the template's client tests
|
||||
if: steps.guard.outputs.present == 'true'
|
||||
run: npm test --prefix template/client
|
||||
|
||||
# The committed OpenAPI fragment, regenerated and compared. Core merges that
|
||||
# file verbatim into its own spec, so a stale one documents a URL surface the
|
||||
# module does not serve — and nothing at runtime will ever say so.
|
||||
- name: Check the template's OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||
if: steps.guard.outputs.present == 'true'
|
||||
run: npm run check:swagger --prefix template/server
|
||||
|
||||
27
README.md
27
README.md
@@ -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
|
||||
@@ -83,9 +89,15 @@ scripts/ the checks CI runs over both
|
||||
```
|
||||
|
||||
CI clones core at a **pinned commit**, asserts the version the template declares
|
||||
still matches that core's `MODULE_API_VERSION`, builds the template, and checks
|
||||
every link in the book. So a change to the contract breaks this repo's build
|
||||
loudly instead of leaving a chapter quietly wrong.
|
||||
still matches that core's `MODULE_API_VERSION`, builds the template and runs its
|
||||
guards, checks every link in the book, holds the template's rename checklist
|
||||
against the template's own tree, and checks that every path a chapter names is
|
||||
still there. So a change to the contract breaks this repo's build loudly instead
|
||||
of leaving a chapter quietly wrong.
|
||||
|
||||
None of that can tell you whether a paragraph has become untrue about a file that
|
||||
still exists. That is a reviewer's job on every pull request, and a
|
||||
`MODULE_API_VERSION` bump is when it is owed in full.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -96,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
|
||||
|
||||
275
book/01-first-module.md
Normal file
275
book/01-first-module.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# 1. Your first module in twenty minutes
|
||||
|
||||
No theory in this chapter. You will copy a module that already works, rename it,
|
||||
build it, install it into a running core, and load a page it serves. Everything
|
||||
after this chapter is a change to something that runs, rather than a step toward
|
||||
something that might.
|
||||
|
||||
That order is deliberate. The module system has a lot of seams — a server entry
|
||||
point, a client chunk, a schema fragment, a nav registration, an OpenAPI fragment
|
||||
— and each one is easy to understand and unpleasant to debug in the abstract. Get
|
||||
all of them working at once with almost no content in them, and you can then break
|
||||
exactly one at a time on purpose.
|
||||
|
||||
**What you need:** a Runic Gateway core you can restart, Node 20 or newer, and
|
||||
about twenty minutes. You do not need core's source, and you should not read it —
|
||||
if this chapter cannot be followed without it, that is a bug in this chapter and
|
||||
[worth telling us about][issues].
|
||||
|
||||
---
|
||||
|
||||
## The pieces you are about to copy
|
||||
|
||||
`template/` is a whole module, in the shape a real one has. Nine things matter and
|
||||
the rest is filling:
|
||||
|
||||
| Piece | What it is |
|
||||
| --- | --- |
|
||||
| `template/module.json` | The first thing core reads. Your id, your version, the core API range you need, and a declaration of every prefix you will mount. |
|
||||
| `template/server/index.js` | The server-side handshake: one exported function, called once with `(ctx, api)`. |
|
||||
| `template/server/core.js` | Lazy accessors over `ctx`, so the rest of your server code can reach core the way ordinary code reaches a library. |
|
||||
| `template/server/boot.js` | `onBoot` and `onShutdown` — where anything needing a live database goes. |
|
||||
| `template/server/db/schema.sql` | Your tables. Idempotent, replayed at every boot. |
|
||||
| `template/server/db/purge.sql` | The same tables, dropped. Run only when an operator explicitly purges you. |
|
||||
| `template/client/src/entry.jsx` | The client-side handshake: registers your routes and your nav rows into core's SPA. |
|
||||
| `template/client/vite.config.js` | The library build that produces the chunk core serves — and the aliases that make your React core's React. |
|
||||
| `template/swagger-fragment.json` | Generated. Core merges it into its own API documentation. |
|
||||
|
||||
Two of those have a reputation. `vite.config.js` is the highest-risk mechanical
|
||||
detail in the whole system and chapter 2 spends real time on why; `module.json`'s
|
||||
`mounts` is the one field people fill in wrong and discover at boot. Neither
|
||||
matters yet — the template has both right.
|
||||
|
||||
## Copy it, and make it yours
|
||||
|
||||
```bash
|
||||
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
|
||||
match `^[a-z][a-z0-9-]{1,31}$`, and you want no hyphen in it unless you enjoy
|
||||
backticking table names.
|
||||
|
||||
Change `id` in `module.json` first, then work down the checklist in
|
||||
`template/README.md` — it names every file that still carries the placeholder,
|
||||
and it is [verified by CI][renamecheck] in both directions, so it is not the kind
|
||||
of checklist that is wrong by the second edit.
|
||||
|
||||
**The placeholder is `examplegame`, not `example`, and that is not an aesthetic
|
||||
choice.** A check for a leftover `example` fires on the phrase "for example" in
|
||||
ordinary prose, and a check that cries wolf is a check people learn to ignore. If
|
||||
you build your own checks later, pick placeholder names that cannot occur by
|
||||
accident.
|
||||
|
||||
## Build it
|
||||
|
||||
```bash
|
||||
npm ci --prefix server
|
||||
npm test --prefix server
|
||||
|
||||
npm ci --prefix client
|
||||
npm run build --prefix client # → client/dist/entry.js
|
||||
npm test --prefix client
|
||||
```
|
||||
|
||||
Build **before** you run the client tests. Two of them read the built chunk and
|
||||
skip when there is none, so a run in the other order passes while asking nothing
|
||||
about the artifact that actually ships. That ordering has bitten this project
|
||||
twice in two different repositories, which is why it is called out here rather
|
||||
than left to a CI file.
|
||||
|
||||
What you have now is `client/dist/entry.js` — a prebuilt ES module — and a server
|
||||
tree that has never been compiled at all, because it does not need to be.
|
||||
|
||||
**An operator never builds anything.** That is the constraint the whole delivery
|
||||
path is designed around: a module arrives as a tarball with the chunk already in
|
||||
it, and core serves that file untouched. Your build machine is the only place a
|
||||
bundler ever runs.
|
||||
|
||||
## Install it
|
||||
|
||||
Three supported ways, and for the next twenty minutes you want the third:
|
||||
|
||||
1. **Admin → Modules**, pasting the URL of an install manifest — the JSON your
|
||||
release workflow publishes beside your tarball. This is how a real operator
|
||||
installs you.
|
||||
2. **The `MODULES` environment variable**, `<id>@<version>=<manifest URL>`, for a
|
||||
deployment that declares its module set instead of clicking it.
|
||||
3. **A directory on the volume.** Copy your whole module tree to
|
||||
`<website>/modules/<your-id>/` and restart core.
|
||||
|
||||
```bash
|
||||
cp -r ~/my-module <website>/modules/my-id
|
||||
# restart core
|
||||
```
|
||||
|
||||
**Copy it. Do not symlink it.** The loader lists directory entries and asks each
|
||||
whether it is a directory; a symlink answers no, and your module is skipped in
|
||||
complete silence. This is the single most common way a first install appears to do
|
||||
nothing at all.
|
||||
|
||||
Two more things that look like your module failing and are not:
|
||||
|
||||
- If core is running in a container, your files have to be on the volume core sees
|
||||
— `MODULES_DIR` (`/app/modules` under the shipped Compose file), not the
|
||||
repository directory next to it.
|
||||
- A core with a fresh database boots in **maintenance mode**, and public module
|
||||
pages sit behind the same maintenance gate core's own do. Your page will look
|
||||
broken while the site is not live yet.
|
||||
|
||||
## What you should see
|
||||
|
||||
Restart core and read the log. A module that loaded says so:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Your two lines and core's three, interleaved: core narrates each step of your load
|
||||
in its own `[modules]` namespace, and your logger is namespaced with your id. That
|
||||
alternation is the quickest way to see how far a load got.
|
||||
|
||||
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.
|
||||
- **`/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.
|
||||
- **`/api/docs`** shows your route under its own tag, merged out of the OpenAPI
|
||||
fragment you committed. (`/api/docs.json` is the raw merged document, if you
|
||||
would rather grep it.)
|
||||
- **Admin → Modules** shows you as `started`.
|
||||
|
||||
Open the browser console while you are there. Your entry logs the core API version
|
||||
it registered against, and any complaint the client half has to make will be sitting
|
||||
next to it.
|
||||
|
||||
## The state your module is in
|
||||
|
||||
Core keeps one row per module and its `state` column has five values. Four are
|
||||
outcomes and one is an operator's decision:
|
||||
|
||||
| State | Means |
|
||||
| --- | --- |
|
||||
| `installed` | Files are on the volume; the row was just created. |
|
||||
| `enabled` | Cleared for this boot to try. Every non-disabled row is reset to this at each boot. |
|
||||
| `started` | Loaded, registered, schema replayed, `onBoot` returned. This is the one you want. |
|
||||
| `startup_failed` | Something went wrong; the panel shows the stage and the reason. The site came up anyway. |
|
||||
| `disabled` | An operator switched you off. Nothing else — not a failure, not a reinstall — moves this. |
|
||||
|
||||
The important half of that table is what it implies: **a module that fails to
|
||||
load never takes the site down.** Core try/catches your entire lifecycle, records
|
||||
where you broke, and serves everything else. You are debugging from an admin
|
||||
screen, not from a stack trace in a crash loop.
|
||||
|
||||
**A retry is a restart.** Every boot resets non-disabled rows to `enabled` and
|
||||
writes that boot's outcome, so the panel always describes the run you are looking
|
||||
at rather than a run from last week.
|
||||
|
||||
## The four ways it fails
|
||||
|
||||
When something is wrong, the shape of the failure tells you where to look before
|
||||
you read a single message.
|
||||
|
||||
**1. Your module is not in the panel at all.** The loader never saw a directory
|
||||
worth scanning. It is a symlink; or it is in the wrong place; or it has no
|
||||
`module.json` at the top of it. Note the bundle shape here — a release tarball's
|
||||
top-level directory is `<name>-<version>`, so an unpacked bundle copied wholesale
|
||||
leaves core looking at a directory with nothing in it but another directory.
|
||||
|
||||
**2. It is `startup_failed`, and your routes and nav are simply absent.** The
|
||||
failure happened before anything was mounted: a malformed `module.json`, an
|
||||
unsatisfiable `coreApi`, a prefix that collides with core's, a schema fragment
|
||||
breaking a rule. Nothing of yours is on the URL surface, so nothing of yours can
|
||||
half-work.
|
||||
|
||||
**3. It is `startup_failed`, and your routes answer `503`.** The failure happened
|
||||
after mounting — the database rejected a statement in your fragment, or your
|
||||
`onBoot` threw. Your routes stay mounted deliberately: the URL surface is a
|
||||
property of what is installed, not of whether a boot hook succeeded on this
|
||||
machine. A module that failed to warm up says it is down; it does not serve half
|
||||
its data.
|
||||
|
||||
**4. It answers `404` everywhere.** Someone disabled you. Same mechanism — mounted
|
||||
and guarded, never unmounted.
|
||||
|
||||
The panel names the stage each failure happened in, and the stages are the
|
||||
loader's own validation steps, listed in [`MODULE_API.md`][api] §4.3 and §4.4. Read
|
||||
the stage first; it is usually enough. Core logs the same thing at boot —
|
||||
`module "…" failed to load — continuing without it {"stage":…,"reason":…}` — so you
|
||||
do not need the panel to debug this.
|
||||
|
||||
**In all four cases you disappear from `/api/v1/public/modules`.** That endpoint
|
||||
answers what this backend is *serving*, so a client feature-detecting your
|
||||
capability renders a site without it rather than one advertising something that
|
||||
`503`s. It is also a quick check with no login: if you are not in that list, you
|
||||
are not running, whatever the page looks like.
|
||||
|
||||
## What to do next
|
||||
|
||||
You have a module. Now break it on purpose, once each, and watch what the panel
|
||||
says:
|
||||
|
||||
- Add a prefix to `module.json`'s `mounts` and do not register it. → stage
|
||||
`register`, *"declared public/extra but never registered it"*. What you declared
|
||||
and what you registered must match, in both directions.
|
||||
- Rename one of your tables so it no longer starts with your id. → stage `schema`,
|
||||
at **load** time, before anything is mounted: your routes answer `404`.
|
||||
- Throw inside `onBoot`. → after mounting, so the same route answers `503` with
|
||||
*"Module unavailable"* instead of vanishing.
|
||||
|
||||
Those are the three outcomes above, and the messages are what this core actually
|
||||
prints for them — they were run to write this paragraph rather than predicted.
|
||||
|
||||
Twenty minutes of that is worth more than any chapter, because every one of those
|
||||
failures is one you will cause accidentally later, and you will recognise it.
|
||||
|
||||
Then read [chapter 2](02-website-module.md), which is the same module explained —
|
||||
what `ctx` hands you and why it is handed rather than imported, what each
|
||||
`register*` call is for, why the client half is built the way it is, and what a
|
||||
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
|
||||
821
book/02-website-module.md
Normal file
821
book/02-website-module.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# 2. The website module
|
||||
|
||||
The module you built in [chapter 1](01-first-module.md), explained. This is the
|
||||
longest chapter in the book because the website module is most of the work, and
|
||||
because almost every part of it is shaped by a constraint that is invisible until
|
||||
you hit it.
|
||||
|
||||
Nothing here is normative. [`MODULE_API.md`][api] is the contract; where this
|
||||
chapter and the contract disagree, the contract is right and this chapter has a
|
||||
bug. What is here is the reasoning — which is exactly what a contract cannot carry
|
||||
without becoming unreadable.
|
||||
|
||||
---
|
||||
|
||||
## The shape of the whole thing
|
||||
|
||||
A module is a directory core reads at boot. Core loads it, hands it two objects,
|
||||
and takes back whatever it registers.
|
||||
|
||||
```
|
||||
core boots (its own schema and seed have already run)
|
||||
└─ scans modules/*/module.json
|
||||
└─ validates yours ← nothing mounted yet: a failure here leaves
|
||||
│ nothing of yours on the URL surface at all
|
||||
└─ require(server entry)
|
||||
└─ register(ctx, api) ← your one synchronous handshake
|
||||
└─ second pass: mounts everything that survived
|
||||
└─ replays your schema fragment
|
||||
└─ onBoot(ctx) ← the first moment a database exists
|
||||
└─ HTTP listener binds
|
||||
```
|
||||
|
||||
Two properties of that sequence explain most of the rules that follow.
|
||||
|
||||
**It is synchronous and it happens during `require`.** Core's route-manifest
|
||||
generator and its OpenAPI generator both require the app with the database pool
|
||||
pointed at a dead port — that is how they introspect a real Express app without a
|
||||
database. So `register()` may not `await` and may not query. A module that did
|
||||
would hang both build tools, and the symptom would be a CI job that never
|
||||
finishes rather than an error anyone can read.
|
||||
|
||||
**Mounting is a second pass.** Every module is validated before any module is
|
||||
mounted. If mounting happened inside the scan loop, the first module's layers
|
||||
would be sitting on the tier router while the second was validated —
|
||||
indistinguishable from core's own — and the second would be told it collided with
|
||||
*core*, naming the wrong culprit. You will never see this; it is why the failure
|
||||
messages you do see are trustworthy.
|
||||
|
||||
## `module.json`
|
||||
|
||||
Every field is documented in [§2.1][api]. Three of them decide whether your module
|
||||
loads at all.
|
||||
|
||||
**`id`** is the directory core loads you from, the key of your database row, the
|
||||
URL segment your pages hang under, and the required prefix of every table you
|
||||
create. It must equal its own directory name — a module renamed by copying it to a
|
||||
different directory is rejected rather than quietly mounted under a name nothing
|
||||
else agrees with.
|
||||
|
||||
**`coreApi`** is a semver range against core's `MODULE_API_VERSION`. Set it to the
|
||||
version you developed against and let it drift upward deliberately. This is the
|
||||
one number that decides whether a module written today loads against a core
|
||||
shipped next year, and a range that is too loose does not fail — it half-works.
|
||||
|
||||
**`mounts`** declares every prefix you will register, per tier. **The loader
|
||||
compares it with what you actually register and rejects a mismatch in both
|
||||
directions.** A prefix you declared and never registered fails just as loudly as a
|
||||
route you registered without declaring. That is the point: the file is a statement
|
||||
of your URL surface that cannot rot, because it is checked against reality at
|
||||
every boot.
|
||||
|
||||
**Choosing prefixes is the part to slow down on.** They share one namespace with
|
||||
core's own, so `/status` is not available to you — and the loader's collision
|
||||
probe cannot see all of core's, because several of core's endpoints are mounted at
|
||||
the tier root rather than under a prefix of their own. `template/server/index.js`
|
||||
carries the current list of what core answers on the public tier in a comment
|
||||
beside the registration. Read it before you choose, and choose a noun from your
|
||||
own domain rather than a generic one.
|
||||
|
||||
`capabilities` is the opposite kind of field: opaque strings core never
|
||||
interprets, published by `GET /api/v1/public/modules` while you are `started`, so
|
||||
that a client — the SPA, the Android app — can feature-detect you. Two modules may
|
||||
declare the same one. A client must treat an unknown capability as absent, and must
|
||||
never infer a URL from one.
|
||||
|
||||
## The server entry point
|
||||
|
||||
One exported function, called once: `register(ctx, api)`. `ctx` is what core hands
|
||||
you; `api` is what you hand back. Read `template/server/index.js` — it is short,
|
||||
and every comment in it is load-bearing.
|
||||
|
||||
### Why `ctx` is handed over rather than imported
|
||||
|
||||
Your module lives at `<website>/modules/<id>/`, outside core's `server/`. Node's
|
||||
resolver walks *up* from a file looking for `node_modules`, so it never reaches
|
||||
core's — and `require('express')` from inside a module simply fails.
|
||||
|
||||
That is the mechanical reason, and it is the shallow one. The real reason is that
|
||||
there is exactly one of certain things in the process and core owns them: one
|
||||
express, so there is one `Router` prototype; one database pool; one logger; one
|
||||
session reader. A second express resolved from your own dependencies would work
|
||||
for about a week and then produce a routing bug nobody can reproduce.
|
||||
|
||||
So the rule generalises past the two obvious cases: **anything shared between core
|
||||
and a module is owned by core and handed over, never resolved by the module.** On
|
||||
the server that is `express` and `express-validator`; on the client it is React,
|
||||
`react-dom`, `react-router-dom` and the JSX runtime. Both halves of the system
|
||||
have one mechanism for it, and it is the same rule twice.
|
||||
|
||||
[§2.3][api] lists every member of `ctx`. It is a curated list, not core's
|
||||
internals: `ctx.auth` is one function rather than core's whole auth facade,
|
||||
because minting sessions is core's job and a module that needs an identity needs
|
||||
to *read* one. `ctx.settings` is three functions rather than a settings model with
|
||||
two dozen. Expect the narrowing, and expect to occasionally want something that is
|
||||
not there — that is a conversation about a minor version bump, not a reason to
|
||||
reach around it.
|
||||
|
||||
### The lazy-accessor pattern, and the require order it forces
|
||||
|
||||
`ctx` exists only from the moment `register()` is called. But the code underneath
|
||||
— models, controllers, routers — is ordinary Node that requires its dependencies
|
||||
at file scope, and *that* runs before `register()` does.
|
||||
|
||||
`template/server/core.js` is what makes both true at once: every member is an
|
||||
accessor that resolves `ctx` **when it is called**, so a model can write
|
||||
`const { query } = require('../../core')` at the top of the file, exactly as
|
||||
ordinary code does.
|
||||
|
||||
Two consequences, and both have cost this project time:
|
||||
|
||||
**Require order is load-bearing.** A router writes `const express = core.express`
|
||||
at *its* file scope, and that runs the moment the router is required. So
|
||||
`core.init(ctx)` has to happen before the first `require` of anything under
|
||||
`router/`. This is why `template/server/index.js` requires its routers *inside*
|
||||
the register function instead of at the top of the file. Hoist them and the module
|
||||
breaks with an error about a missing `ctx`, thrown from a file that never mentions
|
||||
one.
|
||||
|
||||
**Never destructure a getter at init time.** Core is free to hand over an accessor
|
||||
rather than a value — `ctx.site.baseUrl` is one — and a value captured once at
|
||||
startup is a value that cannot change afterwards.
|
||||
|
||||
`template/server/core.js` is also deliberately a *narrowing*: it re-exports only
|
||||
what the module actually uses. Copy that discipline. It makes the file an honest
|
||||
statement of your dependencies, and it makes a test double for it — see
|
||||
`template/server/test/_fakes.js` — a complete one rather than a guess.
|
||||
|
||||
## What you register
|
||||
|
||||
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
|
||||
own stack trace. Whether a *name* is taken can only be answered once your batch is
|
||||
complete, and is checked when the loader commits it. So a module that registers two
|
||||
notification streams and then throws leaves nothing behind. That matters more than
|
||||
it sounds: a half-registered catalog is a stream a user can subscribe to and
|
||||
nothing will ever publish to, which is worse than a missing one because it looks
|
||||
like it works.
|
||||
|
||||
### Routes
|
||||
|
||||
`api.registerRoutes({ public, admin, player })` — one router per prefix per tier.
|
||||
|
||||
**The tier gate is already applied.** A router registered under `admin` sits
|
||||
behind core's own `noindex, isLoggedIn, requireRole(...)`; under `player`, behind
|
||||
`noindex, requireAuth`; under `public`, behind nothing, by design. You add
|
||||
per-route gates on top of that and you never re-implement the tier gate. A module
|
||||
cannot supply its own auth wrapper, and that restriction is one of the few places
|
||||
the boundary is genuinely load-bearing rather than organisational: the server's
|
||||
route table and the client's sidebar have to agree about who may see what, and
|
||||
they only do if one thing decides.
|
||||
|
||||
Your router is mounted *inside* the tier router, so it structurally cannot reach
|
||||
above its own prefix. This is not enforcement by review; there is no path
|
||||
expressible from inside your router that escapes it.
|
||||
|
||||
### Extension slots
|
||||
|
||||
Sometimes what you have to add is not a page of your own but a section of core's.
|
||||
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. 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.**
|
||||
`site.footer.status` is "the status-ish spot in the footer" — not a declaration
|
||||
that core knows what a game server's status is. Core supplies the position and the
|
||||
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
|
||||
easy to get wrong:
|
||||
|
||||
- **A notification stream** is a subscribable channel. You register the catalog
|
||||
entry — id, label, whether it is personal, whether it needs a linked game
|
||||
account — and core uses it for the subscribe endpoint and its gates. You publish
|
||||
to it yourself with `ctx.push.publish`. Core never maps your events to your
|
||||
streams; you have already resolved the id, and it follows that the safety rule
|
||||
about which of your events may reach a *public* stream lives in your module too
|
||||
— which is right, because the event kinds, the stream list and the filter are
|
||||
then one file that moves together.
|
||||
- **An announce leg** is one-shot delivery with retry. Core's CMS publishes a post,
|
||||
every registered leg tries to deliver it somewhere, and your `classify` maps your
|
||||
own result to `done` / `retry` / `terminal`. A leg that throws is caught,
|
||||
classified as a retry, and never blocks another leg.
|
||||
- **A post hook** maintains idempotent state, runs on delete as well as save, and
|
||||
refreshes silently on an edit.
|
||||
|
||||
The last two fire on the same transition and are deliberately not one call. A leg
|
||||
that must not be retried and a `classify` that means nothing would be the cost of
|
||||
merging them.
|
||||
|
||||
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
|
||||
**before the HTTP listener binds**. It is the first moment a database exists, so
|
||||
it is where everything that needs one goes: warming a cache, backfilling,
|
||||
connecting to your sidecar.
|
||||
|
||||
**`onBoot` has no timeout, deliberately.** A slow boot delays the listener, and
|
||||
that is the guarantee rather than a problem to be timed out — a module that must
|
||||
not serve traffic before it has warmed up gets exactly that. If your `onBoot`
|
||||
throws, you are `startup_failed`: your routes stay mounted and answer `503`, and
|
||||
the site comes up without you.
|
||||
|
||||
`api.onShutdown(fn)` runs while core's pool, push dispatcher and event fan-out are
|
||||
all still open, because flushing through them is the only thing it is for. It has
|
||||
a five-second budget and is abandoned past it — the process is exiting anyway, and
|
||||
the alternative is a host where stopping the service waits for a kill.
|
||||
|
||||
A module whose `onBoot` threw gets **no** `onShutdown`. It is part-way through a
|
||||
warm-up it never finished, and handing it a half-built world to tear down is worse
|
||||
than not closing cleanly. A module with no hooks at all still reaches `started`:
|
||||
having nothing to warm up is not the same as never having started.
|
||||
|
||||
## The schema fragment
|
||||
|
||||
`template/server/db/schema.sql` is your tables. It is replayed **in full, at every
|
||||
boot**, statement by statement, right after core's own schema.
|
||||
|
||||
**There is no migration runner anywhere in this project, and that is a decision
|
||||
rather than an omission.** Core's own schema is one idempotent file replayed the
|
||||
same way. What you get in exchange is that a module's schema is a single readable
|
||||
statement of what its tables are, with no ordering history to reconstruct and no
|
||||
migration table to get out of step with the tables themselves.
|
||||
|
||||
What it costs you is that **changing a table is an `ALTER`, never an edit to its
|
||||
`CREATE`.** `CREATE TABLE IF NOT EXISTS` no-ops against an existing table, so an
|
||||
edited column definition lands on fresh installs only — and your development
|
||||
database is usually the fresh one, which is what makes this bite six months later
|
||||
on somebody else's instance. Add the column with
|
||||
`ALTER TABLE … ADD COLUMN IF NOT EXISTS`, leave the `CREATE` alone, and both paths
|
||||
converge.
|
||||
|
||||
Two rules the loader enforces before your module is mounted at all:
|
||||
|
||||
**Leading verbs are an allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Not a
|
||||
`DROP` denylist — because the file is replayed at every boot, `TRUNCATE` and
|
||||
`DELETE` would empty a table at every restart and `RENAME` would fail at the
|
||||
second one. A denylist only ever bans what somebody thought of.
|
||||
|
||||
**Table names are namespaced `<id>_` and collision-checked** against core's tables
|
||||
and every other module's. A `CREATE TABLE` missing `IF NOT EXISTS` is rejected on
|
||||
the same grounds as the rest: it succeeds exactly once and fails every boot after,
|
||||
which presents to an operator as a module that broke on restart.
|
||||
|
||||
Both are checked by *reading the file*, before anything mounts, and that split is
|
||||
the design: everything knowable without a database costs you the mount, so a
|
||||
rule-breaking fragment never half-applies; what only a database can answer — an
|
||||
unknown column type, a bad foreign key — happens later and answers `503`.
|
||||
|
||||
`purge.sql` is the destructive counterpart, and it is required whenever you ship a
|
||||
schema. It runs **only** when an operator explicitly purges you, never on
|
||||
uninstall. A module that can create tables and cannot drop them leaves an operator
|
||||
with orphaned data and no supported way to remove it.
|
||||
|
||||
One more thing about a file that replays: **a guard and the statement it guards
|
||||
must live in the same file.** If you write a one-shot data fix conditioned on a
|
||||
marker, put both the marker and the fix in your own fragment. Core's schema
|
||||
replays in full before any module's, so a marker core writes has already been
|
||||
written by the time your guard reads it — a real defect this project shipped and
|
||||
did not notice, because it is latent until the day someone installs on an older
|
||||
version.
|
||||
|
||||
## The client half
|
||||
|
||||
Your client half is a **prebuilt ES module**. Core serves it from your module's
|
||||
directory as a same-origin script and injects a `<script type="module" src>` for
|
||||
it before `</body>`. There is no bundling step on the operator's machine, ever.
|
||||
|
||||
### One React, and core owns it
|
||||
|
||||
`window.__rg` is core's published set of shared dependencies plus the registry,
|
||||
the UI kit and a request primitive ([§3.2][api]). Your build does not bundle React
|
||||
— it aliases every shared specifier to a two-line shim that re-exports from that
|
||||
global.
|
||||
|
||||
The failure this prevents is specific and nasty: a second React in the page is a
|
||||
second hook dispatcher, so your component throws about an invalid hook call
|
||||
somewhere unrelated to the mistake, in a page that otherwise loads fine.
|
||||
|
||||
`template/client/vite.config.js` is the whole mechanism, and its comments are the
|
||||
most valuable prose in the template. Three things there were wrong first and are
|
||||
now contract:
|
||||
|
||||
- **The aliases use the array form with anchored regexes.** Vite's object form does
|
||||
prefix matching, so a `react` key also silently rewrites `react/jsx-runtime` — to
|
||||
the wrong shim.
|
||||
- **The aliases replace `external`; they do not accompany it.** Rollup asks
|
||||
`external` *before* Vite's alias resolver runs, so a specifier in both is never
|
||||
aliased and the chunk ships bare `import 'react'` specifiers. A browser cannot
|
||||
resolve those without an import map, and core's `script-src 'self'` forbids the
|
||||
inline script an import map has to be. The first real module shipped exactly that
|
||||
chunk, from a clean green build.
|
||||
- **The build guard hooks `transform`, not `load`**, and its forbidden-package list
|
||||
is stated rather than derived from the alias list. `load` is first-wins, so
|
||||
written against it the guard sat in the build doing nothing. Deriving the list
|
||||
means deleting an alias also deletes the guard against what that alias prevented
|
||||
— precisely when it is needed.
|
||||
|
||||
`template/client/scripts/checkExternals.js` asks the **built chunk** whether any
|
||||
bare specifier survived. That question cannot be asked of source:
|
||||
`import { useState } from 'react'` is correct in every file, and which React it
|
||||
becomes is decided by the build config. Run it in your CI.
|
||||
|
||||
### Registration happens at evaluation time
|
||||
|
||||
`template/client/src/entry.jsx` registers your routes and nav rows with plain
|
||||
top-level calls. There is no subscription and no late registration: module chunks
|
||||
are deferred scripts that execute after core's bundle and before core's first
|
||||
render, so everything you register is present in that first render.
|
||||
|
||||
**So every page is a static import, and lazy-loading your routes is the one thing
|
||||
this seam cannot have.** A module that registered asynchronously would register
|
||||
after the route table had been read, and the symptom is a page that redirects home
|
||||
with nothing logged anywhere — indistinguishable from a module that failed to
|
||||
load.
|
||||
|
||||
That timing is also where this project's most instructive client-side bug lived.
|
||||
Core's own render used to wait on `document.readyState === 'loading'`; but a
|
||||
deferred script runs *after* the document is parsed, so `readyState` is already
|
||||
`'interactive'`, and core mounted immediately — before any module chunk had
|
||||
evaluated. Every unit test passed. It was found by loading a real chunk in a real
|
||||
browser, which is the only place it was visible.
|
||||
|
||||
### Nav, and what a registered row becomes
|
||||
|
||||
`registry.registerNav` interleaves your rows into **core's** navigation groups,
|
||||
and from that moment your row is an ordinary row: an operator can reorder,
|
||||
relabel or hide it from the nav editor exactly as they can core's. That works
|
||||
because the interleave happens *before* the admin override merge — the override
|
||||
layer is keyed by a row's `to`, and it drops keys its base does not declare, so a
|
||||
row appended afterwards would be unorderable, unrelabellable and unhideable.
|
||||
|
||||
Three details worth knowing before you need them:
|
||||
|
||||
- **A row with no `order` appends after core's rows** rather than defaulting to
|
||||
zero. "I didn't ask for a position" must not mean "put me first".
|
||||
- **An unknown `group` name appends a new group** rather than dropping your row.
|
||||
- **`icon` has no core fallback.** Public header rows carry no icons, so a public
|
||||
row needs none; an admin or player row without one is the only glyph-less row in
|
||||
its sidebar, which reads as breakage. Match the nav you land in rather than
|
||||
shipping one glyph for everywhere.
|
||||
|
||||
`registerFeatureProvider` is how a row can be conditional: core keeps a generic
|
||||
flag context and you supply the hook that fills your namespace. **The namespace
|
||||
comes from the registration, not from parsing the string**, so a typo'd namespace
|
||||
is not a thing that can exist.
|
||||
|
||||
**Everything in this layer fails open.** No provider, an answer still in flight, a
|
||||
malformed row — all of them show the link. This is presentation and the server is
|
||||
the gate: a UI mistake that hides a page from someone entitled to it is worse in
|
||||
every case than one that shows a link which then answers `403`.
|
||||
|
||||
### The UI kit
|
||||
|
||||
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, 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, 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 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.
|
||||
|
||||
One thing that surprises everyone once: **core's public pages render the public
|
||||
layout themselves** — it is a component, not a route wrapper. A public page of
|
||||
yours that does not use `ui.PublicLayout` renders bare, with no site chrome. That
|
||||
is the contract working as intended, not a bug to hunt.
|
||||
|
||||
## The OpenAPI fragment
|
||||
|
||||
Every module that registers routes ships `swagger-fragment.json` in its bundle
|
||||
root, and core merges the fragments of started modules into its own API document.
|
||||
The filename is fixed rather than declared, like `module.json` itself.
|
||||
|
||||
**Generate it from your own registrations** — `template/server/scripts/swaggerFragment.js`
|
||||
is a working generator. It runs your `register()` against a recording `api` and
|
||||
resolves each router back to its source file, so a mount prefix exists in exactly
|
||||
one place rather than being retyped into a generator that then drifts.
|
||||
|
||||
Two rules and one trap:
|
||||
|
||||
**Namespace what you define; reference core's shared schemas by core's name.** A
|
||||
schema you invented gets your prefix. `Error` and `ValidationError` are core's:
|
||||
reference them and do not redefine them. They resolve in the merged document,
|
||||
which is the only place both halves exist — and shipping your own copy is a
|
||||
collision core drops, arriving at the same result the expensive way.
|
||||
|
||||
**Commit the generated file and check it is current in CI.** Core merges it
|
||||
verbatim, so a stale fragment documents a URL surface you do not serve, and
|
||||
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.
|
||||
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
|
||||
|
||||
`template/.gitea/workflows/release.yml` (and its GitHub twin) is a working release
|
||||
pipeline. Copy it to the root of your module's repository — a workflow file is
|
||||
only read from a repository root, which is why it does nothing where it sits
|
||||
inside the kit.
|
||||
|
||||
**A release is not source.** It is the directory core's loader expects to find at
|
||||
`modules/<id>/`, already assembled: the prebuilt chunk, your runtime dependencies
|
||||
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 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.
|
||||
|
||||
The install manifest is the JSON your workflow publishes beside the tarball. Its
|
||||
URL is what an operator pastes into Admin → Modules, and the host it lives on has
|
||||
to be on that core's allowlist — an operator-controlled setting, so tell your users
|
||||
where you publish.
|
||||
|
||||
## Boundaries
|
||||
|
||||
[§2.7][api] is the list. Each item has a failure behind it:
|
||||
|
||||
| Rule | What it prevents |
|
||||
| --- | --- |
|
||||
| No `require` outside your own directory (bar built-ins and your own dependencies) | Two copies of a thing there must be one of; and a module that survives a core refactor only by luck. |
|
||||
| Do not mutate `ctx`, `req.user`, or anything core handed you | A module changing another module's world, invisibly. |
|
||||
| No app-level middleware, no Express error handler | One module deciding how every other module's errors are rendered. |
|
||||
| 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
|
||||
chapters and not one. It is also the only rule in the list with **no CI behind
|
||||
it** — an outbound socket is not statically detectable the way an internal
|
||||
`require` is — so it is enforced in review and by understanding it, which is what
|
||||
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
|
||||
291
book/03-sidecar.md
Normal file
291
book/03-sidecar.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# 3. The sidecar
|
||||
|
||||
Your module may not open a connection to a game server. Not a game socket, not an
|
||||
RCON channel, not a query port, not an engine's admin API. It talks to a
|
||||
**sidecar**, and the sidecar talks to the game.
|
||||
|
||||
That is a rule in the contract ([`MODULE_API.md`][api] §2.7, as of
|
||||
`MODULE_API_VERSION` 1.4.0) rather than advice this kit is offering. It is also
|
||||
the rule most likely to feel like ceremony when your game already exposes a
|
||||
perfectly good remote-control protocol and your module is fifty lines from
|
||||
working. This chapter is why it is not.
|
||||
|
||||
**It is the one rule in that list with no CI behind it.** An outbound socket is
|
||||
not statically detectable the way an internal `require` is. So it is enforced by
|
||||
review, and by you having read this.
|
||||
|
||||
---
|
||||
|
||||
## What a sidecar is
|
||||
|
||||
A small, separate service that owns the connection to your game, keeps a durable
|
||||
copy of what the game said, and exposes an HTTP + WebSocket API that the website's
|
||||
backend reads.
|
||||
|
||||
```
|
||||
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
|
||||
│ (your module)
|
||||
▼
|
||||
its own store
|
||||
```
|
||||
|
||||
Three properties, and each is doing real work.
|
||||
|
||||
## 1. The game dials out; the sidecar listens
|
||||
|
||||
The sidecar binds the listener. The game connects **to it**, and the game opens no
|
||||
listening port at all.
|
||||
|
||||
This is the inversion people find surprising and it is the load-bearing one. The
|
||||
website is the internet-facing process; your game is not, and must not become
|
||||
reachable because a web app knows how to reach it. A module holding the connection
|
||||
makes the public web app the thing the game trusts, and puts the game's address
|
||||
inside the same process as every request from the internet.
|
||||
|
||||
In `uo-link`, that listener is `sidecar/src/shard.rs` — `serve` binds a loopback
|
||||
address and accepts shard connections forever, handling one at a time and looping
|
||||
back to accept the next. The game plugin does the dialling, with its own backoff.
|
||||
Loopback, in that deployment, because the sidecar runs on the game host: the only
|
||||
socket the game speaks over never leaves the machine.
|
||||
|
||||
Only the website's backend talks to the sidecar, and it authenticates. `uo-link`'s
|
||||
`web.rs` requires a token on every request — accepted as a bearer header, an API-key
|
||||
header, or a query parameter, that last one only because browser WebSocket clients
|
||||
cannot set handshake headers — and compares it in constant time. Auth is always on;
|
||||
there is no unauthenticated mode to accidentally deploy.
|
||||
|
||||
## 2. Persist before you forward
|
||||
|
||||
This is the property that makes a sidecar worth having even when your game is
|
||||
already remote-controllable, and the one a message-passing diagram never conveys.
|
||||
|
||||
**The sidecar owns the durable copy.** It writes what the game said into its own
|
||||
store, and answers reads from that store — not by round-tripping the game.
|
||||
|
||||
`uo-link` does this in `sidecar/src/store.rs`: SQLite, holding event history, the
|
||||
latest snapshot of every board the site renders, the economy series and the
|
||||
published ruleset. `insert_event` is called for every live event as it is
|
||||
broadcast; the `upsert_*` functions keep one current row per board; the REST read
|
||||
paths query that store.
|
||||
|
||||
What it buys, concretely:
|
||||
|
||||
- **A website that is down, restarting or mid-deploy loses nothing.** Events that
|
||||
arrive while nothing is listening are still recorded. Without a store they are
|
||||
simply gone, and your first deploy of the week is a hole in your data.
|
||||
- **A page renders the last thing the game said rather than going blank.** A rules
|
||||
page that empties itself because the game restarted is worse than a stale one.
|
||||
- **The live feed is allowed to be lossy.** `uo-link`'s WebSocket fan-out drops
|
||||
frames for a consumer that has fallen behind and logs that it did — deliberately,
|
||||
because durability is the store's job and not the socket's. A feed that instead
|
||||
buffered without limit for a slow client would eventually take the sidecar down.
|
||||
|
||||
That last point is the reasoning to carry into your own design. Once the store is
|
||||
authoritative, every other component is allowed to be best-effort, and each of them
|
||||
gets simpler. Skip the store and you find yourself trying to make a socket reliable,
|
||||
which is the hard version of this problem.
|
||||
|
||||
A module cannot do any of this from inside the website process. There is nowhere to
|
||||
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
|
||||
do not control. So the wire between them is a compatibility contract with a version
|
||||
on it.
|
||||
|
||||
`uo-link` declares `PROTOCOL_VERSION` in `sidecar/src/main.rs`, stamps
|
||||
`X-UOLink-Version` onto every response from `web.rs`, and **refuses a request whose
|
||||
declared version does not match** rather than parsing it optimistically. A refusal
|
||||
is a clear failure an operator can act on; a mis-parse is a wrong number on a page
|
||||
with nobody to tell.
|
||||
|
||||
Two habits come with that:
|
||||
|
||||
- **Bump the version in the same change that changes a message shape**, on every
|
||||
side that declares it. In this project a protocol bump has three declaration
|
||||
sites — the sidecar, the game-side overlay's manifest, and the documented spec —
|
||||
and the tooling refuses to pair components that disagree.
|
||||
- **Version the *shape*, not the content.** Adding a new event kind that old
|
||||
consumers ignore is not a break. Changing what a field means is, even when the
|
||||
JSON still parses.
|
||||
|
||||
## The worked example
|
||||
|
||||
`uo-link` is a complete implementation of everything above, and it is small enough
|
||||
to read:
|
||||
|
||||
| File | What it owns |
|
||||
| --- | --- |
|
||||
| `sidecar/src/shard.rs` | The listener the game dials into; one connection at a time, then accept the next. |
|
||||
| `sidecar/src/store.rs` | SQLite: event history, per-board snapshots, the series and the ruleset. |
|
||||
| `sidecar/src/web.rs` | HTTP + WebSocket for the website, the auth middleware, the version header and the lossy live fan-out. |
|
||||
| `sidecar/src/rpc.rs` | Request/reply correlation, so a website read can ask the game a question and match the answer. |
|
||||
| `sidecar/src/config.rs` | The config file, including a token generated on first run rather than defaulted. |
|
||||
|
||||
The protocol it speaks is specified in [`link/PLAN.md`][linkplan] and
|
||||
[`link/INTEGRATION.md`][linkint]. Those are normative for that sidecar; your game
|
||||
is not Ultima Online and your messages will not be its messages. What transfers is
|
||||
the structure — a listener the game dials into, a store written before anything is
|
||||
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 — and be sure the surface you are
|
||||
thinking of actually carries what your module needs, because that is where this
|
||||
question usually goes wrong.
|
||||
|
||||
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".
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
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
|
||||
|
||||
There is no template for a sidecar in this kit; it is your program, in your
|
||||
language, and the surface it must expose is the surface your module reads. What to
|
||||
settle before writing code:
|
||||
|
||||
1. **Which direction does the connection go?** The game dials out. If your game
|
||||
cannot — if it only accepts connections — then your sidecar is the client to the
|
||||
game and the listener for the website, and the rule that stands is the one that
|
||||
matters: the address of the game is known to the sidecar and to nothing else.
|
||||
2. **What is durable?** Everything a page must still render when the game is down.
|
||||
Write it before you forward it.
|
||||
3. **What is a snapshot and what is an event?** They are different storage
|
||||
problems: an event is appended and read back as history, a board is one current
|
||||
row per subject that you overwrite. `uo-link`'s store holds both, and keeping
|
||||
them separate is why a restart does not replay a year of events at a page.
|
||||
4. **What is the version, and where is it declared?** One place, on every response,
|
||||
refused on mismatch.
|
||||
5. **How does the website authenticate?** A token, generated rather than defaulted,
|
||||
always required.
|
||||
|
||||
Then chapter 4, if your game needs code inside it — which is the part where getting
|
||||
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
|
||||
246
book/04-game-plugin.md
Normal file
246
book/04-game-plugin.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# 4. The game-side plugin
|
||||
|
||||
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 **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
|
||||
a specific game engine and none of that transfers. The threading contract at the
|
||||
top of it does, entirely.
|
||||
|
||||
---
|
||||
|
||||
## The one rule: never block the game
|
||||
|
||||
A game server is a loop. Whatever thread runs the world is the thread that must
|
||||
not stop, and every rule in this chapter is a restatement of that.
|
||||
|
||||
**Emitting an event must enqueue and return.** It formats nothing expensive, waits
|
||||
on nothing, and touches no socket. In `BridgeLink.cs`, `Emit` is called from the
|
||||
game's own thread, appends a line to a queue, signals a waiting writer, and
|
||||
returns. A sidecar that is slow, wedged, restarting or entirely absent cannot stall
|
||||
the game, because the game never touches the connection.
|
||||
|
||||
The failure this prevents is not hypothetical, and it is not a small one: a socket
|
||||
write from the world thread against a peer that has stopped reading blocks until
|
||||
the OS buffer drains. That is a frozen game server, caused by a monitoring
|
||||
feature, at exactly the moment something else is already wrong.
|
||||
|
||||
## The queue is bounded, and it drops the oldest
|
||||
|
||||
An unbounded queue in front of an absent consumer is a memory leak with a delay
|
||||
timer on it. So the queue has a cap, and when it is full **the oldest record is
|
||||
dropped and counted**.
|
||||
|
||||
`Emit` bounds first and enqueues second, so the queue can sit transiently one over
|
||||
the cap and never grows without limit. `BridgeLink` exposes counters — sent,
|
||||
dropped, received, connects, write errors, current depth — and those counters are
|
||||
what an operator debugs from later.
|
||||
|
||||
Dropping is correct here, and it is worth being explicit about why: **telemetry is
|
||||
worth less than the game's memory.** If your sidecar has been unreachable for ten
|
||||
minutes, the useful thing is the most recent state of the world, not a
|
||||
ten-minute-old backlog delivered before it. Newest-wins is the honest policy, and
|
||||
"stall the game rather than lose an event" is never the trade to make.
|
||||
|
||||
Where losing events is genuinely unacceptable, the answer is the sidecar's store
|
||||
([chapter 3](03-sidecar.md)), not a bigger queue inside the game.
|
||||
|
||||
## One writer thread owns the socket
|
||||
|
||||
A dedicated thread drains the queue and owns the connection. It connects,
|
||||
reconnects with backoff, and writes.
|
||||
|
||||
**A single writer is also what keeps event ordering intact** — with two, the order
|
||||
events reach the sidecar is the order two threads happened to be scheduled in, and
|
||||
you find out from a board that says a player logged out before they logged in.
|
||||
|
||||
The reconnect loop in `LinkLoop` backs off with a low ceiling — a few seconds,
|
||||
because a loopback reconnect is cheap and a sidecar restart should cost a few
|
||||
seconds of buffering rather than half a minute of blindness. Pick your ceiling from
|
||||
what the connection actually costs, not from a habit borrowed from internet
|
||||
clients.
|
||||
|
||||
One detail there is subtle enough to be worth stealing: `BridgeLink` tags each
|
||||
connection attempt with an **epoch**, so a reader thread from a previous connection
|
||||
cannot tear down the connection that replaced it. Joining a thread can time out;
|
||||
the stale thread's cleanup then runs against whatever is current. If you write a
|
||||
reconnect loop, write it so a late-arriving cleanup from a dead connection is a
|
||||
no-op.
|
||||
|
||||
## Read the world only on the game's thread
|
||||
|
||||
Inbound is the mirror image. A reader thread parses lines off the socket, and then
|
||||
**hands each one to the game's own thread** to act on — `BridgeLink`'s `Dispatch`
|
||||
does it by scheduling a zero-delay callback on the game's timer, which is the
|
||||
engine's supported way in. The reader itself never touches the world's objects.
|
||||
|
||||
Two rules fall out and both are absolute:
|
||||
|
||||
- **Every read of the world happens on the world's thread.** Game engines are
|
||||
overwhelmingly single-threaded about their state, and reading a collection while
|
||||
the loop mutates it is a crash or, worse, a corruption you notice a week later.
|
||||
- **The writer thread only ever sees plain data.** Format your line — a string, a
|
||||
buffer, whatever your wire is — on the game's thread while the objects are safe
|
||||
to read, and hand the finished bytes over. Never hand the writer a live game
|
||||
object to serialise.
|
||||
|
||||
And an error boundary at the seam: a malformed command from the sidecar must never
|
||||
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
|
||||
picture, and it cannot ask the game for one without an inbound path you may not
|
||||
have built yet.
|
||||
|
||||
So **anything the sidecar needs up front is re-sent on every connect, not once at
|
||||
startup.** In `servuo-plugins` that is an explicit event: the link exposes a
|
||||
"connected" hook that runs on the game thread, and each feature area subscribes to
|
||||
it and re-emits its current state — the hello line, the house registry, the guild
|
||||
and governor boards, the market, the ruleset. A sidecar that has just started is
|
||||
therefore fully populated within one connection, with no negotiation.
|
||||
|
||||
The general form: **for every board your website renders, have exactly one place
|
||||
that can produce its current state, and call it on connect.** If you cannot name
|
||||
that place for some piece of state, your sidecar will eventually be missing it and
|
||||
nobody will know why.
|
||||
|
||||
## Events, snapshots, and the state that has neither
|
||||
|
||||
You will end up emitting two different kinds of thing, and confusing them is a
|
||||
design mistake that shows up as a bad page.
|
||||
|
||||
**An event** is something that happened, at a time: a player logged in, a house
|
||||
fell, a trade completed. Events are appended and read back as history.
|
||||
|
||||
**A snapshot** is the current state of a subject: this board's rows, this guild's
|
||||
membership, the published ruleset. Snapshots overwrite; nobody wants the history of
|
||||
a leaderboard's every intermediate ordering.
|
||||
|
||||
Send both, and be clear at the wire about which a message is — your sidecar's store
|
||||
handles them differently ([chapter 3](03-sidecar.md)), and a snapshot appended as
|
||||
history is a table that grows forever.
|
||||
|
||||
Then there is the state your engine gives you no hook for at all. Player vitals,
|
||||
decay timers, money supply: nothing fires when they change. `servuo-plugins` polls
|
||||
those on the game's own thread with repeating timers, in
|
||||
`overlay/Scripts/Custom/Bridge/BridgeSweeps.cs`, and the comment worth copying is
|
||||
that this is only acceptable **because the cost was measured**. A full pass of all
|
||||
three sweeps is well under a millisecond at that shard's scale. Measure yours
|
||||
before you add a timer to a game loop, and if a sweep is expensive, sample it —
|
||||
never move it off the game thread.
|
||||
|
||||
Two practical notes from that file, both general:
|
||||
|
||||
- **Emit a transition, not a level.** The decay sweep keeps the last known level per
|
||||
house and emits only when one changes, and it takes a silent baseline at startup
|
||||
so a restart does not re-announce every house's current state as news.
|
||||
- **Know when your engine suspends timers.** These do not fire during a world save,
|
||||
so a sweep that would have landed mid-save simply happens a few seconds later.
|
||||
That is fine for all three — but it is fine because someone checked, not by
|
||||
default.
|
||||
|
||||
## A checklist for the plugin you are about to write
|
||||
|
||||
1. The emit path enqueues and returns. Nothing on the game thread touches a socket.
|
||||
2. The queue is bounded and drops the oldest, and something counts the drops.
|
||||
3. One writer thread owns the connection; ordering is therefore intact.
|
||||
4. Reconnect with a bounded backoff; a stale connection's cleanup cannot affect a
|
||||
newer one.
|
||||
5. Inbound lines are marshalled onto the game thread before touching the world, and
|
||||
a handler that throws cannot escape into the engine.
|
||||
6. Every board's current state has exactly one producer, and all of them run on
|
||||
connect.
|
||||
7. Every world read is on the world's thread; the writer sees only plain data.
|
||||
8. Anything polled has had its cost measured against a realistic world.
|
||||
|
||||
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.
|
||||
|
||||
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||
364
book/05-events.md
Normal file
364
book/05-events.md
Normal 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
|
||||
126
book/README.md
126
book/README.md
@@ -1,99 +1,51 @@
|
||||
# The book
|
||||
|
||||
Four chapters, in the order the work happens. **None of them are written yet** —
|
||||
this is the outline, landed first so the shape can be argued with before the prose
|
||||
exists. Chapter status is in the table; a chapter that is not there yet is not
|
||||
there yet, rather than a stub that reads like an answer.
|
||||
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.
|
||||
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.
|
||||
|
||||
| # | Chapter | File | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Your first module in twenty minutes | `01-first-module.md` | not written |
|
||||
| 2 | The website module | `02-website-module.md` | not written |
|
||||
| 3 | The sidecar | `03-sidecar.md` | not written |
|
||||
| 4 | The game-side plugin | `04-game-plugin.md` | not written |
|
||||
| # | Chapter | What it covers |
|
||||
| --- | --- | --- |
|
||||
| 1 | [Your first module in twenty minutes](01-first-module.md) | Copy the template, rename it, build it, install it, see a page. No theory. |
|
||||
| 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. |
|
||||
|
||||
They are named but not linked on purpose: a link to a file that does not exist is
|
||||
the thing this repo's link check is for, and an outline should not be the first
|
||||
thing to fail it.
|
||||
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.
|
||||
|
||||
## 1. Your first module in twenty minutes
|
||||
**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.
|
||||
|
||||
Copy `template/`, rename it, build it, install it, see a page. No theory. The point
|
||||
is to reach a working module before learning anything, so that everything after it
|
||||
is a change to something that already runs rather than a step toward something that
|
||||
might.
|
||||
## What is normative, and what is here
|
||||
|
||||
- What the pieces of `template/` are, one paragraph each.
|
||||
- `module.json`: the fields you must change, and `coreApi`.
|
||||
- Building the client chunk. Why a module ships **prebuilt** and an operator never
|
||||
builds anything.
|
||||
- Installing it: the admin panel, the `MODULES` environment variable, or a directory
|
||||
on the volume.
|
||||
- Reading the state your module lands in, and the four ways it can fail to load.
|
||||
Nothing in these chapters is. Where a chapter and one of these disagree, the
|
||||
document is right and the chapter has a bug — [say so][issues]:
|
||||
|
||||
## 2. The website module
|
||||
| Authority | For |
|
||||
| --- | --- |
|
||||
| [`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 bulk of the kit.
|
||||
|
||||
- **`module.json`** — every field, and which are load-bearing at boot.
|
||||
- **The server entry point.** `register(ctx, api)`; what `ctx` hands you and why
|
||||
each member is handed rather than imported; the lazy-accessor pattern that lets a
|
||||
ported file keep a file-scope `require`, and the require-order rule that comes
|
||||
with it.
|
||||
- **The `register*` calls** — routes per tier, notification streams, announce legs,
|
||||
post hooks, extension slots. Worked examples of each, with the distinctions that
|
||||
are easy to get wrong (a leg is one-shot delivery with retry; a post hook is
|
||||
idempotent state that also runs on delete).
|
||||
- **The schema fragment.** Idempotent, replayed every boot, leading-verb allowlist,
|
||||
the table-prefix rule, and why there is no migration runner anywhere in this
|
||||
project. What belongs in `purge.sql` instead.
|
||||
- **The client half.** The prebuilt ESM chunk; `window.__rg`; the shared-dependency
|
||||
rule (core owns React and hands it over — a module that resolves its own gets two
|
||||
Reacts and a broken page); the Vite library build with anchored aliases and
|
||||
`external: []`, and *why* that combination rather than the obvious one.
|
||||
- **Routes, nav and features on the client**, and how a module's nav row becomes an
|
||||
ordinary row an operator can reorder, relabel or hide.
|
||||
- **The UI kit** — seven members, closed on purpose. What to do about the eighth
|
||||
thing you want.
|
||||
- **The OpenAPI fragment**, and how to generate it from your own registrations.
|
||||
- **Packaging and release CI**: the tarball, the install manifest, the checksum,
|
||||
and the version living in `module.json`.
|
||||
- **Boundaries.** What a module must not do, each with the failure it prevents.
|
||||
|
||||
## 3. The sidecar
|
||||
|
||||
Why it exists, why it is **not optional**, and what "thin" means for a game that
|
||||
already speaks a remote-control protocol.
|
||||
|
||||
- The invariant: your game is never network-reachable; it **dials out**, the
|
||||
sidecar listens, and only the website's backend talks to the sidecar.
|
||||
- **Persist before you forward.** The sidecar owns the durable copy — event
|
||||
history, the latest snapshot of every board, whatever a page must still be able
|
||||
to render when the game or the website is down. A live feed is allowed to be
|
||||
lossy *because* the store is not.
|
||||
- The wire as a **versioned compatibility contract** rather than a build
|
||||
dependency: a version on every response, a mismatch refused rather than
|
||||
mis-parsed, and what a bump obliges you to change in the same commit.
|
||||
- Auth, and why the sidecar is the only exposed part.
|
||||
- `uo-link` as the worked example, and what a *thin* sidecar for an RCON-style game
|
||||
keeps and drops.
|
||||
|
||||
## 4. The game-side plugin
|
||||
|
||||
The chapter with the least code and the highest stakes: a plugin that gets this
|
||||
wrong takes the game down when the sidecar wedges.
|
||||
|
||||
- **Never block the game thread.** Enqueue and return; a bounded, drop-oldest queue;
|
||||
a dedicated writer thread that drains it. Dropping the oldest event is correct,
|
||||
and stalling the game to avoid it is not.
|
||||
- **Read the world only on the game's own thread**, and hand plain data to the
|
||||
writer.
|
||||
- Reconnect, backoff, and what to send on connect so the sidecar can rebuild its
|
||||
picture without asking.
|
||||
- What to emit at all: the difference between an event stream and a state snapshot,
|
||||
and why both exist.
|
||||
- `servuo-plugins` as the worked example. The constraints are general; the C# is not.
|
||||
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
|
||||
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
{
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"branch": "edge",
|
||||
"ref": "c3120ea3daee579ea9948e9e466065f25ee4e92f",
|
||||
"branch": "main",
|
||||
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
|
||||
"why": [
|
||||
"The core this kit is written against, pinned to a commit rather than a branch.",
|
||||
"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."
|
||||
|
||||
142
scripts/checkChapterPaths.js
Normal file
142
scripts/checkChapterPaths.js
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
// Every path in this repo that a chapter names in backticks must exist.
|
||||
//
|
||||
// The book teaches out of `template/`: it says "open
|
||||
// `template/server/index.js`", "the aliases are in `template/client/vite.config.js`",
|
||||
// "your tables go in `template/server/db/schema.sql`". None of that is a markdown
|
||||
// link, so `checkLinks.js` never looks at it — and none of it is code, so nothing
|
||||
// else does either. Rename one template file and four chapters quietly point at
|
||||
// nothing, which is the exact rot this repo exists to be immune to.
|
||||
//
|
||||
// This is the cheap half of "is the book still true", and it is honest about
|
||||
// being only the half a machine can answer. Whether a paragraph has become wrong
|
||||
// about a file that still exists is a reviewer's job (MODULE_SYSTEM.md §2.10).
|
||||
//
|
||||
// ── What counts as a claim about this repo ────────────────────────────────────
|
||||
//
|
||||
// An inline code span whose text begins with one of this repo's own top-level
|
||||
// directories, `ANCHORS` below. That is what makes the check answerable: a
|
||||
// chapter also quotes `server/index.js` loosely, and `sidecar/src/store.rs`,
|
||||
// which lives in another repo entirely and cannot be resolved here. Anchoring on
|
||||
// our own directory names means every token this check reads is a claim it can
|
||||
// actually settle.
|
||||
//
|
||||
// **The anchors are stated, not derived from the tree**, and that is deliberate
|
||||
// for the reason core's own build guard states it (MODULE_API.md §3.6): a list
|
||||
// derived from what exists cannot fail when what exists changes. Rename
|
||||
// `template/` and a derived anchor set would simply stop checking every
|
||||
// `template/…` mention in the book, silently, at the moment they all became
|
||||
// wrong. So the anchors are written down — and each one must exist, or this check
|
||||
// fails. An anchor that has stopped matching is a check that has stopped
|
||||
// checking, the same rule the identifier exemptions in core's CI follow.
|
||||
//
|
||||
// Fenced blocks are excluded (`lib/markdown.js`). A fence in this book is often a
|
||||
// listing of the reader's own future tree, and their files are not ours.
|
||||
//
|
||||
// Usage: node scripts/checkChapterPaths.js (from the repo root)
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { codeSpans } = require('./lib/markdown')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// This repo's own top-level directories. See the note above on why this is a list
|
||||
// and not a directory scan.
|
||||
const ANCHORS = ['template/', 'book/', 'scripts/', 'ci/']
|
||||
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||
|
||||
/** Every markdown file in the repo, repo-relative, sorted. */
|
||||
function markdownFiles(dir = ROOT, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
markdownFiles(path.join(dir, entry.name), out)
|
||||
} else if (entry.name.toLowerCase().endsWith('.md')) {
|
||||
out.push(path.relative(ROOT, path.join(dir, entry.name)).split(path.sep).join('/'))
|
||||
}
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo paths a document claims, from its inline code spans.
|
||||
*
|
||||
* A span is a claim when it starts with an anchor and names something a
|
||||
* filesystem could answer for. Three kinds are skipped, each because the answer
|
||||
* would be "no" for a reason that is not a mistake:
|
||||
*
|
||||
* • a placeholder — `template/<id>/…`, `scripts/*.js` — which is a shape rather
|
||||
* than a path;
|
||||
* • a span with whitespace in it, which is a phrase or a command line
|
||||
* (`npm ci --prefix template/server` is not a path and its first word is not
|
||||
* an anchor either, but a span like `cd template/server && npm test` would
|
||||
* slip through on its first token without this);
|
||||
* • trailing prose punctuation, stripped rather than skipped, so `template/`
|
||||
* ending a sentence still resolves.
|
||||
*/
|
||||
function claimedPaths(markdown) {
|
||||
const found = []
|
||||
for (const { text, line } of codeSpans(markdown)) {
|
||||
const token = text.trim()
|
||||
if (/\s/.test(token)) continue
|
||||
if (!ANCHORS.some((a) => token.startsWith(a))) continue
|
||||
if (/[<>*?]|\.\.\./.test(token)) continue
|
||||
// A path may legitimately end in `/` (a directory); anything else in this set
|
||||
// is the sentence around it, not part of the name.
|
||||
const cleaned = token.replace(/[.,;:)\]]+$/, '')
|
||||
if (cleaned) found.push({ path: cleaned, line })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Everything wrong, as sentences. Empty means every claim resolves. */
|
||||
function problems({ claims, exists }) {
|
||||
const out = []
|
||||
|
||||
for (const anchor of ANCHORS) {
|
||||
const dir = anchor.replace(/\/$/, '')
|
||||
if (!exists(dir)) {
|
||||
out.push(
|
||||
`${anchor} is listed as an anchor and does not exist. ` +
|
||||
'Either restore it or update ANCHORS — an anchor that matches nothing is a ' +
|
||||
'check that has silently stopped checking.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { file, path: claimed, line } of claims) {
|
||||
if (!exists(claimed)) {
|
||||
out.push(`${file}:${line}: no such path — ${claimed}`)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { ANCHORS, claimedPaths, problems, markdownFiles }
|
||||
|
||||
if (require.main !== module) return
|
||||
|
||||
const files = markdownFiles()
|
||||
const claims = []
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(path.join(ROOT, file), 'utf8')
|
||||
for (const claim of claimedPaths(text)) claims.push({ file, ...claim })
|
||||
}
|
||||
|
||||
const exists = (p) => fs.existsSync(path.join(ROOT, p))
|
||||
const found = problems({ claims, exists })
|
||||
|
||||
if (found.length) {
|
||||
console.error(`\ncheckChapterPaths: ${found.length} problem(s):\n`)
|
||||
for (const p of found) console.error(` - ${p}`)
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`checkChapterPaths: ${claims.length} path(s) claimed across ${files.length} markdown file(s) — all present.`,
|
||||
)
|
||||
81
scripts/checkChapterPaths.test.js
Normal file
81
scripts/checkChapterPaths.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// The chapter-path check, checked.
|
||||
//
|
||||
// Same rule as the rename check's own suite: a check written when the thing it
|
||||
// guards is already clean never fires again, and nothing distinguishes "still
|
||||
// checking" from "quietly broken" without cases it is required to reject. Every
|
||||
// "must not catch" case below is a real span that appears in the book.
|
||||
//
|
||||
// No filesystem — `problems()` takes `exists` as an argument precisely so it can
|
||||
// be tested this way, and `claimedPaths()` is pure.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { ANCHORS, claimedPaths, problems } = require('./checkChapterPaths')
|
||||
|
||||
/** `problems()` over a fixture set of paths that exist. */
|
||||
const check = (claims, present) =>
|
||||
problems({ claims, exists: (p) => new Set([...present, ...ANCHORS.map((a) => a.replace(/\/$/, ''))]).has(p) })
|
||||
|
||||
test('a claim that resolves is not a problem', () => {
|
||||
assert.deepStrictEqual(check([{ file: 'book/01.md', line: 3, path: 'template/module.json' }],
|
||||
['template/module.json']), [])
|
||||
})
|
||||
|
||||
test('a claim that does not resolve fails, naming the file and line', () => {
|
||||
const found = check([{ file: 'book/02-website-module.md', line: 41, path: 'template/server/gone.js' }], [])
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0], /book\/02-website-module\.md:41.*template\/server\/gone\.js/)
|
||||
})
|
||||
|
||||
test('a missing anchor fails on its own', () => {
|
||||
// The half that keeps this check honest: if `template/` is renamed, every
|
||||
// template path in the book is wrong AND the check would stop looking at them.
|
||||
const found = problems({ claims: [], exists: (p) => p !== 'template' })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0], /template\/ is listed as an anchor and does not exist/)
|
||||
})
|
||||
|
||||
test('paths are read only from inline code spans', () => {
|
||||
const md = 'Open the entry point and read it: template/server/index.js, then stop.'
|
||||
assert.deepStrictEqual(claimedPaths(md), [])
|
||||
})
|
||||
|
||||
test('a code span inside a fenced block is not a claim', () => {
|
||||
// A fence is usually the reader's own future tree, and their files are not ours.
|
||||
const md = ['```', '`template/nope.js`', 'template/also-nope.js', '```'].join('\n')
|
||||
assert.deepStrictEqual(claimedPaths(md), [])
|
||||
})
|
||||
|
||||
test('a path in another repo is not this check\'s business', () => {
|
||||
const md = 'The store is `sidecar/src/store.rs`, and the plugin is `overlay/Scripts/Custom/Bridge/BridgeLink.cs`.'
|
||||
assert.deepStrictEqual(claimedPaths(md), [])
|
||||
})
|
||||
|
||||
test('a placeholder shape is not a path', () => {
|
||||
const md = 'Your copy lands at `template/<id>/module.json`, and the checks are `scripts/*.js`.'
|
||||
assert.deepStrictEqual(claimedPaths(md), [])
|
||||
})
|
||||
|
||||
test('a command line is not a path', () => {
|
||||
// The first token is an anchor in neither case, but a span that BEGINS with one
|
||||
// and carries arguments would otherwise be read as a filename with spaces in it.
|
||||
const md = 'Run `npm ci --prefix template/server`, or `template/server && npm test` if you must.'
|
||||
assert.deepStrictEqual(claimedPaths(md), [])
|
||||
})
|
||||
|
||||
test('trailing sentence punctuation is stripped, not skipped', () => {
|
||||
const md = 'It all lives under `template/`.'
|
||||
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/', line: 1 }])
|
||||
})
|
||||
|
||||
test('a claim on a later line reports that line', () => {
|
||||
const md = ['# Title', '', 'See `template/server/boot.js`.'].join('\n')
|
||||
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/server/boot.js', line: 3 }])
|
||||
})
|
||||
|
||||
test('every anchor is a directory of this repo', () => {
|
||||
// Stated, not derived (see the header) — so this asserts the stated list is
|
||||
// still the real one at the moment it is written down.
|
||||
assert.ok(ANCHORS.every((a) => a.endsWith('/')), 'anchors are directory prefixes')
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,19 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
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 = []) {
|
||||
@@ -38,31 +46,10 @@ function markdownFiles(dir = ROOT, out = []) {
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
// Fenced code blocks are stripped before links are read: a fence can legitimately
|
||||
// contain a path that does not exist (a directory listing of a project the reader
|
||||
// has not created yet), and flagging those would make the check useless in exactly
|
||||
// the document type this repo is made of. Stripped by walking lines and toggling
|
||||
// on a fence marker, rather than by regexp — a fence's own content can contain
|
||||
// anything, including a line that looks like the end of one.
|
||||
function stripFences(text) {
|
||||
const out = []
|
||||
let fence = null
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /^\s*(```+|~~~+)/.exec(line)
|
||||
if (fence) {
|
||||
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
|
||||
out.push('')
|
||||
continue
|
||||
}
|
||||
if (m) {
|
||||
fence = m[1]
|
||||
out.push('')
|
||||
continue
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
// Fenced code blocks are stripped before links are read (`lib/markdown.js`): a
|
||||
// fence can legitimately contain a path that does not exist — a directory listing
|
||||
// of a project the reader has not created yet — and flagging those would make the
|
||||
// check useless in exactly the document type this repo is made of.
|
||||
|
||||
/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */
|
||||
function linksIn(text) {
|
||||
|
||||
160
scripts/checkRenameSites.js
Normal file
160
scripts/checkRenameSites.js
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
// The rename checklist in `template/README.md`, checked against the tree.
|
||||
//
|
||||
// A reader's first action is to copy `template/` and make it theirs, and the only
|
||||
// thing telling them where the placeholder name is buried is that table. A
|
||||
// checklist nobody verifies is wrong by the second edit to the template — someone
|
||||
// adds a file, mentions the placeholder id in it, and every reader after that
|
||||
// ships a module with a stray `examplegame` in its OpenAPI tags.
|
||||
//
|
||||
// So this asserts the table and the tree agree, in BOTH directions:
|
||||
//
|
||||
// • every file that still mentions the placeholder is listed, and
|
||||
// • every listed file exists and still mentions it.
|
||||
//
|
||||
// The second half is the one that is easy to leave out and is the more valuable:
|
||||
// an entry that has stopped matching is an entry that will be read as instructions
|
||||
// to edit something that is not there. Same rule the identifier check in core's CI
|
||||
// follows about its own exemptions — an exemption that no longer matches fails the
|
||||
// build rather than being quietly tolerated.
|
||||
//
|
||||
// **Why the placeholder is `examplegame` and not `example`.** This is a whole-file
|
||||
// text search, and `example` appears in ordinary English ("for example") all over
|
||||
// prose that is not a rename site at all. A placeholder that cannot occur by
|
||||
// accident is what makes a check like this answerable rather than a source of
|
||||
// false alarms someone eventually learns to ignore.
|
||||
//
|
||||
// Usage: node scripts/checkRenameSites.js (from the repo root)
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
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"), 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
|
||||
// consequence of one.
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||
|
||||
// The checklist is the one file exempt from the scan: it is a table OF the
|
||||
// placeholder and would trivially list itself.
|
||||
const SELF = 'README.md'
|
||||
|
||||
/** Every file under `template/`, template-relative, sorted. */
|
||||
function templateFiles(dir = TEMPLATE, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
templateFiles(path.join(dir, entry.name), out)
|
||||
} else if (entry.isFile()) {
|
||||
out.push(path.relative(TEMPLATE, path.join(dir, entry.name)).split(path.sep).join('/'))
|
||||
}
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths the checklist names, read from between its two markers.
|
||||
*
|
||||
* Delimited by explicit HTML comments rather than by looking for a heading or for
|
||||
* every backticked path in the document: the README quotes plenty of paths in
|
||||
* prose and in its tree diagram, and none of those are checklist entries. An
|
||||
* explicit marker also means the table can be reformatted freely.
|
||||
*/
|
||||
function checklistPaths(markdown) {
|
||||
const start = markdown.indexOf('<!-- rename-sites -->')
|
||||
const end = markdown.indexOf('<!-- /rename-sites -->')
|
||||
if (start === -1 || end === -1 || end < start) {
|
||||
throw new Error(
|
||||
'template/README.md has no <!-- rename-sites --> … <!-- /rename-sites --> block. ' +
|
||||
'That block is the checklist this check exists to verify.',
|
||||
)
|
||||
}
|
||||
const table = markdown.slice(start, end)
|
||||
const paths = []
|
||||
for (const line of table.split('\n')) {
|
||||
// A table row whose first cell is a backticked path.
|
||||
const match = /^\|\s*`([^`]+)`\s*\|/.exec(line.trim())
|
||||
if (match) paths.push(match[1])
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/** Everything wrong, as sentences. Empty means the checklist is current. */
|
||||
function problems({ files, listed, contains }) {
|
||||
const out = []
|
||||
const listedSet = new Set(listed)
|
||||
|
||||
const duplicates = listed.filter((p, i) => listed.indexOf(p) !== i)
|
||||
for (const p of new Set(duplicates)) out.push(`${p} is listed in the checklist twice.`)
|
||||
|
||||
for (const file of files) {
|
||||
if (file === SELF) continue
|
||||
if (!contains(file)) continue
|
||||
if (!listedSet.has(file)) {
|
||||
out.push(
|
||||
`${file} still mentions the placeholder and is NOT in the rename checklist. ` +
|
||||
'Add a row for it, or take the placeholder out of the file.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const present = new Set(files)
|
||||
for (const file of listed) {
|
||||
if (!present.has(file)) {
|
||||
out.push(`the checklist lists ${file}, which does not exist. Remove the row or restore the file.`)
|
||||
} else if (!contains(file)) {
|
||||
out.push(
|
||||
`the checklist lists ${file}, which no longer mentions the placeholder. ` +
|
||||
'A row that has stopped matching tells a reader to edit something that is not there.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { PLACEHOLDER, checklistPaths, problems, templateFiles, TEMPLATE }
|
||||
|
||||
if (require.main !== module) return
|
||||
|
||||
if (!fs.existsSync(TEMPLATE)) {
|
||||
console.log('checkRenameSites: no template/ yet — nothing to check')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const files = templateFiles()
|
||||
const listed = checklistPaths(fs.readFileSync(CHECKLIST, 'utf8'))
|
||||
const contains = (file) => PLACEHOLDER.test(fs.readFileSync(path.join(TEMPLATE, file), 'utf8'))
|
||||
|
||||
const found = problems({ files, listed, contains })
|
||||
|
||||
if (found.length) {
|
||||
console.error(`\n${found.length} problem(s) with the rename checklist in template/README.md:\n`)
|
||||
for (const p of found) console.error(` - ${p}`)
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`OK — the rename checklist matches the template (${listed.length} files).`)
|
||||
119
scripts/checkRenameSites.test.js
Normal file
119
scripts/checkRenameSites.test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// The rename check, checked.
|
||||
//
|
||||
// A check that has never been shown to fail is a check nobody knows the state of.
|
||||
// This one gates the only instructions a reader has for the first thing they do
|
||||
// with the template, so both directions of it are exercised here against
|
||||
// fixtures — no filesystem, because `problems()` takes its three inputs as
|
||||
// arguments precisely so that it can be tested this way.
|
||||
//
|
||||
// Run by CI as `node --test scripts/`, which needs no dependencies and no
|
||||
// package.json: Node's own test runner, over a repo with nothing installed.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { PLACEHOLDER, checklistPaths, problems } = require('./checkRenameSites')
|
||||
|
||||
/** `problems()` with a `contains` built from a set of file names. */
|
||||
const check = (files, listed, dirty) =>
|
||||
problems({ files, listed, contains: (f) => new Set(dirty).has(f) })
|
||||
|
||||
test('a clean, complete checklist has no problems', () => {
|
||||
assert.deepStrictEqual(check(['a.js', 'b.js', 'clean.js'], ['a.js', 'b.js'], ['a.js', 'b.js']), [])
|
||||
})
|
||||
|
||||
test('a file that mentions the placeholder and is not listed fails', () => {
|
||||
const found = check(['a.js', 'new.js'], ['a.js'], ['a.js', 'new.js'])
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0], /new\.js.*NOT in the rename checklist/s)
|
||||
})
|
||||
|
||||
test('a listed file that no longer mentions the placeholder fails', () => {
|
||||
// The direction that is easy to leave out, and the more valuable of the two: a
|
||||
// row that has stopped matching reads as instructions to edit something that is
|
||||
// not there any more.
|
||||
const found = check(['a.js', 'b.js'], ['a.js', 'b.js'], ['a.js'])
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0], /b\.js.*no longer mentions/s)
|
||||
})
|
||||
|
||||
test('a listed file that has been deleted fails', () => {
|
||||
const found = check(['a.js'], ['a.js', 'gone.js'], ['a.js'])
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0], /gone\.js.*does not exist/s)
|
||||
})
|
||||
|
||||
test('a duplicated row fails', () => {
|
||||
const found = check(['a.js'], ['a.js', 'a.js'], ['a.js'])
|
||||
assert.ok(found.some((p) => /listed in the checklist twice/.test(p)))
|
||||
})
|
||||
|
||||
test('the checklist file itself is exempt', () => {
|
||||
// It is a table OF the placeholder, so it would otherwise always list itself.
|
||||
assert.deepStrictEqual(check(['README.md'], [], ['README.md']), [])
|
||||
})
|
||||
|
||||
test('the placeholder pattern matches every form a rename touches', () => {
|
||||
for (const text of [
|
||||
"const ID = 'examplegame'",
|
||||
'ExamplegameWorldStatus',
|
||||
'name: "Example Game"',
|
||||
"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}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the placeholder pattern does not fire on ordinary prose', () => {
|
||||
// The reason the id is `examplegame` rather than `example`: a check that
|
||||
// false-alarms on the word "example" in a comment is a check whose failures
|
||||
// stop being read.
|
||||
for (const text of [
|
||||
'// for example, a router mounted under /shard',
|
||||
'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}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('checklistPaths reads only the rows between the markers', () => {
|
||||
const md = [
|
||||
'# Heading',
|
||||
'',
|
||||
'Prose quoting `not/a/row.js` and a tree diagram.',
|
||||
'',
|
||||
'<!-- rename-sites -->',
|
||||
'',
|
||||
'| File | What to change |',
|
||||
'| --- | --- |',
|
||||
'| `module.json` | the id |',
|
||||
'| `server/core.js` | the message |',
|
||||
'',
|
||||
'<!-- /rename-sites -->',
|
||||
'',
|
||||
'More prose about `also/not/a/row.js`.',
|
||||
].join('\n')
|
||||
assert.deepStrictEqual(checklistPaths(md), ['module.json', 'server/core.js'])
|
||||
})
|
||||
|
||||
test('a README with no markers is an error, not an empty checklist', () => {
|
||||
// Silently reading zero entries would make every later assertion vacuous, and
|
||||
// the check would pass on a README whose checklist someone deleted.
|
||||
assert.throws(() => checklistPaths('# Heading\n\nno markers here\n'), /rename-sites/)
|
||||
})
|
||||
60
scripts/lib/markdown.js
Normal file
60
scripts/lib/markdown.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// The two pieces of markdown handling both checks in this directory need, in one
|
||||
// place rather than two copies that drift.
|
||||
//
|
||||
// Shared code, not a shared description. Core's own loader and its schema replay
|
||||
// use one splitter for the same reason (MODULE_API.md §2.6): two implementations
|
||||
// of "what counts as a code fence" would disagree eventually, and the check that
|
||||
// disagreed quietly would be the one still reporting green.
|
||||
|
||||
/**
|
||||
* The text with every fenced code block blanked out, line count preserved.
|
||||
*
|
||||
* Fenced blocks are stripped before either check reads anything, because a fence
|
||||
* can legitimately contain a path or a link that does not exist: a directory
|
||||
* listing of the project the reader has not created yet, a URL in an example. In
|
||||
* a repo made entirely of that document type, flagging them makes the check
|
||||
* useless.
|
||||
*
|
||||
* Done by walking lines and toggling on a fence marker rather than by regexp — a
|
||||
* fence's own content can contain anything, including a line that looks like the
|
||||
* end of one. Lines are replaced by empty strings rather than removed so that
|
||||
* line numbers in a report still point at the right place.
|
||||
*/
|
||||
function stripFences(text) {
|
||||
const out = []
|
||||
let fence = null
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /^\s*(```+|~~~+)/.exec(line)
|
||||
if (fence) {
|
||||
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
|
||||
out.push('')
|
||||
continue
|
||||
}
|
||||
if (m) {
|
||||
fence = m[1]
|
||||
out.push('')
|
||||
continue
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every inline code span outside a fenced block, with the 1-based line it is on.
|
||||
*
|
||||
* `[a](b)` inside backticks is an example rather than a link, and `template/x.js`
|
||||
* inside backticks is a claim about this repo's tree — which is why one check
|
||||
* throws these away and the other reads only these.
|
||||
*/
|
||||
function codeSpans(text) {
|
||||
const found = []
|
||||
stripFences(text).split(/\r?\n/).forEach((line, i) => {
|
||||
for (const m of line.matchAll(/`([^`]+)`/g)) {
|
||||
found.push({ text: m[1], line: i + 1 })
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
module.exports = { stripFences, codeSpans }
|
||||
22
template/.gitattributes
vendored
Normal file
22
template/.gitattributes
vendored
Normal 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
|
||||
426
template/.gitea/workflows/release.yml
Normal file
426
template/.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,426 @@
|
||||
# ── Publish an installable bundle (Gitea Actions) ─────────────────────────
|
||||
#
|
||||
# **This file does nothing where it sits.** Gitea only runs workflows found at
|
||||
# the REPOSITORY root, and inside the kit this one is at `template/.gitea/…`. It
|
||||
# arms itself the moment your copy of `template/` is a repository of its own —
|
||||
# which is the point: packaging is the part of a module you cannot guess at, and
|
||||
# copying a file beats retyping one out of a chapter.
|
||||
#
|
||||
# There is a GitHub Actions twin next door in `.github/workflows/release.yml`.
|
||||
# Keep whichever host you use and delete the other.
|
||||
#
|
||||
# ── What a release IS ─────────────────────────────────────────────────────
|
||||
#
|
||||
# **An operator never builds anything.** That constraint is the shape of the
|
||||
# whole module system, so a release is not source: it is the directory core's
|
||||
# loader expects to find at `modules/<id>/`, already assembled — the prebuilt
|
||||
# client chunk, any runtime dependency installed, the schema fragment and the
|
||||
# OpenAPI fragment — packed exactly as it will be unpacked. The website's admin
|
||||
# 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 DERIVED, and `module.json` is a floor ──────────────────
|
||||
#
|
||||
# **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, 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 ───────────────────────────────────────────────────
|
||||
#
|
||||
# 1. Change GITEA_HOST and REPO below to yours.
|
||||
# 2. Settings → Actions → Secrets: add REGISTRY_TOKEN, a Gitea access token
|
||||
# with `write:repository`, so the job can push the tag and create the release.
|
||||
|
||||
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
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# ── CHANGE THESE ────────────────────────────────────────────────────────
|
||||
GITEA_HOST: gitea.example.com
|
||||
REPO: your-org/your-module
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- 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")"
|
||||
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>}"
|
||||
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
# credential `actions/checkout` left in the git config while the release API
|
||||
# call 401s, leaving the repo tagged and unreleased.
|
||||
- name: Verify release credentials are configured
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
|
||||
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release credentials present."
|
||||
|
||||
- name: Build the client chunk
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
npm ci --prefix client
|
||||
npm run build --prefix client
|
||||
|
||||
# `--omit=dev`, and then PACKED. express and swagger-autogen are build- and
|
||||
# test-time only — the shipped half is handed express on `ctx` — so this
|
||||
# installs only what `dependencies` declares. Node resolves those by walking
|
||||
# up from `modules/<id>/server/`, which is why they ship INSIDE the tarball
|
||||
# rather than being installed on the operator's box.
|
||||
#
|
||||
# With no runtime dependencies at all this produces an empty tree and the
|
||||
# copy below is a no-op. That is the shape to aim for.
|
||||
- name: Install the shipped runtime dependencies
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: npm ci --omit=dev --prefix server
|
||||
|
||||
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||
#
|
||||
# Stated as an INCLUDE list, never an exclude list. An exclude list ships
|
||||
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||
# credential in it, an exclude list packs it and nobody finds out.
|
||||
- name: Assemble the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ID="${{ steps.plan.outputs.id }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/${ID}-${VERSION}"
|
||||
# `$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 — 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 e in server/*; do
|
||||
case "$(basename "$e")" in
|
||||
test|scripts|swagger|package-lock.json) continue ;;
|
||||
esac
|
||||
cp -r "$e" "$OUT/server/"
|
||||
done
|
||||
|
||||
# The client half is the BUILT chunk only. `client/src` is source an
|
||||
# operator has no use for and core will never read.
|
||||
mkdir -p "$OUT/client/dist"
|
||||
cp client/dist/entry.js "$OUT/client/dist/"
|
||||
|
||||
# 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. 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, 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" "$VERSION"
|
||||
|
||||
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
|
||||
SHA="$(sha256sum "dist/${ID}-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/${ID}-${VERSION}.tar.gz")"
|
||||
|
||||
# The install manifest — the URL an operator pastes into Admin →
|
||||
# Modules. A per-asset sha256 fetched over HTTPS, no signatures.
|
||||
jq -n \
|
||||
--arg id "$ID" \
|
||||
--arg name "$(node -p "require('./module.json').name")" \
|
||||
--arg version "$VERSION" \
|
||||
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||
--arg artifact "${ID}-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/${ID}-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
> "dist/${ID}-${VERSION}.json"
|
||||
|
||||
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
cat "dist/${ID}-${VERSION}.json"
|
||||
|
||||
# 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' && steps.plan.outputs.reuse_tag != 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
git config user.name 'Module CI'
|
||||
git config user.email 'ci@example.com'
|
||||
git tag -a "$TAG" -m "${{ steps.plan.outputs.id }} ${TAG}"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Create the release and upload the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ID="${{ steps.plan.outputs.id }}"
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}.json" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
391
template/.github/workflows/release.yml
vendored
Normal file
391
template/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,391 @@
|
||||
# ── Publish an installable bundle (GitHub Actions) ────────────────────────
|
||||
#
|
||||
# The GitHub twin of `.gitea/workflows/release.yml`. **Keep whichever host your
|
||||
# module lives on and delete the other** — nothing breaks if both are present,
|
||||
# but two release engines racing to tag the same version is a mess nobody needs.
|
||||
#
|
||||
# **This file does nothing where it sits.** Workflows run only from the
|
||||
# REPOSITORY root, and inside the kit this one is at `template/.github/…`. It arms
|
||||
# itself the moment your copy of `template/` is a repository of its own.
|
||||
#
|
||||
# Nothing about a module's release depends on where it is hosted: core installs
|
||||
# from a **URL**. Point Admin → Modules at the install manifest this job attaches
|
||||
# to the release and add your host to the website's `MODULE_SOURCE_HOSTS`
|
||||
# allowlist, and a module released here installs exactly like one released
|
||||
# anywhere else.
|
||||
#
|
||||
# ── What a release IS ─────────────────────────────────────────────────────
|
||||
#
|
||||
# **An operator never builds anything.** So a release is not source: it is the
|
||||
# directory core's loader expects to find at `modules/<id>/`, already assembled —
|
||||
# the prebuilt client chunk, any runtime dependency installed, the schema fragment
|
||||
# and the OpenAPI fragment — packed exactly as it will be unpacked. The website
|
||||
# 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 DERIVED, and `module.json` is a floor ──────────────────
|
||||
#
|
||||
# **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.
|
||||
#
|
||||
# ── Before this can run ───────────────────────────────────────────────────
|
||||
#
|
||||
# Nothing to configure. `GITHUB_TOKEN` is provided automatically; the `contents:
|
||||
# write` permission below is what lets it push a tag and create a release.
|
||||
|
||||
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
|
||||
|
||||
concurrency:
|
||||
group: release-module
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- 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")"
|
||||
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>}"
|
||||
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||
|
||||
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
|
||||
|
||||
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' }}
|
||||
run: |
|
||||
npm ci --prefix client
|
||||
npm run build --prefix client
|
||||
|
||||
# `--omit=dev`, and then PACKED. express and swagger-autogen are build- and
|
||||
# test-time only — the shipped half is handed express on `ctx` — so this
|
||||
# installs only what `dependencies` declares. Node resolves those by walking
|
||||
# up from `modules/<id>/server/`, which is why they ship INSIDE the tarball
|
||||
# rather than being installed on the operator's box.
|
||||
#
|
||||
# With no runtime dependencies at all this produces an empty tree and the
|
||||
# copy below is a no-op. That is the shape to aim for.
|
||||
- name: Install the shipped runtime dependencies
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: npm ci --omit=dev --prefix server
|
||||
|
||||
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||
#
|
||||
# Stated as an INCLUDE list, never an exclude list. An exclude list ships
|
||||
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||
# credential in it, an exclude list packs it and nobody finds out.
|
||||
- name: Assemble the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ID="${{ steps.plan.outputs.id }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/${ID}-${VERSION}"
|
||||
# `$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 — 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 e in server/*; do
|
||||
case "$(basename "$e")" in
|
||||
test|scripts|swagger|package-lock.json) continue ;;
|
||||
esac
|
||||
cp -r "$e" "$OUT/server/"
|
||||
done
|
||||
|
||||
# The client half is the BUILT chunk only.
|
||||
mkdir -p "$OUT/client/dist"
|
||||
cp client/dist/entry.js "$OUT/client/dist/"
|
||||
|
||||
# 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.
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
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" "$VERSION"
|
||||
|
||||
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
|
||||
SHA="$(sha256sum "dist/${ID}-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/${ID}-${VERSION}.tar.gz")"
|
||||
|
||||
# The install manifest — the URL an operator pastes into Admin →
|
||||
# Modules. A per-asset sha256 fetched over HTTPS, no signatures.
|
||||
jq -n \
|
||||
--arg id "$ID" \
|
||||
--arg name "$(node -p "require('./module.json').name")" \
|
||||
--arg version "$VERSION" \
|
||||
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||
--arg artifact "${ID}-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/v${VERSION}/${ID}-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
> "dist/${ID}-${VERSION}.json"
|
||||
|
||||
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
cat "dist/${ID}-${VERSION}.json"
|
||||
|
||||
- name: Tag and publish
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ID="${{ steps.plan.outputs.id }}"
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
|
||||
# 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" \
|
||||
--notes-file dist/CHANGELOG.md \
|
||||
"dist/${ID}-${VERSION}.tar.gz" \
|
||||
"dist/${ID}-${VERSION}.json" \
|
||||
dist/SHA256SUMS
|
||||
36
template/.gitignore
vendored
Normal file
36
template/.gitignore
vendored
Normal 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/
|
||||
674
template/LICENSE.md
Normal file
674
template/LICENSE.md
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
178
template/README.md
Normal file
178
template/README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# The template module
|
||||
|
||||
A Runic Gateway module that builds, loads, and does almost nothing. Copy it,
|
||||
rename it, and you have a running module before you have read a chapter.
|
||||
|
||||
Installed into a core, it adds:
|
||||
|
||||
- **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
|
||||
the shape: every seam a real module uses is here once, with the reasoning next to
|
||||
it, and CI proves the whole thing still builds against a pinned core.
|
||||
|
||||
## The tree
|
||||
|
||||
```
|
||||
module.json what core reads first — id, version, coreApi, mounts
|
||||
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, 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
|
||||
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, 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 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
|
||||
.github/workflows/release.yml the same, for GitHub. Keep one, delete the other.
|
||||
swagger-fragment.json generated; commit it
|
||||
```
|
||||
|
||||
Neither workflow runs while it sits inside the kit — a workflow is only read from
|
||||
a repository root. They arm themselves when your copy is a repository of its own.
|
||||
|
||||
## Build it
|
||||
|
||||
```bash
|
||||
npm ci --prefix server
|
||||
npm test --prefix server
|
||||
npm run check:imports --prefix server
|
||||
|
||||
npm ci --prefix client
|
||||
npm run build --prefix client # → client/dist/entry.js, the chunk that ships
|
||||
npm run check:externals --prefix client
|
||||
npm test --prefix client # build FIRST: two of these tests read the chunk
|
||||
```
|
||||
|
||||
`npm test` in `client/` passes with no build, by skipping the tests that need one.
|
||||
That is on purpose — the suite has to be runnable before the build — and it means
|
||||
**a CI job that tests without building is a job asking nothing.** Build first.
|
||||
|
||||
Regenerate the OpenAPI fragment whenever a route or an annotation changes:
|
||||
|
||||
```bash
|
||||
npm run swagger --prefix server # writes swagger-fragment.json
|
||||
npm run check:swagger --prefix server # fails if it is stale
|
||||
```
|
||||
|
||||
## Install it
|
||||
|
||||
Three supported ways, and none of them builds anything on the operator's machine:
|
||||
|
||||
1. **Admin → Modules**, pasting the URL of an install manifest — the JSON the
|
||||
release workflow attaches beside the tarball. This is how an operator installs
|
||||
your module.
|
||||
2. **The `MODULES` environment variable**, `<id>@<version>=<manifest URL>`, for a
|
||||
deployment that declares its module set rather than clicking it.
|
||||
3. **A directory on the volume.** Copy this whole tree to `<website>/modules/<id>/`
|
||||
and restart. The fastest loop while you are developing.
|
||||
|
||||
For (3): **copy, do not symlink.** The loader lists directory entries and a
|
||||
symlink is not a directory, so a linked module is skipped in silence.
|
||||
|
||||
## Rename it
|
||||
|
||||
Change `id` in `module.json` first, then work down the list. Nothing here is
|
||||
subtle, and the suites catch most of a half-finished job: `schema.test.js` fails
|
||||
the moment a table name stops matching the id, and `registration.test.js` fails
|
||||
when a nav row stops matching its route.
|
||||
|
||||
Your id must match `^[a-z][a-z0-9-]{1,31}$`, must equal the directory name core
|
||||
loads you from, and becomes your table prefix — so **no hyphen unless you enjoy
|
||||
backticking table names**.
|
||||
|
||||
<!-- rename-sites -->
|
||||
|
||||
| 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`, 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` |
|
||||
|
||||
<!-- /rename-sites -->
|
||||
|
||||
That table is checked. `scripts/checkRenameSites.js` at the root of this kit
|
||||
compares it against the tree on every pull request: a file that still mentions the
|
||||
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.
|
||||
|
||||
**`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. **`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
|
||||
|
||||
GPL-3.0-or-later, like everything else in this project — see
|
||||
[LICENSE.md](LICENSE.md). This directory is meant to be copied and made yours; it
|
||||
carries that licence, and so does anything derived from it.
|
||||
1792
template/client/package-lock.json
generated
Normal file
1792
template/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
template/client/package.json
Normal file
24
template/client/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "examplegame-module-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Client half of the Example Game module — a prebuilt ESM chunk core injects into its own SPA",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "node --test",
|
||||
"check:externals": "node scripts/checkExternals.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
172
template/client/scripts/checkExternals.js
Normal file
172
template/client/scripts/checkExternals.js
Normal file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.1's client half — what stayed a bare import in the built chunk ──────
|
||||
//
|
||||
// The server half's boundary check reads source. The client half's has to read
|
||||
// the BUILD OUTPUT, because the failure it exists to catch is invisible in
|
||||
// source: `import { useState } from 'react'` is correct in every file, and
|
||||
// whether it ends up as core's React or as a second copy welded into the chunk
|
||||
// is decided by vite.config.js's aliases. A missed alias changes nothing you can
|
||||
// see until a hook throws in the browser.
|
||||
//
|
||||
// So: build, then ask the artifact two questions.
|
||||
//
|
||||
// 1. **Is there a bare import left?** There must not be. Aliased shims are
|
||||
// bundled, so a surviving bare specifier means an alias missed and
|
||||
// `external` caught it — the loud failure the config prefers, but still a
|
||||
// failure, and better found here than by a browser refusing to load.
|
||||
// 2. **Did a shared dependency get bundled?** React's own source has
|
||||
// fingerprints that no module of ours would contain by accident. Finding
|
||||
// one means the chunk carries a second React, which is the silent version
|
||||
// of the same mistake and the one worth the fingerprint check.
|
||||
//
|
||||
// Run after `npm run build`, in CI, on the artifact that ships.
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
|
||||
|
||||
/**
|
||||
* Which characters of the chunk are inside a string, template or comment.
|
||||
*
|
||||
* **A check that reads code with a regexp fails on code that talks about
|
||||
* itself.** The first real chunk this script ever saw — slice 3's, the first
|
||||
* with any content in it — was rejected for importing `" }),\n !l && …`,
|
||||
* because a button reading "Approve and import" put the token `import`
|
||||
* immediately before a quote and the pattern could not tell that from a
|
||||
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
|
||||
* anything.
|
||||
*
|
||||
* The server half hit the same wall from the other side and answered it the same
|
||||
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
|
||||
* regexp. There is no regexp that distinguishes a keyword from the same letters
|
||||
* inside a string, because that distinction is a property of the parse.
|
||||
*
|
||||
* A mask rather than a rewrite, because the two halves of a real import — the
|
||||
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
|
||||
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
|
||||
* would take the answer with the noise.
|
||||
*/
|
||||
export function stringMask(src) {
|
||||
const inString = new Uint8Array(src.length)
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const two = src.slice(i, i + 2)
|
||||
if (two === '//') {
|
||||
const nl = src.indexOf('\n', i)
|
||||
const end = nl === -1 ? src.length : nl
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (two === '/*') {
|
||||
const close = src.indexOf('*/', i + 2)
|
||||
const end = close === -1 ? src.length : close + 2
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (c === '"' || c === "'" || c === '`') {
|
||||
// The opening quote itself stays unmasked: a specifier is read starting
|
||||
// at its quote, and the regexp below anchors on that.
|
||||
i += 1
|
||||
while (i < src.length && src[i] !== c) {
|
||||
// A backslash escapes the next character, including the closing quote.
|
||||
const step = src[i] === '\\' ? 2 : 1
|
||||
inString.fill(1, i, Math.min(i + step, src.length))
|
||||
i += step
|
||||
}
|
||||
i += 1
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return inString
|
||||
}
|
||||
|
||||
// Static and dynamic imports that survived into the output. A relative or
|
||||
// absolute specifier is a chunk that was split, which this build does not do —
|
||||
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
||||
//
|
||||
// **This pattern used to require whitespace after `import`, and so could not see
|
||||
// the one shape the build actually emits.** Minified Rollup output is
|
||||
// `import{useState}from"react"`, with no space anywhere in it; the old
|
||||
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
|
||||
// alternative, met `{` instead of a quote and matched nothing. A bare named
|
||||
// import — the most likely way for an alias to miss — would have passed this
|
||||
// check silently. It was found by writing the test for the false POSITIVE above
|
||||
// it, which is the argument for testing a check against both answers.
|
||||
//
|
||||
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
|
||||
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
|
||||
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
|
||||
// import's parenthesis.
|
||||
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
|
||||
|
||||
/** Every bare specifier the chunk still imports at runtime. */
|
||||
export function bareImports(chunk) {
|
||||
const masked = stringMask(chunk)
|
||||
const bare = new Set()
|
||||
for (const match of chunk.matchAll(IMPORTS)) {
|
||||
// Where the `import` keyword itself starts — one past the leading delimiter,
|
||||
// unless the match began at position 0.
|
||||
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
||||
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
|
||||
const specifier = match[1]
|
||||
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||
}
|
||||
return [...bare]
|
||||
}
|
||||
|
||||
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||
// packages ship and this module has no other reason to contain.
|
||||
//
|
||||
// These are matched against the RAW chunk, deliberately unmasked: a bundled
|
||||
// library's source arrives as code AND as its own error-message strings, and
|
||||
// masking would discard half the evidence. The direction of the risk is opposite
|
||||
// to the import check's — here a false positive is a fingerprint too generic,
|
||||
// which is a fixable choice of probe, not a property of the parse.
|
||||
const BUNDLED = [
|
||||
{ what: 'react', probe: 'react.development.js' },
|
||||
{ what: 'react', probe: 'Invalid hook call' },
|
||||
{ what: 'react-dom', probe: 'react-dom.development.js' },
|
||||
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
|
||||
]
|
||||
|
||||
/** Every problem with this chunk, as sentences. Empty means it ships. */
|
||||
export function problemsWith(chunk) {
|
||||
const problems = []
|
||||
const bare = bareImports(chunk)
|
||||
if (bare.length) {
|
||||
problems.push(
|
||||
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
|
||||
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
for (const { what, probe } of BUNDLED) {
|
||||
if (chunk.includes(probe)) {
|
||||
problems.push(
|
||||
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||
)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// Only when run as a script. Importing this from a test must not read a chunk
|
||||
// that may not have been built, and must not call process.exit.
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
||||
if (problems.length) {
|
||||
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||
for (const p of problems) console.error(` - ${p}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||
}
|
||||
42
template/client/src/api.js
Normal file
42
template/client/src/api.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ── This module's own API bindings ────────────────────────────────────────
|
||||
//
|
||||
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
||||
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
|
||||
// `ApiError` thrown on any non-2xx. The paths are yours, because the routes at
|
||||
// the other end are yours — `server/router/**` in this repo serves them.
|
||||
//
|
||||
// **Do not build your own fetch wrapper.** The primitive is what carries the
|
||||
// session cookie, the CSRF handling and the error shape core's `ErrorState`
|
||||
// knows how to render. A module that calls `fetch` directly gets none of that
|
||||
// and finds out one page at a time.
|
||||
//
|
||||
// Keeping the bindings in one file, ordered the way the routers are, is
|
||||
// convention rather than contract — but the two halves of every call live in
|
||||
// different directories and nothing checks them against each other, so anything
|
||||
// that makes a mismatch easy to see is worth doing.
|
||||
|
||||
import rg from './core.js'
|
||||
|
||||
const { request: req, BASE } = rg.api
|
||||
|
||||
// ── public ────────────────────────────────────────────────────────────────
|
||||
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
|
||||
// `/api/v1/public/world/status` — the route `server/router/public/world.router.js`
|
||||
// registers under the `/world` prefix `module.json` declares.
|
||||
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, clans, BASE }
|
||||
85
template/client/src/core.js
Normal file
85
template/client/src/core.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// ── What core hands this module, on the client side ────────────────────────
|
||||
//
|
||||
// The client twin of `server/core.js`, and deliberately much simpler than it.
|
||||
// Every page imports its layout, its state components and its hooks from here,
|
||||
// so the boundary is one file. The normative contract is MODULE_API.md §3.2 and
|
||||
// §3.4.
|
||||
//
|
||||
// **Why this is a plain read and the server's is a lazy accessor.** On the
|
||||
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
|
||||
// run — so `server/core.js` has to defer resolution to call time or a router
|
||||
// would capture `undefined` at file scope. There is no such gap here.
|
||||
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
|
||||
// and every module chunk is a deferred script the server injects *after* that
|
||||
// bundle's tag, so by the time the first line of this file executes the global
|
||||
// is already there. Reading it once, at module scope, is safe — and it means a
|
||||
// component keeps the ordinary `import { PageHeader } from '…'` shape rather
|
||||
// than being wrapped in an accessor that would cost it its identity.
|
||||
//
|
||||
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
|
||||
// also goes through — the shims touch the global before this file does, so a
|
||||
// check here would be unreachable.
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { rg as shared } from './shim/rg.js'
|
||||
|
||||
const rg = shared()
|
||||
|
||||
// ── The shared-dependency self-check ───────────────────────────────────────
|
||||
//
|
||||
// Keep this. There are two BUILD guards on the same rule — `assertSharedNotBundled`
|
||||
// in vite.config.js at resolution time, and `scripts/checkExternals.js` on the
|
||||
// finished artifact — and both reason about the chunk in isolation. Neither can
|
||||
// see the one failure that only exists once the chunk meets a core: a
|
||||
// `window.__rg` whose React is not the React that rendered the page.
|
||||
//
|
||||
// Identity is the only question worth asking. A second React satisfies every
|
||||
// type check, renders its first element happily, and then throws about an invalid
|
||||
// hook call somewhere unrelated — in a component that has nothing to do with it.
|
||||
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
|
||||
console.error(
|
||||
'[examplegame] the bindings this chunk imported are not the ones core published — it has bundled ' +
|
||||
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
Loading,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
Slot,
|
||||
} = rg.ui
|
||||
|
||||
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||
export const registry = rg.registry
|
||||
|
||||
// The core API version this module was loaded against. Logged by entry.jsx —
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check, only something to report.
|
||||
export const coreApiVersion = rg.version
|
||||
|
||||
export default rg
|
||||
137
template/client/src/entry.jsx
Normal file
137
template/client/src/entry.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// ── The client entry point ────────────────────────────────────────────────
|
||||
//
|
||||
// Core serves `dist/entry.js` from your module's directory and injects it into
|
||||
// its own HTML as a same-origin `<script type="module" src>` before `</body>`.
|
||||
// This file registers what the module has; core renders it. Normative:
|
||||
// MODULE_API.md §3.3.
|
||||
//
|
||||
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
||||
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
||||
// is published — and before core's first render. There is no subscription and no
|
||||
// late registration: a module that registered asynchronously would register after
|
||||
// the route table had been read, and the symptom is a page that redirects home
|
||||
// with nothing logged anywhere.
|
||||
//
|
||||
// So everything below is a plain top-level call and every page is a STATIC
|
||||
// import. Lazy-loading the routes is the natural instinct for a chunk that grows,
|
||||
// and it is the one thing this seam cannot have.
|
||||
|
||||
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.
|
||||
const ID = 'examplegame'
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Paths are relative to your module's namespace and core prefixes them. Whatever
|
||||
// you write here, a public route lands at `/<id>/<path>`, an admin route at
|
||||
// `/admin/<id>/<path>` and a player route at `/player/<id>/<path>`. You cannot
|
||||
// write the segment your routes hang under, which is the point: two modules
|
||||
// installed side by side cannot collide, and an operator can see from a URL which
|
||||
// module served it.
|
||||
//
|
||||
// So this one page is at `/examplegame/status`.
|
||||
//
|
||||
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
||||
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||
// possible, because the sidebar and the route table have to agree about who may
|
||||
// see what, and they only do if one thing decides.
|
||||
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 /> },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A registered row is an ORDINARY row from here on. It interleaves into core's
|
||||
// own navigation, and an operator can reorder it, relabel it or hide it from the
|
||||
// admin nav editor exactly as they can core's — because the interleave happens
|
||||
// before the override merge, and the override layer is keyed by `to`.
|
||||
//
|
||||
// Three fields worth knowing before you need them:
|
||||
//
|
||||
// • `order` places the row among core's, which are keyed by their index. A row
|
||||
// with NO order appends after them, rather than defaulting to 0 — otherwise
|
||||
// "I didn't ask for a position" would mean "put me first".
|
||||
// • `group` (admin sidebar) names an existing core group; an unknown name
|
||||
// appends a new group at the end rather than dropping the row.
|
||||
// • `icon` is a component, and core supplies no fallback. Public header rows
|
||||
// carry no icons, so there is none here — but an admin or player row without
|
||||
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||
// Match the nav you are landing in: the admin sidebar draws at 18px with a
|
||||
// 1.6 stroke, the player portal at 16px with a 2.
|
||||
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
|
||||
// global is otherwise invisible from the browser, which is where the client half
|
||||
// actually fails.
|
||||
console.info(`[${ID}] registered against core API ${coreApiVersion}`)
|
||||
113
template/client/src/routes/public/Clan.jsx
Normal file
113
template/client/src/routes/public/Clan.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
template/client/src/routes/public/Clans.jsx
Normal file
56
template/client/src/routes/public/Clans.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
template/client/src/routes/public/WorldStatus.jsx
Normal file
81
template/client/src/routes/public/WorldStatus.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// ── The one page ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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 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'
|
||||
|
||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||
// one fewer thing an operator ships.
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
}
|
||||
|
||||
export default function WorldStatus() {
|
||||
// `useAsync` is core's fetch/loading/error hook, and the three components
|
||||
// below are its three states. Using them rather than rolling your own is what
|
||||
// makes a module page indistinguishable from a core one while it loads and
|
||||
// while it fails.
|
||||
const { data, loading, error } = useAsync(() => api.world.status(), [])
|
||||
|
||||
return (
|
||||
<PublicLayout shell="narrow">
|
||||
<PageHeader
|
||||
title="World status"
|
||||
// `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 />}
|
||||
{error && <ErrorState error={error} />}
|
||||
|
||||
{data && (
|
||||
<div style={{ display: 'grid', gap: '0.75rem', maxWidth: '32rem' }}>
|
||||
<p>
|
||||
<strong>{data.worldName || 'The world'}</strong> is{' '}
|
||||
{data.online ? 'online' : 'offline'}
|
||||
{data.online && data.players > 0 ? ` with ${data.players} playing` : ''}.
|
||||
</p>
|
||||
<p style={{ opacity: 0.7 }}>
|
||||
Last reported {ago(data.updatedAt)}
|
||||
{/* `stale` is a first-class part of the answer rather than something
|
||||
the page infers from a timestamp. The server decides what counts
|
||||
as stale, because the server is what knows how often the game is
|
||||
supposed to check in. */}
|
||||
{data.stale ? ' — this is out of date, so the world is shown as offline.' : '.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
16
template/client/src/shim/jsx-runtime.js
Normal file
16
template/client/src/shim/jsx-runtime.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// `react/jsx-runtime`, from core.
|
||||
//
|
||||
// Every .jsx file this module compiles becomes imports from `react/jsx-runtime`
|
||||
// under the automatic runtime, which is the default the tooling assumes. Those
|
||||
// have to resolve to CORE's React like every other import — a second jsx runtime
|
||||
// bound to a second React is the same one-React violation as bundling `react`
|
||||
// itself, only harder to see, because it shows up as a hook dispatcher error in
|
||||
// a component that looks fine.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const jsxRuntime = rg().jsxRuntime
|
||||
|
||||
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||
|
||||
export default jsxRuntime.default ?? jsxRuntime
|
||||
14
template/client/src/shim/react-dom.js
vendored
Normal file
14
template/client/src/shim/react-dom.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// `react-dom/client`, from core.
|
||||
//
|
||||
// A module never calls `createRoot` — core owns the root and the module renders
|
||||
// inside it. This exists because a transitive import can still reach for
|
||||
// react-dom, and one that resolved to a bundled copy would put a second
|
||||
// renderer in the page.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const reactDom = rg().reactDom
|
||||
|
||||
export default reactDom.default ?? reactDom
|
||||
|
||||
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom
|
||||
32
template/client/src/shim/react-router-dom.js
vendored
Normal file
32
template/client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
// `react-router-dom`, from core.
|
||||
//
|
||||
// The sharpest of the four, because router state is not just a library — it is
|
||||
// one live navigation context. A module with its own copy would get a router
|
||||
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
|
||||
// instead of the SPA, on a page that otherwise renders perfectly.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const router = rg().router
|
||||
|
||||
export default router.default ?? router
|
||||
|
||||
export const {
|
||||
BrowserRouter,
|
||||
Link,
|
||||
NavLink,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
createSearchParams,
|
||||
generatePath,
|
||||
matchPath,
|
||||
useLocation,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
useParams,
|
||||
useResolvedPath,
|
||||
useSearchParams,
|
||||
} = router
|
||||
50
template/client/src/shim/react.js
vendored
Normal file
50
template/client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
// The shared React, taken from core rather than bundled.
|
||||
//
|
||||
// Why a shim file exists at all (MODULE_API.md §3.6, and the spike proved it the
|
||||
// hard way): Rollup's `external` alone emits a bare `import 'react'` into the
|
||||
// chunk, which the browser cannot resolve without an import map — and an import
|
||||
// map has to be an inline `<script type="importmap">`, which core's
|
||||
// `script-src 'self'` forbids. `output.globals` does not help either; it is
|
||||
// iife/umd only, and this is an ES module. So each shared dependency is aliased
|
||||
// to a two-line module that re-exports from the global core published before any
|
||||
// module chunk evaluated.
|
||||
//
|
||||
// The named re-exports are not decoration: `import { useState } from 'react'`
|
||||
// compiles to a named import, and a module with only a default export would fail
|
||||
// at link time in the browser with a message about the binding, not about this.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const react = rg().react
|
||||
|
||||
export default react.default ?? react
|
||||
|
||||
export const {
|
||||
Children,
|
||||
Component,
|
||||
Fragment,
|
||||
StrictMode,
|
||||
Suspense,
|
||||
cloneElement,
|
||||
createContext,
|
||||
createElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
lazy,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useDebugValue,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useInsertionEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
useTransition,
|
||||
} = react
|
||||
29
template/client/src/shim/rg.js
Normal file
29
template/client/src/shim/rg.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// The one place this module reads `window.__rg`, and the one place that says
|
||||
// something useful when it is not there.
|
||||
//
|
||||
// Every shim beside this file, and `src/core.js`, go through here. That is not
|
||||
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
|
||||
// modules evaluate dependencies in the source order of their import statements,
|
||||
// so "put the friendly check in the file that is imported first" is a guarantee
|
||||
// that survives exactly until someone sorts the imports. Whichever module the
|
||||
// bundler happens to reach first, it reaches `window.__rg` through this.
|
||||
//
|
||||
// A missing global means core did not publish its shared dependencies before
|
||||
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
|
||||
// §3.1), not a fault in this module. Without this, the first symptom is
|
||||
// "Cannot read properties of undefined (reading 'react')" thrown from a file
|
||||
// called react.js, which reads like the module bundled React wrong — the
|
||||
// opposite of what happened.
|
||||
export function rg() {
|
||||
const shared = window.__rg
|
||||
if (!shared) {
|
||||
throw new Error(
|
||||
'[examplegame] window.__rg is missing — core did not publish its shared dependencies before this ' +
|
||||
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
|
||||
'fault in this module.',
|
||||
)
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
export default rg
|
||||
154
template/client/test/build.test.js
Normal file
154
template/client/test/build.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// What can be checked about the client half without a browser.
|
||||
//
|
||||
// Not much, and being honest about that is the point: the client half's real
|
||||
// failures are timing and resolution, and neither has a shape a DOM-less test
|
||||
// runner can see. MODULE_API.md §7.7's four-step browser smoke is what actually
|
||||
// proves this half works, and it is re-run whenever this seam changes.
|
||||
//
|
||||
// What IS testable here is the configuration that decides resolution — and one
|
||||
// of these tests exists because the trap it guards cost this project real time: Vite's object-form `resolve.alias` does PREFIX matching, so a `react`
|
||||
// key silently also rewrites `react/jsx-runtime`. An anchored regexp in the
|
||||
// array form cannot. That is a property of the config, and a test can hold it.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CLIENT = path.resolve(HERE, '..')
|
||||
|
||||
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
||||
const configModule = await import('../vite.config.js')
|
||||
const config = configModule.default
|
||||
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
||||
|
||||
test('every alias is an anchored regexp, never a bare prefix string', () => {
|
||||
const aliases = config.resolve.alias
|
||||
assert.ok(Array.isArray(aliases), 'alias must use the ARRAY form — the object form prefix-matches')
|
||||
for (const { find } of aliases) {
|
||||
assert.ok(find instanceof RegExp, `alias "${find}" is a string; a string prefix-matches`)
|
||||
assert.ok(find.source.startsWith('^') && find.source.endsWith('$'), `alias ${find} is not anchored`)
|
||||
}
|
||||
})
|
||||
|
||||
test('react and react/jsx-runtime resolve to different shims', () => {
|
||||
// The exact collision the object form causes. Asserted on the outcome rather
|
||||
// than on the config's shape, so it keeps holding however the config is
|
||||
// rewritten.
|
||||
const resolve = (specifier) =>
|
||||
config.resolve.alias.find(({ find }) => find.test(specifier))?.replacement
|
||||
assert.ok(resolve('react'))
|
||||
assert.ok(resolve('react/jsx-runtime'))
|
||||
assert.notStrictEqual(resolve('react'), resolve('react/jsx-runtime'))
|
||||
})
|
||||
|
||||
test('every shared dependency is aliased', () => {
|
||||
for (const specifier of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'react-router-dom']) {
|
||||
assert.ok(
|
||||
config.resolve.alias.some(({ find }) => find.test(specifier)),
|
||||
`${specifier} is not aliased — it would be bundled, giving the page a second copy`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('rollup external stays empty — it preempts the aliases rather than backing them up', () => {
|
||||
// Rollup asks `external` BEFORE Vite's alias resolver runs, so a specifier
|
||||
// listed in both is marked external and never aliased. The chunk then ships
|
||||
// bare `import 'react'`, which no browser can resolve without an import map
|
||||
// and CSP forbids one. §3.6 shows both; they do not compose.
|
||||
assert.deepStrictEqual(config.build.rollupOptions.external, [])
|
||||
})
|
||||
|
||||
test('the not-bundled guard covers every shared specifier and is not derived from them', () => {
|
||||
// The direction of this dependency is the finding. Deriving the forbidden
|
||||
// package list FROM the alias list means deleting an alias also deletes the
|
||||
// guard against what that alias prevented — which is precisely when the guard
|
||||
// is needed. So the guard states the contract, and this asserts the aliases
|
||||
// stay inside it.
|
||||
const packages = new Set(guardedPackages)
|
||||
for (const { specifier } of SHARED) {
|
||||
const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]
|
||||
assert.ok(packages.has(pkg), `${pkg} is aliased but not guarded against being bundled`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every alias points at a shim file that exists', () => {
|
||||
for (const { find, replacement } of config.resolve.alias) {
|
||||
assert.ok(fs.existsSync(replacement), `alias ${find} points at a missing file: ${replacement}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the build emits one unhashed entry.js, which is what module.json names', () => {
|
||||
assert.deepStrictEqual(config.build.lib.formats, ['es'])
|
||||
assert.strictEqual(config.build.lib.fileName(), 'entry.js')
|
||||
const manifest = JSON.parse(fs.readFileSync(path.resolve(CLIENT, '..', 'module.json'), 'utf8'))
|
||||
assert.strictEqual(manifest.client.entry, 'client/dist/entry.js')
|
||||
assert.strictEqual(config.build.outDir, 'dist')
|
||||
})
|
||||
|
||||
test('modulePreload polyfilling stays off — an inline bootstrap is refused under CSP', () => {
|
||||
assert.strictEqual(config.build.modulePreload.polyfill, false)
|
||||
})
|
||||
|
||||
test('exactly one file reads window.__rg, and every shim goes through it', () => {
|
||||
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
|
||||
// makes the "core did not publish its dependencies" message reachable. The
|
||||
// shims touch the global before anything else in the chunk does, so a check
|
||||
// placed in the first-imported file is a guarantee that lasts until someone
|
||||
// sorts the imports.
|
||||
const dir = path.join(CLIENT, 'src', 'shim')
|
||||
const shims = fs.readdirSync(dir)
|
||||
assert.ok(shims.length >= 5)
|
||||
for (const file of shims) {
|
||||
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
||||
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
|
||||
if (file === 'rg.js') {
|
||||
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
|
||||
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
|
||||
continue
|
||||
}
|
||||
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
|
||||
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
|
||||
// A shim may import its sibling helper and nothing else — anything further
|
||||
// would be a shim with a dependency to resolve, the problem it exists to remove.
|
||||
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the built chunk has no bare imports and bundles no shared dependency', () => {
|
||||
// The artifact check itself, over the artifact that ships. Skipped rather than
|
||||
// failed when there is no build: `npm test` must be runnable before `npm run
|
||||
// build`, and CI runs them in order.
|
||||
const chunk = path.join(CLIENT, 'dist', 'entry.js')
|
||||
if (!fs.existsSync(chunk)) return
|
||||
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
|
||||
})
|
||||
|
||||
test('an import inside a string is not an import — the check reads code, not text', () => {
|
||||
// The regression that made this necessary: the first chunk with real content
|
||||
// in it had a button labelled "Approve and import" put the token
|
||||
// immediately before a quote. The check rejected the whole build, naming a
|
||||
// fragment of minified JSX as the offending specifier.
|
||||
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
|
||||
assert.deepStrictEqual(bareImports(uiCopy), [])
|
||||
|
||||
// Neither is one in a comment, or in a template literal.
|
||||
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
|
||||
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
|
||||
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
|
||||
|
||||
// And a real one still is, in each form the build could emit.
|
||||
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
|
||||
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
|
||||
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
|
||||
// A relative specifier is a split chunk, not a shared dependency: not our concern.
|
||||
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
|
||||
|
||||
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
|
||||
// string must not end it early and leave the tail looking like code.
|
||||
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
||||
})
|
||||
222
template/client/test/registration.test.js
Normal file
222
template/client/test/registration.test.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// ── What the chunk registers, checked without a browser ───────────────────
|
||||
//
|
||||
// `build.test.js` says the honest thing about this half: its real failures are
|
||||
// timing and resolution, and a DOM-less runner cannot see either. MODULE_API.md
|
||||
// §7.7's browser smoke is what proves the client half works, and nothing here
|
||||
// replaces it.
|
||||
//
|
||||
// What a test CAN do is read back what the chunk asked for. Registration is the
|
||||
// one thing the chunk does at evaluation time, and it does it through an object
|
||||
// core hands it — so: stand up a fake `window.__rg` with a recording registry and
|
||||
// the real React behind it, import the BUILT artifact, and inspect the result. No
|
||||
// DOM is needed because nothing renders; `<WorldStatus />` is `jsx(WorldStatus)`,
|
||||
// an object, and the route table is full of them by design.
|
||||
//
|
||||
// It catches a page that silently stops being routed, a nav row whose `to` drifts
|
||||
// from its route's path, and the whole registration surface disappearing because
|
||||
// something threw halfway down entry.jsx.
|
||||
//
|
||||
// **It runs against `dist/entry.js`, so build before you test.** The skip below
|
||||
// is deliberate — `npm test` has to be runnable before `npm run build` — which
|
||||
// means a CI job that tests without building is a job asking nothing at all. Ours
|
||||
// builds first, on purpose.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import * as react from 'react'
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
import * as router from 'react-router-dom'
|
||||
|
||||
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 })
|
||||
|
||||
function fakeRg() {
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
const extensions = new Map()
|
||||
const declaredSlots = []
|
||||
return {
|
||||
version: manifest.coreApi.replace(/^\D+/, ''),
|
||||
react,
|
||||
jsxRuntime,
|
||||
router,
|
||||
// `react-dom/client` is imported for the identity check in core.js and never
|
||||
// called — `createRoot` in a DOM-less process would throw. The shim reads
|
||||
// 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', 'Slot']
|
||||
.map((n) => [n, stub(n)]),
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
registry: {
|
||||
registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
|
||||
}
|
||||
},
|
||||
registerNav(id, { area, items }) {
|
||||
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||
},
|
||||
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
|
||||
registerExtension(id, slot, Component) {
|
||||
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, declaredSlots }),
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded once: an ES module is evaluated a single time per process however many
|
||||
// times it is imported, so every test below reads the same registration pass —
|
||||
// which is also how it behaves in a browser.
|
||||
let registered = null
|
||||
let skip = false
|
||||
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
skip = true
|
||||
} else {
|
||||
const rg = fakeRg()
|
||||
globalThis.window = { __rg: rg }
|
||||
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
|
||||
registered = rg._read()
|
||||
}
|
||||
|
||||
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
|
||||
|
||||
it('registers at least one route, namespaced under the module id', () => {
|
||||
const all = Object.values(registered.routes).flat()
|
||||
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
for (const r of list) {
|
||||
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
|
||||
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every route path is distinct within its area', () => {
|
||||
// Two routes on one path is a page that can never be reached, and React
|
||||
// renders the first one without complaint.
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
const paths = list.map((r) => r.path)
|
||||
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('every nav row points at a route this module actually registered', () => {
|
||||
// The agreement that matters, and the one that rots quietly: a row survives a
|
||||
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
|
||||
// the FULL rendered path (`/examplegame/status`); routes carry the namespaced
|
||||
// one (`examplegame/status`). Reconciling the two is the whole test.
|
||||
const rendered = {
|
||||
public: (p) => `/${p}`,
|
||||
admin: (p) => `/admin/${p}`,
|
||||
player: (p) => `/player/${p}`,
|
||||
}
|
||||
for (const [area, rows] of Object.entries(registered.nav)) {
|
||||
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
|
||||
for (const row of rows) {
|
||||
assert.ok(reachable.has(row.to), `${area} nav row "${row.label}" links to ${row.to}, which no route serves`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every admin and player nav row carries an icon', () => {
|
||||
// Both of those navs draw a glyph on every core row, so a row without one reads
|
||||
// as breakage rather than as a design — and core's player portal used to render
|
||||
// `<n.icon />` unguarded, which blanked the entire portal with React error #130
|
||||
// the first time a module registered a row without one. Core guards it now; a
|
||||
// missing icon there is still a visible defect and this is the cheap place to
|
||||
// catch it. The PUBLIC header is text buttons and is deliberately excluded.
|
||||
for (const area of ['admin', 'player']) {
|
||||
for (const row of registered.nav[area]) {
|
||||
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('a nav row that gates on a feature has a provider to resolve it', () => {
|
||||
// Resolution is by the REGISTERING module (§3.3), and every unknown fails OPEN.
|
||||
// So a row carrying a `feature` from a module that registered no provider is a
|
||||
// row that always shows — which re-advertises a surface an operator hid.
|
||||
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
|
||||
if (gated.length === 0) return
|
||||
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('every slot module.json declares is one the chunk fills', () => {
|
||||
// `module.json` declares SERVER slots, and the loader validates those before
|
||||
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||
// knows nothing about them — so this is the one place the two halves meet.
|
||||
for (const slot of manifest.extensions || []) {
|
||||
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
|
||||
}
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
137
template/client/vite.config.js
Normal file
137
template/client/vite.config.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// ── The client half's library build ────────────────────────────────────────
|
||||
//
|
||||
// Produces `dist/entry.js`: one prebuilt ES module that core injects as a
|
||||
// same-origin `<script type="module" src>` before `</body>`. The operator never
|
||||
// builds anything (MODULE_SYSTEM.md §1.14), so this config is not a developer
|
||||
// convenience — it is how the artifact that ships is made, and CI runs it.
|
||||
//
|
||||
// The normative contract is MODULE_API.md §3.6. Three mechanical details in here
|
||||
// were each found the hard way and are worth reading before changing anything.
|
||||
//
|
||||
// **1. `resolve.alias` uses the ARRAY form with anchored regexes.** Vite's object
|
||||
// form does PREFIX matching, so a `react` key also rewrites `react/jsx-runtime`
|
||||
// — silently, to the wrong shim, and the chunk then fails at its first element
|
||||
// with a message about `jsx` not being a function. `^react$` and
|
||||
// `^react/jsx-runtime$` cannot collide.
|
||||
//
|
||||
// **2. The aliases replace `external`; they do not accompany it.** §3.6 shows
|
||||
// both, and they do not compose: Rollup asks `external` BEFORE Vite's alias
|
||||
// resolver runs, so a specifier listed there is marked external and never
|
||||
// aliased. The chunk then ships bare `import 'react'` specifiers, which the
|
||||
// browser cannot resolve without an import map — and core's `script-src 'self'`
|
||||
// forbids the inline script an import map has to be. (`output.globals` would
|
||||
// have covered iife/umd and does nothing for an ES module.) The first real module
|
||||
// shipped with both, built cleanly, and emitted exactly that chunk;
|
||||
// `scripts/checkExternals.js` is what caught it. So: alias only, and nothing in
|
||||
// `external`.
|
||||
//
|
||||
// **3. What `external` was there to guard is guarded by `assertSharedNotBundled`
|
||||
// below.** The risk it was covering is real — an alias that misses means a
|
||||
// second React welded into the chunk, which loads fine and then throws about an
|
||||
// invalid hook call somewhere unrelated. A resolution-time assertion catches
|
||||
// that precisely, at build time, instead of by looking for fingerprints in
|
||||
// minified output afterwards.
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const shim = (name) => fileURLToPath(new URL(`./src/shim/${name}.js`, import.meta.url))
|
||||
|
||||
// The shared dependencies, in one place: what a module must never bundle, and
|
||||
// the shim it is aliased to instead. Adding to this list means adding to
|
||||
// `window.__rg` in core, which is a MODULE_API minor bump — not a decision this
|
||||
// file can make on its own.
|
||||
export const SHARED = [
|
||||
{ specifier: 'react', shim: 'react' },
|
||||
{ specifier: 'react/jsx-runtime', shim: 'jsx-runtime' },
|
||||
// A production `vite build` emits the non-dev runtime, but the plugin picks
|
||||
// per mode and a `--mode development` build would reach for this one. Aliased
|
||||
// rather than left to chance: the shim re-exports `jsxDEV` too.
|
||||
{ specifier: 'react/jsx-dev-runtime', shim: 'jsx-runtime' },
|
||||
{ specifier: 'react-dom', shim: 'react-dom' },
|
||||
{ specifier: 'react-dom/client', shim: 'react-dom' },
|
||||
{ specifier: 'react-router-dom', shim: 'react-router-dom' },
|
||||
]
|
||||
|
||||
// The packages whose real source must never end up in the chunk.
|
||||
//
|
||||
// Stated independently of SHARED, and that is the whole point — an earlier
|
||||
// version derived this from the alias list "so the two cannot disagree", which
|
||||
// meant deleting an alias also deleted the guard against the thing that alias
|
||||
// prevented. The guard then reported nothing on a chunk with react-router welded
|
||||
// into it. What may not be bundled is a fact about core's `window.__rg`, not a
|
||||
// function of what this config happens to alias; `test/build.test.js` asserts
|
||||
// every SHARED specifier is covered here, which is the direction the dependency
|
||||
// belongs in.
|
||||
//
|
||||
// `react-router` and `@remix-run/router` are react-router-dom's own internals.
|
||||
// They cannot appear while the alias holds — nothing resolves through to them —
|
||||
// so naming them costs nothing and closes the case where a module imports one
|
||||
// directly and gets a second navigation context in a page that otherwise works.
|
||||
export const SHARED_PACKAGES = ['react', 'react-dom', 'react-router-dom', 'react-router', '@remix-run/router']
|
||||
|
||||
/**
|
||||
* Fail the build if a shared dependency's real source is about to be bundled.
|
||||
*
|
||||
* This is the safety net, and it is a resolution-time one on purpose. The
|
||||
* alternative — grepping the built chunk for a fingerprint — has to guess at
|
||||
* strings that survive minification, and guesses at that are how a check ends up
|
||||
* passing on a chunk that carries a second React. Here there is nothing to
|
||||
* guess: if a module id resolved into `node_modules/react`, an alias missed, and
|
||||
* the alias that missed is named in the error.
|
||||
*
|
||||
* It hooks `transform` rather than `load`, and that is not interchangeable:
|
||||
* `load` is FIRST-WINS, so an earlier plugin returning the module's contents
|
||||
* means this hook is never called for it. Written against `load` this guard sat
|
||||
* in the build doing nothing, and a deliberately-broken alias produced a 24 kB
|
||||
* chunk with react-router welded into it and a green build — which is the exact
|
||||
* failure it exists to prevent. `transform` runs for every module, every time.
|
||||
*/
|
||||
function assertSharedNotBundled() {
|
||||
return {
|
||||
name: 'examplegame:assert-shared-not-bundled',
|
||||
enforce: 'post',
|
||||
transform(code, id) {
|
||||
const normalised = id.split('\\').join('/')
|
||||
const hit = SHARED_PACKAGES.find((pkg) => normalised.includes(`/node_modules/${pkg}/`))
|
||||
if (hit) {
|
||||
this.error(
|
||||
`"${hit}" resolved into node_modules (${normalised}). It must be aliased to a shim that ` +
|
||||
're-exports from window.__rg — there is exactly one React in the page and core owns it ' +
|
||||
'(MODULE_API.md §3.2, §3.6). Check resolve.alias in vite.config.js.',
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), assertSharedNotBundled()],
|
||||
resolve: {
|
||||
alias: SHARED.map(({ specifier, shim: name }) => ({
|
||||
find: new RegExp(`^${specifier.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')}$`),
|
||||
replacement: shim(name),
|
||||
})),
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: fileURLToPath(new URL('./src/entry.jsx', import.meta.url)),
|
||||
formats: ['es'],
|
||||
// Unhashed, deliberately: `module.json` names this file, and a hashed name
|
||||
// would have to be discovered at runtime. Core answers the cache question
|
||||
// instead, serving it `no-cache` so a revalidation catches a new build
|
||||
// (MODULE_API.md §3.1).
|
||||
fileName: () => 'entry.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
// No inline bootstrap, for the same reason core disables it: an inline
|
||||
// script is refused under `script-src 'self'`, and the failure is a chunk
|
||||
// that never evaluates with a CSP report as the only clue.
|
||||
modulePreload: { polyfill: false },
|
||||
// `rollupOptions.external` is deliberately EMPTY — see note 2 at the top.
|
||||
rollupOptions: { external: [] },
|
||||
},
|
||||
})
|
||||
14
template/module.json
Normal file
14
template/module.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "examplegame",
|
||||
"name": "Example Game",
|
||||
"version": "0.1.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", "/clans"]
|
||||
},
|
||||
"capabilities": ["world-status", "clans"]
|
||||
}
|
||||
213
template/server/boot.js
Normal file
213
template/server/boot.js
Normal file
@@ -0,0 +1,213 @@
|
||||
// ── The lifecycle hooks ───────────────────────────────────────────────────
|
||||
//
|
||||
// `register()` may not touch the database (MODULE_API.md §2.2). This file is
|
||||
// where everything it could not do goes.
|
||||
//
|
||||
// core schema → your schema fragment → onBoot(ctx) → the listener binds
|
||||
//
|
||||
// So by the time `onBoot` runs your tables exist, core's settings are seeded, and
|
||||
// nothing is serving traffic yet. That last part is a guarantee you can rely on:
|
||||
// a module that must warm a cache before its first request gets to.
|
||||
//
|
||||
// **`onBoot` has no timeout.** Shutdown races the process being killed; boot does
|
||||
// not. A slow `onBoot` delays the listener, which is the promise above rather
|
||||
// than a problem to be timed out.
|
||||
//
|
||||
// **If `onBoot` throws, the module is `startup_failed` and the site still comes
|
||||
// up.** Your routes stay mounted but answer 503, because a module that failed to
|
||||
// warm up serving half-initialised data is worse than one that says it is down.
|
||||
// You then get NO `onShutdown` — you are part-way through a warm-up you never
|
||||
// finished, and being handed a half-built world to tear down is worse than not
|
||||
// closing cleanly.
|
||||
//
|
||||
// This is where a real module opens its sidecar connection. **The website process
|
||||
// never opens a connection to a game server** — that is §2.7, contract as of
|
||||
// MODULE_API 1.4.0, not advice. What you connect to here is your sidecar: a
|
||||
// service you write, which owns the socket to the game, persists what the game
|
||||
// says before forwarding it, and answers reads from that store. See the kit's
|
||||
// chapter 3 for why that shape and not a shorter one.
|
||||
|
||||
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')
|
||||
|
||||
// Whatever a real module would keep open — a sidecar WebSocket, a poll timer —
|
||||
// is held here so `onShutdown` can close it. This template has one timer, purely
|
||||
// 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
|
||||
|
||||
/**
|
||||
* Ask the game (in a real module: your sidecar) how it is doing, and store it.
|
||||
*
|
||||
* Isolated from the hooks so it is the one place a failure is handled: an
|
||||
* unreachable game is expected, is not this module's fault, and must not become
|
||||
* an unhandled rejection in core's process.
|
||||
*/
|
||||
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.
|
||||
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.
|
||||
*
|
||||
* Receives the same frozen `ctx` `register()` was given — not a second object
|
||||
* built to look like it — so a module that only needs core at boot time can skip
|
||||
* `core.init` entirely and use this argument.
|
||||
*/
|
||||
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
|
||||
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
||||
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
||||
log.info('booted', { refreshMs: REFRESH_MS })
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on SIGINT/SIGTERM, before core closes anything of its own.
|
||||
*
|
||||
* The database pool, the push dispatcher and the SSE fan-out are all still open,
|
||||
* because flushing through them is the only thing this hook is for. There is a
|
||||
* five-second budget per module, after which the hook is abandoned — abandoned
|
||||
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||
* Close what you opened, flush what is buffered, and return.
|
||||
*/
|
||||
async function onShutdown() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
lastBootId = null
|
||||
log.info('shut down')
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, refresh, seedClans, checkForRestart, REFRESH_MS }
|
||||
447
template/server/config/eventActions.js
Normal file
447
template/server/config/eventActions.js
Normal 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 }
|
||||
131
template/server/core.js
Normal file
131
template/server/core.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// ── Everything this module reaches in core ─────────────────────────────────
|
||||
//
|
||||
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
|
||||
// code beneath it — models, controllers, utilities — is ordinary Node that
|
||||
// requires its dependencies at file scope, the way any Node file does. This file
|
||||
// is what lets both of those be true at the same time.
|
||||
//
|
||||
// **Every export is a lazy accessor, not a stored reference, and that is the
|
||||
// whole point.** A model writes
|
||||
//
|
||||
// const { query } = require('../../core')
|
||||
//
|
||||
// at require time, which is before `register()` has been called and therefore
|
||||
// before any `ctx` exists. Handing out `ctx.db.query` at that moment would hand
|
||||
// out `undefined`, permanently, and the failure would surface much later as a
|
||||
// TypeError inside a model with no clue pointing here. So each member resolves
|
||||
// `ctx` when it is CALLED. Require order stops mattering for everything except
|
||||
// `core.init()` itself, which `index.js` runs first.
|
||||
//
|
||||
// The same rule in the other direction: **never destructure off `ctx` at init
|
||||
// time.** Core is free to hand over a getter — `ctx.site.baseUrl` is one — and a
|
||||
// value captured once is a value that cannot change.
|
||||
//
|
||||
// If `ctx` is missing every accessor throws the same message. The only ways to
|
||||
// reach one before `register()` are a require cycle or a test that forgot to call
|
||||
// `init`, and both want naming rather than `undefined`.
|
||||
//
|
||||
// ── This file is a NARROWING, on purpose ───────────────────────────────────
|
||||
//
|
||||
// §2.3 lists everything core hands over. What is re-exported below is only what
|
||||
// this module actually uses, which is the discipline worth copying: the file is
|
||||
// then an honest statement of what your module depends on, and a test double for
|
||||
// it (see `test/_fakes.js`) is a complete one. Add a member here when you reach
|
||||
// for it — not in advance.
|
||||
|
||||
let ctx = null
|
||||
|
||||
function need() {
|
||||
if (!ctx) {
|
||||
throw new Error('examplegame: core accessed before register() — see server/core.js')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Called once, first thing in `register()`. */
|
||||
function init(value) {
|
||||
ctx = value
|
||||
}
|
||||
|
||||
/** Test seam. Nothing in the module calls this; there is no de-registration. */
|
||||
function _reset() {
|
||||
ctx = null
|
||||
}
|
||||
|
||||
// A logger that can be taken at require time and used after `register()`.
|
||||
//
|
||||
// A file writes `const log = require('../core').logger('world')` at file scope,
|
||||
// so the object returned has to exist before `ctx` does. It is a façade whose
|
||||
// four methods each resolve the real logger when called. Core namespaces the
|
||||
// output with your module id, so these come out as `[examplegame:world]`.
|
||||
function logger(namespace) {
|
||||
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
|
||||
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
_reset,
|
||||
logger,
|
||||
|
||||
// Shared server dependencies. Core owns exactly one express, as it owns
|
||||
// exactly one React on the client, and for the same reason: a second copy in
|
||||
// the process is a second Router prototype and a second set of `instanceof`
|
||||
// checks. A module could not resolve these for itself even if it were allowed
|
||||
// to — it lives outside core's `server/` (§7.2).
|
||||
get express() { return need().express },
|
||||
get validator() { return need().validator },
|
||||
|
||||
// The database. `query(sql, params)` is what every `*.db.js` file uses; raw
|
||||
// parameterised SQL, no ORM, the same as core. `pool` is there for the rare
|
||||
// case that needs a connection it can hold (a streamed import, say).
|
||||
query: (...args) => need().db.query(...args),
|
||||
get pool() { return need().db.pool },
|
||||
|
||||
// Read-only access to who is asking. Minting a session is core's job; a module
|
||||
// that needs an identity needs to *read* one.
|
||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||
|
||||
// Core's middleware, taken as values rather than wrapped: express stores the
|
||||
// function reference at mount time, so a wrapper is what would end up in the
|
||||
// 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.
|
||||
get moduleRoot() { return need().paths.moduleRoot },
|
||||
get moduleId() { return need().moduleId },
|
||||
}
|
||||
27
template/server/db/purge.sql
Normal file
27
template/server/db/purge.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- ── The teardown ──────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
|
||||
-- Nothing on the boot path executes this file, and uninstalling your module does
|
||||
-- not either: removing an operator's data is a second decision they have to make
|
||||
-- on purpose, offered inside the uninstall flow and confirmed separately.
|
||||
--
|
||||
-- It exists because `schema.sql` does. A module that can create tables and
|
||||
-- cannot drop them leaves an operator with orphaned data and no supported way to
|
||||
-- remove it — so core refuses to load a module that declares one without the
|
||||
-- other.
|
||||
--
|
||||
-- **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
|
||||
-- subscriptions, announce-job legs and settings rows live in core's schema, and
|
||||
-- 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;
|
||||
124
template/server/db/schema.sql
Normal file
124
template/server/db/schema.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
-- ── The schema fragment ───────────────────────────────────────────────────
|
||||
--
|
||||
-- Core replays this file on EVERY boot, statement by statement, immediately
|
||||
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
|
||||
--
|
||||
-- **There is no migration runner anywhere in this project, and that is a
|
||||
-- decision rather than an omission.** Core's own schema is one idempotent file
|
||||
-- replayed the same way. So a module's schema is not a sequence of changes to
|
||||
-- apply once — it is a statement of what the tables should look like, written so
|
||||
-- that running it against a database that already matches does nothing.
|
||||
--
|
||||
-- Which means: every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
|
||||
-- IF NOT EXISTS. A statement that succeeds once and fails afterwards presents as
|
||||
-- a module that worked until the first restart.
|
||||
--
|
||||
-- **And it means CHANGING a table is an ALTER, never an edit to its CREATE.**
|
||||
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
|
||||
-- there, so an edited column definition takes effect on a fresh install and on no
|
||||
-- existing one — the worst possible split, because your development database is
|
||||
-- usually the fresh one. Add the column with
|
||||
-- `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, below the CREATE, and leave the
|
||||
-- CREATE describing what a new install gets.
|
||||
--
|
||||
-- ── What core checks, and when ────────────────────────────────────────────
|
||||
--
|
||||
-- Core validates this file at LOAD time, before your module mounts anything —
|
||||
-- so a rule broken here costs you the mount entirely rather than leaving you
|
||||
-- with half-created tables and routes that 503. What is left for replay time is
|
||||
-- the class only the database can answer: an unknown column type, a bad foreign
|
||||
-- key. Those are post-mount and do answer 503.
|
||||
--
|
||||
-- • **Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE.** Not a
|
||||
-- DROP denylist. This file replays every boot, so a TRUNCATE or a DELETE
|
||||
-- would empty a table on every restart.
|
||||
-- • **Every table you create must be prefixed with your module id** —
|
||||
-- `examplegame_` here. Nothing else in the database is yours to create.
|
||||
-- • **No table core declares, and none another module has claimed.**
|
||||
--
|
||||
-- A foreign key INTO a core table is allowed, and works because core's schema is
|
||||
-- already in place when this runs. The reverse is not, and could not be: it
|
||||
-- would make core's schema depend on your module being installed.
|
||||
--
|
||||
-- Teardown is `purge.sql`, which no boot ever runs. See it.
|
||||
|
||||
|
||||
-- ── World status ──────────────────────────────────────────────────────────
|
||||
-- One row, id 1, holding the last thing the game server said about itself.
|
||||
--
|
||||
-- A singleton row rather than a settings key because it is *observed state* and
|
||||
-- not configuration: it is written by whatever ingests from your sidecar, and an
|
||||
-- operator never edits it. In a real module the writer is the sidecar ingest;
|
||||
-- here `boot.js` writes it once so the page has something to render.
|
||||
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
|
||||
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
|
||||
-- the same numbers back — which is what a quiet game looks like — leaves the
|
||||
-- timestamp frozen at the first write, and the row then goes stale while nothing
|
||||
-- is wrong. The writer sets the column explicitly instead; see
|
||||
-- `model/worldStatus/worldStatus.db.js`.
|
||||
CREATE TABLE IF NOT EXISTS examplegame_world_status (
|
||||
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||
players INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
world_name VARCHAR(120) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Seed the singleton. `INSERT IGNORE` rather than a plain INSERT: this runs
|
||||
-- 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
|
||||
);
|
||||
309
template/server/index.js
Normal file
309
template/server/index.js
Normal file
@@ -0,0 +1,309 @@
|
||||
// ── The server entry point ─────────────────────────────────────────────────
|
||||
//
|
||||
// Core requires this file once, synchronously, while its own `app.js` is still
|
||||
// being required, and calls the exported function with `(ctx, api)`. That is the
|
||||
// entire server-side handshake: everything this module can reach arrives on
|
||||
// `ctx`, and everything it can offer is registered through `api`.
|
||||
//
|
||||
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
|
||||
//
|
||||
// ── Three rules, and each one has a failure behind it ──────────────────────
|
||||
//
|
||||
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
|
||||
// with the connection pool pointed at a dead port — the route-manifest
|
||||
// generator and the OpenAPI generator both do it — so a module that queried
|
||||
// at registration time would hang both. Anything that needs a live database
|
||||
// goes in `onBoot`, which runs after the schema is up.
|
||||
//
|
||||
// 2. **Never resolve what core owns.** Your module lives at
|
||||
// `<website>/modules/<id>/`, which is outside core's `server/`, so Node's
|
||||
// resolver never reaches core's `node_modules` and `require('express')` from
|
||||
// here simply fails. express, express-validator, the database, the logger and
|
||||
// the middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`.
|
||||
// This is not a style rule: a second express in the process would be a second
|
||||
// `Router` prototype, exactly as a second React would be a second renderer.
|
||||
//
|
||||
// 3. **Never reach into core's tree.** No relative path may escape this module's
|
||||
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
|
||||
//
|
||||
// ── Why the requires are INSIDE the function ───────────────────────────────
|
||||
//
|
||||
// Every file below reaches core through `./core`, whose members resolve `ctx`
|
||||
// when they are CALLED. But a router writes `const express = core.express` at its
|
||||
// own file scope, and that runs the moment the file is required. So
|
||||
// `core.init(ctx)` has to happen before the first `require` of anything under
|
||||
// `router/`. Hoisting these to the top of the file breaks the module with an
|
||||
// error about a missing `ctx`, thrown from a file that never mentions one.
|
||||
//
|
||||
// Node caches modules, so requiring here costs nothing after the first call.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
/**
|
||||
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
|
||||
* @param {object} api what the module registers (§2.4)
|
||||
*/
|
||||
module.exports = function register(ctx, api) {
|
||||
core.init(ctx)
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const worldRouter = require('./router/public/world.router')
|
||||
const clansRouter = require('./router/public/clans.router')
|
||||
const clanProvider = require('./model/clans/clanProvider.model')
|
||||
const eventActions = require('./config/eventActions')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = core.logger()
|
||||
|
||||
// One prefix, on one tier. The keys here must match `module.json`'s `mounts`
|
||||
// exactly — the loader compares the two and rejects a mismatch in either
|
||||
// direction, so a route you forgot to declare and a prefix you declared and
|
||||
// never registered both fail loudly at boot instead of quietly at runtime.
|
||||
//
|
||||
// This mounts at `/api/v1/public/world`. The router sits INSIDE the tier
|
||||
// router, so it structurally cannot reach above its prefix, and the tier's own
|
||||
// gate is already applied: `public` is behind nothing by design, `admin` sits
|
||||
// behind `noindex, isLoggedIn, requireRole(...)` and `player` behind
|
||||
// `noindex, requireAuth`. You add per-route gates on top; you never
|
||||
// re-implement the tier gate.
|
||||
//
|
||||
// **Prefixes share one namespace with core's own, and `/world` was chosen to
|
||||
// stay out of it.** Core answers `/api/v1/public/` + contact, modules, pages,
|
||||
// posts, settings, status, version and wiki. The loader rejects a collision at
|
||||
// registration time — but four of those eight are mounted at the tier root
|
||||
// rather than under a prefix of their own, and the loader's probe cannot see
|
||||
// them. `/status` would have been the obvious name for this module's route and
|
||||
// is exactly the one that would have gone wrong. Check the list before you
|
||||
// choose (§2.4, and MODULE_SYSTEM.md §2.7's own note about the probe).
|
||||
api.registerRoutes({
|
||||
public: { '/world': worldRouter, '/clans': clansRouter },
|
||||
})
|
||||
|
||||
// ── Teams: this module is the authoritative source of them ───────────────
|
||||
//
|
||||
// A Team is a CORE entity — core owns the tables, the reconciler, the access
|
||||
// rules, the forum and the activity feed. What core does not own is the word for
|
||||
// one, because this game says clan and the next will say company. So core asks
|
||||
// this module three questions and never reads its tables (MODULE_API 1.6.0).
|
||||
//
|
||||
// **This is the first registration where core calls YOU and waits**, which is
|
||||
// what makes it unlike every other line in this file: the others hand core a
|
||||
// router to mount or a row to draw. Two consequences worth carrying:
|
||||
//
|
||||
// • **Registration is a claim, not a call.** Nothing in the provider runs
|
||||
// until core reconciles, which is after `onBoot` — which is what makes it
|
||||
// legal for every one of its methods to read the database while this
|
||||
// function may not (§2.2).
|
||||
// • **One provider per deployment.** Unlike every other registry this holds a
|
||||
// single value: two modules answering "what Teams exist" would produce two
|
||||
// disjoint sets under one table with no rule for merging them. A second
|
||||
// registration is a collision, reported against the module that holds it.
|
||||
//
|
||||
// The whole object is passed rather than picking its members out, so adding the
|
||||
// optional ones is an edit to the provider and not to this file.
|
||||
api.registerTeamProvider(clanProvider)
|
||||
|
||||
// ── Engagement: declaring what your game can announce ────────────────────
|
||||
//
|
||||
// The three calls below are one seam, and it is the one where a module is most
|
||||
// tempted to reach past the boundary. **You declare what CAN happen; core
|
||||
// decides who is told.** A module never names a person, a channel or an
|
||||
// address, and never sends anything (MODULE_API 1.7.0 and 1.9.0; §2.7).
|
||||
//
|
||||
// A TRIGGER is not a notification stream, and the two are easy to confuse
|
||||
// because both are catalogs of things that happen. A stream is a subscribe
|
||||
// toggle you publish to yourself. A trigger is a PAYLOAD CONTRACT an operator
|
||||
// writes rules against — it says what variables the event carries and how wide
|
||||
// an audience it may ever be given, and core does the sending. Their ids share
|
||||
// one namespace, so declaring both for one id is legal and is one event with a
|
||||
// toggle and a contract; taking an id another module owns is not.
|
||||
api.registerEventTriggers([
|
||||
{
|
||||
id: 'examplegame.world.status_changed',
|
||||
label: 'World came up or went down',
|
||||
description: 'The game server changed between online and offline.',
|
||||
kind: 'event',
|
||||
// The cooldown subject: "once per world", not "once per user". It must
|
||||
// NAME one of the variables below — core refuses the registration
|
||||
// otherwise, with this trigger's id in the message, and the module does not
|
||||
// load. That check exists because the failure it prevents is silent: a
|
||||
// subjectKey naming nothing keys every subject on `undefined`, which looks
|
||||
// exactly like the feature working right up until two worlds share it.
|
||||
subjectKey: 'worldName',
|
||||
audience: 'authenticated', // what a rule is CREATED with
|
||||
// ...and the widest it may EVER be given. Required, with no default,
|
||||
// because there is no safe value to guess: `owner` would silently break a
|
||||
// broadcast and `authenticated` would silently widen a staff-only event.
|
||||
// The values are ordered by CONTAINMENT, not by size — see chapter 2.
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
// Every variable needs an `example`, and it is not decoration: it is what
|
||||
// lets an operator preview and test-send a template without waiting for a
|
||||
// real game event, which is the reason template systems ship untested.
|
||||
{ name: 'worldName', type: 'string', required: true, example: 'Example World' },
|
||||
{ name: 'status', type: 'string', required: true, example: 'online' },
|
||||
{ name: 'players', type: 'int', required: false, example: 42 },
|
||||
// A `url` is validated SITE-RELATIVE, because it ends up in an href in a
|
||||
// mail somebody opens days later. Never a full URL of your own.
|
||||
{ name: 'url', type: 'url', required: false, example: '/world' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// An AUDIENCE is a named set of PEOPLE this module can resolve over its own
|
||||
// data, for an operator to point a rule at. "This clan's members" is one;
|
||||
// "everyone who opened the last mail" is not, and nothing here builds it.
|
||||
//
|
||||
// **The resolver returns user ids and nothing else.** It is not handed a
|
||||
// template, a channel or an address and it cannot enumerate them — core maps
|
||||
// ids to addresses on its own side, after preferences, suppression and the
|
||||
// verification gate. That is what stops this becoming the back door §2.7 spends
|
||||
// a section closing.
|
||||
//
|
||||
// Audiences are their OWN id space, unlike triggers and streams: an audience
|
||||
// names a set of people and a trigger names an event, so the two may share a
|
||||
// name without colliding.
|
||||
api.registerAudiences([
|
||||
{
|
||||
id: 'examplegame.clan.members',
|
||||
label: 'Members of a clan',
|
||||
// `int` or `string` only, and CONSTANT — an operator fills these in when
|
||||
// they save the rule. There is no way to say "the clan this event was
|
||||
// about"; if a rule needs that, the EVENT carries its own recipients
|
||||
// instead. Finding that out late is a phase's worth of rework.
|
||||
params: [{ id: 'clanId', type: 'string', required: true }],
|
||||
ceiling: 'members',
|
||||
resolve: async ({ clanId }) => clanProvider.listClanMemberUserIds({ clanId }),
|
||||
},
|
||||
])
|
||||
|
||||
// Finally the CONTENT: the bodies your messages use, and the rules that decide
|
||||
// when one is sent. Both arrive **switched off** — `enabled` is not a parameter
|
||||
// and there is no call that sets it. An operator turns a module's mail on;
|
||||
// installing a module never does.
|
||||
//
|
||||
// The two halves have different lifetimes, and the asymmetry is the contract:
|
||||
//
|
||||
// • **Templates are re-ensured on every boot**, under `seedVersion`, so
|
||||
// improving a default body reaches deployments that never edited it — and
|
||||
// one an operator HAS edited is marked customized and left alone. Bump
|
||||
// `seedVersion` when the body changes; never for a comment.
|
||||
// • **Rule groups are offered ONCE, per named group key.** Re-offering would
|
||||
// resurrect a rule an operator deleted and reset one they enabled. So a rule
|
||||
// appended to an existing group reaches FRESH INSTALLS ONLY. That is the
|
||||
// guarantee rather than a limitation to work around: a rule that has to
|
||||
// reach existing deployments takes a NEW group key, and you choose that
|
||||
// knowingly because you name the groups.
|
||||
//
|
||||
// Core's generic bodies are a first-class answer, not a fallback: point a
|
||||
// channel at `notify.event` / `inapp.event` / `notify.digest` and author
|
||||
// nothing. Ship a body of your own when the message has something to say that a
|
||||
// structural projection of the payload cannot. Below, the mail does — a world
|
||||
// coming back deserves a sentence — and the in-app item does not, so it uses
|
||||
// core's.
|
||||
//
|
||||
// Note the two casings, which are not a slip: a TEMPLATE is an object this call
|
||||
// shapes (`triggerId`), and a RULE is a row (`trigger_id`). Copy them as they
|
||||
// are.
|
||||
api.registerEngagementSeeds({
|
||||
templates: [
|
||||
{
|
||||
// MUST be namespaced `<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.
|
||||
// It has no timeout, deliberately: a slow boot delays the listener, which is
|
||||
// the guarantee rather than a problem to be timed out.
|
||||
//
|
||||
// `onShutdown` runs while core's database pool and push dispatcher are still
|
||||
// open, because flushing through them is the only thing it is for. It gets a
|
||||
// five-second budget and is abandoned past it.
|
||||
//
|
||||
// Both are optional. A module with neither still reaches `started`.
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
log.info('registered', {
|
||||
version: require('../module.json').version,
|
||||
routes: 'public:/world,/clans',
|
||||
})
|
||||
}
|
||||
106
template/server/model/clans/clanProvider.db.js
Normal file
106
template/server/model/clans/clanProvider.db.js
Normal 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 }
|
||||
334
template/server/model/clans/clanProvider.model.js
Normal file
334
template/server/model/clans/clanProvider.model.js
Normal 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,
|
||||
}
|
||||
22
template/server/model/clans/clanSettings.js
Normal file
22
template/server/model/clans/clanSettings.js
Normal 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 }
|
||||
83
template/server/model/clans/clans.model.js
Normal file
83
template/server/model/clans/clans.model.js
Normal 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 }
|
||||
53
template/server/model/worldStatus/worldStatus.db.js
Normal file
53
template/server/model/worldStatus/worldStatus.db.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
||||
//
|
||||
// Core's own backend is layered `router → controller → model → db`, with models
|
||||
// arriving in pairs: a `.db.js` holding the SQL and a `.model.js` holding the
|
||||
// logic that calls it. Your module is under no obligation to copy that — the
|
||||
// contract says nothing about how you organise yourself — but the split earns
|
||||
// its keep here for the same reason it does in core: 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.
|
||||
//
|
||||
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always; a
|
||||
// value interpolated into a query string is the one mistake in this file that
|
||||
// nothing downstream can catch.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const TABLE = 'examplegame_world_status'
|
||||
|
||||
/** The singleton status row, or `null` if the schema replay has not run yet. */
|
||||
async function getStatus() {
|
||||
const rows = await core.query(
|
||||
`SELECT online, players, world_name AS worldName, updated_at AS updatedAt
|
||||
FROM ${TABLE}
|
||||
WHERE id = 1`,
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the singleton. Called by whatever ingests from your sidecar.
|
||||
*
|
||||
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
|
||||
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
|
||||
* value — an update that writes the same numbers back is a no-op and leaves the
|
||||
* timestamp where it was. A game sitting quietly at the same player count writes
|
||||
* exactly that update, so the column would freeze at the first write, the row
|
||||
* would cross the freshness window, and the page would report the world offline
|
||||
* while the game was up and reporting normally.
|
||||
*
|
||||
* That is invisible to every test — the model takes its timestamps as arguments,
|
||||
* and nothing in a suite runs an UPDATE twice against a real database. It shows
|
||||
* up as a page that was right when you looked at it and wrong an hour later.
|
||||
*/
|
||||
async function setStatus({ online, players, worldName }) {
|
||||
await core.query(
|
||||
`UPDATE ${TABLE}
|
||||
SET online = ?, players = ?, world_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1`,
|
||||
[online ? 1 : 0, players, worldName],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { getStatus, setStatus, TABLE }
|
||||
50
template/server/model/worldStatus/worldStatus.model.js
Normal file
50
template/server/model/worldStatus/worldStatus.model.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// ── The logic half ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Shapes what the database returned into what a client should see. It is a
|
||||
// separate file from the SQL so that it is testable without a database, and the
|
||||
// suite next door tests it that way.
|
||||
//
|
||||
// The one decision worth pointing at: **a module answers when the game is
|
||||
// unreachable rather than failing.** The website is the internet-facing process
|
||||
// and your game is not; the game being down, or the sidecar being mid-restart,
|
||||
// is an ordinary Tuesday and not an error condition for the site. A page that
|
||||
// renders "offline, last seen 20 minutes ago" is right; a page that 500s because
|
||||
// a socket is closed is a module that has made the site's availability depend on
|
||||
// the game's.
|
||||
|
||||
const db = require('./worldStatus.db')
|
||||
|
||||
// Past this, the last thing the game said stops being news and starts being
|
||||
// history. Presentation, so the number lives with the code that shapes the
|
||||
// response rather than in the client.
|
||||
const STALE_AFTER_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* The public view of the world's status.
|
||||
*
|
||||
* Never throws for an absent or stale row: both are answers, not failures.
|
||||
*/
|
||||
async function getPublicStatus(now = Date.now()) {
|
||||
const row = await db.getStatus()
|
||||
if (!row) {
|
||||
// No row at all means the schema fragment has not been replayed — a fresh
|
||||
// install whose first boot has not finished. Report it as offline rather
|
||||
// than as an error; the next boot fixes it.
|
||||
return { online: false, players: 0, worldName: null, updatedAt: null, stale: true }
|
||||
}
|
||||
|
||||
const updatedAt = row.updatedAt ? new Date(row.updatedAt) : null
|
||||
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
|
||||
|
||||
return {
|
||||
// A stale row cannot claim the world is up. The row says what was true when
|
||||
// it was written, and nothing has written it since.
|
||||
online: Boolean(row.online) && !stale,
|
||||
players: stale ? 0 : Number(row.players) || 0,
|
||||
worldName: row.worldName || null,
|
||||
updatedAt: updatedAt ? updatedAt.toISOString() : null,
|
||||
stale,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getPublicStatus, STALE_AFTER_MS }
|
||||
1056
template/server/package-lock.json
generated
Normal file
1056
template/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
template/server/package.json
Normal file
23
template/server/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "examplegame-module-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Server half of the Example Game module — routers, models and the schema fragment core loads at boot",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"check:imports": "node scripts/checkImports.js",
|
||||
"swagger": "node scripts/swaggerFragment.js",
|
||||
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"//dependencies": "There are none, and that is the shape to aim for: everything the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger and the middleware are all core-owned and handed over. If you do add one, remember an operator never builds: your release CI runs npm ci --omit=dev and packs server/node_modules into the tarball, so every dependency is weight in the artifact and a package the operator now runs. scripts/checkImports.js reads this file to decide what the shipped half may resolve.",
|
||||
"devDependencies": {
|
||||
"express": "^4.19.2",
|
||||
"swagger-autogen": "^2.23.7"
|
||||
},
|
||||
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express Router, because a fake Router would only ever test the fake. swagger-autogen generates the OpenAPI fragment; pin it to the same major core uses, so the fragment and the spec it merges into come out of one tool."
|
||||
}
|
||||
51
template/server/router/public/clans.controller.js
Normal file
51
template/server/router/public/clans.controller.js
Normal 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 }
|
||||
52
template/server/router/public/clans.router.js
Normal file
52
template/server/router/public/clans.router.js
Normal 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
|
||||
27
template/server/router/public/world.controller.js
Normal file
27
template/server/router/public/world.controller.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// ── Public · World — the handlers ─────────────────────────────────────────
|
||||
//
|
||||
// Thin on purpose: read the request, call a model, answer. Everything worth
|
||||
// testing is in the model, which needs no express and no database to test.
|
||||
//
|
||||
// **A handler must not throw past express.** Core mounts your router inside its
|
||||
// own tier router, so an unhandled rejection here reaches core's error handler
|
||||
// and answers 500 — which is survivable, but it means an operator sees core
|
||||
// blamed for a fault in your module. Catch, log through `core.logger` (so the
|
||||
// line carries your module id), and answer something honest.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const worldStatus = require('../../model/worldStatus/worldStatus.model')
|
||||
|
||||
const log = core.logger('world')
|
||||
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
res.json(await worldStatus.getPublicStatus())
|
||||
} catch (err) {
|
||||
log.error('failed to read world status', { error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read world status' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus }
|
||||
58
template/server/router/public/world.router.js
Normal file
58
template/server/router/public/world.router.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// ── Public · World ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mounted at `/api/v1/public/world` by `index.js`. One express Router, built
|
||||
// from CORE's express (`core.express`) — never from a `require('express')` of
|
||||
// your own, which would not resolve from here anyway (MODULE_API.md §7.2).
|
||||
//
|
||||
// **The tier's gate is already on.** This router sits inside core's public tier,
|
||||
// which is behind nothing by design — the public API is public. Per-route
|
||||
// middleware goes on top, and `siteMode` below is the one worth understanding:
|
||||
// it is what makes a page respect the operator's maintenance switch. Core applies
|
||||
// it to its own content routes (`/posts`, `/wiki`) and deliberately does not
|
||||
// apply it to its status endpoints, because status is exactly what an operator
|
||||
// wants visible *during* maintenance. Which of those two your route is depends on
|
||||
// what it serves, and it is your decision to make.
|
||||
//
|
||||
// ── About the `#swagger` comments ─────────────────────────────────────────
|
||||
//
|
||||
// They are not documentation *of* the code, they are the source the OpenAPI
|
||||
// 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, 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.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const world = require('./world.controller')
|
||||
const { siteMode } = core.middleware
|
||||
|
||||
const worldRouter = express.Router()
|
||||
|
||||
worldRouter.get(
|
||||
'/status',
|
||||
// #swagger.tags = ['Public · Example Game']
|
||||
// #swagger.summary = 'The game world’s current status'
|
||||
// #swagger.description = 'What the game server last reported: whether it is up, how many players are on, and when that was. Answers with `online: false` and `stale: true` rather than failing when the game or its sidecar is unreachable — the site’s availability does not depend on the game’s.'
|
||||
/* #swagger.responses[200] = { description: 'The world’s status', content: { "application/json": { schema: { $ref: "#/components/schemas/ExamplegameWorldStatus" } } } } */
|
||||
siteMode,
|
||||
world.getStatus,
|
||||
)
|
||||
|
||||
module.exports = worldRouter
|
||||
190
template/server/scripts/checkImports.js
Normal file
190
template/server/scripts/checkImports.js
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.1 — zero internal imports ───────────────────────────────────────────
|
||||
//
|
||||
// The acceptance test for the whole module contract. A module that reaches into
|
||||
// core's tree still works — right up until core moves a file — and the boundary
|
||||
// this workstream exists to build is worth exactly as much as this check is.
|
||||
//
|
||||
// MODULE_API.md §5.1 sketches it as a grep for `../../`. That is the shape of
|
||||
// the violation but not the rule, and the difference matters in both directions:
|
||||
// a grep says nothing about `require('../../../../etc/passwd')` from a deeply
|
||||
// nested file (which it catches by accident) and false-alarms on a legitimate
|
||||
// `require('../module.json')` from `server/` (which it catches wrongly). So this
|
||||
// RESOLVES each specifier against the file that wrote it and asks whether the
|
||||
// result is still inside the module root — the actual rule, stated once.
|
||||
//
|
||||
// Bare specifiers are checked too, and against a stricter list than "is it
|
||||
// installed": core hands the module express, express-validator, the database and
|
||||
// the logger on `ctx` precisely so the module never resolves them, and Node's
|
||||
// resolver cannot reach core's `node_modules` from here anyway. A bare
|
||||
// `require` that is not a Node builtin is therefore a module that will fail to
|
||||
// load on a real install, with a message about a missing package rather than
|
||||
// about the rule it broke.
|
||||
//
|
||||
// **That second check applies to SHIPPED code only.** `test/` and `scripts/`
|
||||
// never run inside core's process — the fakes in `test/_fakes.js` build a real
|
||||
// `express` router precisely so the module's routers are exercised for real —
|
||||
// so they may use devDependencies. The containment check applies everywhere,
|
||||
// because a test that reaches into core's tree is a test that passes on this
|
||||
// machine and nowhere else.
|
||||
//
|
||||
// Run over the SERVER half. The client half's equivalents are its Vite build,
|
||||
// which fails if a shared dependency resolves into node_modules, and
|
||||
// client/scripts/checkExternals.js, which asks the built chunk whether any bare
|
||||
// specifier survived.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
// Node's own answer, not a list reconstructed from `builtinModules`. That list
|
||||
// omits `test` on Node 20 and includes it on Node 24, so a suite that requires
|
||||
// `node:test` passed locally and failed in CI on the very first run — reported
|
||||
// as the module boundary being broken, which it was not. `isBuiltin` is the
|
||||
// authoritative check and handles the `node:` prefix itself.
|
||||
const { isBuiltin } = require('module')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
|
||||
// Packages the SHIPPED half may resolve for itself: this package's declared
|
||||
// `dependencies`, and nothing else. Read from package.json rather than listed
|
||||
// here, so adding one is a visible, reviewable edit to the manifest that also
|
||||
// changes what CI installs and what the release tarball carries.
|
||||
//
|
||||
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
|
||||
// release tarball carries `server/node_modules` because an operator never builds
|
||||
// — so every entry is weight in the artifact and a package the operator's
|
||||
// deployment now runs. Anything core already owns must come from `ctx` instead:
|
||||
// a second express is a second Router prototype, a second express-rate-limit is
|
||||
// a second store, and a limit enforced by two independent counters is not the
|
||||
// limit either of them states.
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
|
||||
|
||||
// Directories whose contents never run inside core's process, and may therefore
|
||||
// resolve this package's devDependencies.
|
||||
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
|
||||
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
|
||||
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
|
||||
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
|
||||
|
||||
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
|
||||
// need a dependency, and a specifier this pattern misses is a specifier written
|
||||
// to be missed, which review catches and a stricter regexp would not.
|
||||
const SPECIFIER = /(?:require\(|from\s+|import\()\s*['"]([^'"]+)['"]/g
|
||||
|
||||
/**
|
||||
* Blank out comments and template literals before scanning.
|
||||
*
|
||||
* Not a nicety — without it this file fails on ITSELF, because the comments
|
||||
* above name `require('../../../../etc/passwd')` as an example of what to
|
||||
* catch, and index.js explains in prose why it must never `require('express')`.
|
||||
* A boundary check that cannot survive being described is a check people stop
|
||||
* writing comments around.
|
||||
*
|
||||
* A character walk rather than a regexp, because the two get in each other's
|
||||
* way: `'https://x'` contains a line-comment opener inside a string, and
|
||||
* `// don't` contains a quote inside a comment. Tracking the state is shorter
|
||||
* than the regexp that would almost handle it. Content is replaced with spaces
|
||||
* rather than removed so nothing else has to care.
|
||||
*/
|
||||
function stripCommentsAndTemplates(src) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
const keep = (n) => { out += src.slice(i, i + n); i += n }
|
||||
const blank = (end) => { out += src.slice(i, end).replace(/[^\n]/g, ' '); i = end }
|
||||
while (i < src.length) {
|
||||
const two = src.slice(i, i + 2)
|
||||
if (two === '//') {
|
||||
const nl = src.indexOf('\n', i)
|
||||
blank(nl === -1 ? src.length : nl)
|
||||
} else if (two === '/*') {
|
||||
const end = src.indexOf('*/', i + 2)
|
||||
blank(end === -1 ? src.length : end + 2)
|
||||
} else if (src[i] === '"' || src[i] === "'") {
|
||||
// Strings are KEPT — they are where the specifiers live.
|
||||
const quote = src[i]
|
||||
keep(1)
|
||||
while (i < src.length && src[i] !== quote) keep(src[i] === '\\' ? 2 : 1)
|
||||
keep(1)
|
||||
} else if (src[i] === '`') {
|
||||
// Template literals are blanked: nothing may `require` a template, and a
|
||||
// template holding SQL or HTML is a rich source of false positives.
|
||||
i += 1
|
||||
out += ' '
|
||||
while (i < src.length && src[i] !== '`') {
|
||||
if (src[i] === '\\') { out += ' '; i += 2 } else { out += src[i] === '\n' ? '\n' : ' '; i += 1 }
|
||||
}
|
||||
i += 1
|
||||
out += ' '
|
||||
} else {
|
||||
keep(1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name))
|
||||
} else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
|
||||
yield path.join(dir, entry.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every boundary violation under `root`, resolved against `moduleRoot`.
|
||||
*
|
||||
* Exported so `test/checkImports.test.js` can point it at fixtures. A check that
|
||||
* has never been shown to fail is a check nobody knows the state of — and this
|
||||
* one guards the acceptance criterion for the whole contract.
|
||||
*/
|
||||
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
|
||||
const violations = []
|
||||
for (const file of walk(root)) {
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(SPECIFIER)) {
|
||||
if (specifier.startsWith('.')) {
|
||||
const resolved = path.resolve(path.dirname(file), specifier)
|
||||
if (resolved !== moduleRoot && !resolved.startsWith(moduleRoot + path.sep)) {
|
||||
violations.push({ file, specifier, why: 'escapes the module root' })
|
||||
}
|
||||
} else if (path.isAbsolute(specifier)) {
|
||||
violations.push({ file, specifier, why: 'absolute path' })
|
||||
} else {
|
||||
const pkg = specifier.startsWith('@')
|
||||
? specifier.split('/').slice(0, 2).join('/')
|
||||
: specifier.split('/')[0]
|
||||
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
|
||||
// The `node:` prefix can only ever name a builtin, so it never reaches
|
||||
// node_modules and is safe whatever this Node version enumerates.
|
||||
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
||||
if (!builtin && !allowed) {
|
||||
violations.push({ file, specifier, why: 'undeclared bare specifier — should this come from ctx?' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
module.exports = { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT }
|
||||
|
||||
// Required by a test, or run as the check? Only the second one exits.
|
||||
if (require.main !== module) return
|
||||
|
||||
const violations = scan(SERVER_ROOT)
|
||||
|
||||
if (violations.length) {
|
||||
console.error(`\n${violations.length} import(s) break the module boundary (MODULE_API.md §5.1):\n`)
|
||||
for (const v of violations) {
|
||||
console.error(` ${path.relative(MODULE_ROOT, v.file)}\n "${v.specifier}" — ${v.why}`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`OK — no import escapes the module root (${SERVER_ROOT}).`)
|
||||
265
template/server/scripts/swaggerFragment.js
Normal file
265
template/server/scripts/swaggerFragment.js
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §2.8 — the OpenAPI fragment ───────────────────────────────────────────
|
||||
//
|
||||
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
|
||||
// tags and schemas describing every route this module registers. Core merges the
|
||||
// fragments of *started* modules over its own committed spec at request time and
|
||||
// serves the result at `/api/docs.json` (MODULE_API.md §6.1a).
|
||||
//
|
||||
// ── Why a module has to ship this at all ──────────────────────────────────
|
||||
//
|
||||
// Core's own spec generation is STATIC analysis — swagger-autogen parses core's
|
||||
// `app.js` as text and follows the literal `app.use(...)` chain. Your module
|
||||
// arrives on a volume after core was built, is required by a filesystem loop, and
|
||||
// mounts through `api.registerRoutes()`. There is no literal mount for a parser to
|
||||
// follow, and core does not have your sources anyway. So nothing core can run
|
||||
// will ever describe your routes.
|
||||
//
|
||||
// The failure mode is the dangerous one: swagger-autogen reports success and
|
||||
// emits a spec with the routes simply absent. It happened twice inside core
|
||||
// before anyone noticed, and once to the first module — 417 annotations that
|
||||
// generated nothing at all, for two phases, because nobody had built the
|
||||
// fragment. If you take one thing from this file, take that a green build is not
|
||||
// evidence that anything was described.
|
||||
//
|
||||
// ── Where the prefixes come from ──────────────────────────────────────────
|
||||
//
|
||||
// swagger-autogen is pointed at one router file at a time, so its paths come out
|
||||
// relative to that router (`/status`, not `/api/v1/public/world/status`) —
|
||||
// nothing in the file says where it hangs. §6.1a requires fully-qualified paths,
|
||||
// because core merges the fragment verbatim and never re-derives a prefix.
|
||||
//
|
||||
// So this script **runs your own `register()`** against a recording `api` and
|
||||
// reads the mounts back out. Every prefix is therefore the prefix that router is
|
||||
// actually registered under — the same call an operator's core will make, rather
|
||||
// than a table beside it that drifts the first time a mount moves. Which file a
|
||||
// recorded router object came from is answered by `require.cache`: the module
|
||||
// whose `exports` IS that router.
|
||||
//
|
||||
// The tier base paths are the one thing that cannot be derived here, because they
|
||||
// are core's and not yours. They are §2.4's normative table, quoted below.
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||
|
||||
const { fakeCtx, fakeApi } = require('../test/_fakes')
|
||||
const doc = require('../swagger/doc')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
|
||||
// router in core, behind its gate; the base path is core's and fixed.
|
||||
const TIER_BASE = {
|
||||
public: '/api/v1/public',
|
||||
admin: '/api/v1/admin',
|
||||
player: '/api/v1/player',
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
|
||||
*
|
||||
* The ctx is the test fakes' — the same one the suite proves the module runs
|
||||
* against — because registration must not touch a database (§2.2), and this
|
||||
* script is exactly the kind of no-database caller that rule exists for.
|
||||
*/
|
||||
function mountedRouters() {
|
||||
const register = require('../index')
|
||||
const api = fakeApi()
|
||||
register(fakeCtx(), api)
|
||||
|
||||
const fileOf = (router) => {
|
||||
for (const mod of Object.values(require.cache)) {
|
||||
if (mod && mod.exports === router) return mod.filename
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mounts = []
|
||||
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
|
||||
const base = TIER_BASE[tier]
|
||||
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
|
||||
}
|
||||
}
|
||||
|
||||
return mounts.map(({ router, prefix, what }) => {
|
||||
const file = fileOf(router)
|
||||
if (!file) {
|
||||
// A router built inline in index.js rather than required from its own
|
||||
// file. swagger-autogen needs a file to read, so there is nothing to
|
||||
// generate from — put the router in its own module.
|
||||
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
|
||||
}
|
||||
return { file, prefix, what }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run swagger-autogen over one router file. Paths come out router-relative.
|
||||
*
|
||||
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
|
||||
* `console.error`s "Syntax error" or "out of structure", drops that one
|
||||
* annotation, and prints `Success` in green. So its diagnostics are captured here
|
||||
* and made fatal. Nothing else will tell you.
|
||||
*/
|
||||
async function fragmentFor(file) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-'))
|
||||
const out = path.join(dir, 'fragment.json')
|
||||
|
||||
const complaints = []
|
||||
const realError = console.error
|
||||
console.error = (...args) => {
|
||||
const line = args.map(String).join(' ')
|
||||
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
|
||||
else realError(...args)
|
||||
}
|
||||
try {
|
||||
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
|
||||
// writes its result back into the object it was handed, so reusing one `doc`
|
||||
// across several routers re-wraps the previous pass's output every time. The
|
||||
// first module to hit this produced a 484 MB fragment from six routers.
|
||||
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
|
||||
...JSON.parse(JSON.stringify(doc)),
|
||||
info: { title: 'examplegame fragment', version: '0' },
|
||||
})
|
||||
} finally {
|
||||
console.error = realError
|
||||
}
|
||||
if (complaints.length > 0) {
|
||||
throw new Error(
|
||||
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
|
||||
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-root a router-relative fragment under the prefix it is mounted at.
|
||||
*
|
||||
* Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging
|
||||
* to the PREFIX is moved to the front of each operation's parameter list —
|
||||
* swagger-autogen orders parameters by where they appeared in the path it saw,
|
||||
* which was only the tail.
|
||||
*/
|
||||
function prefixPaths(fragment, prefix) {
|
||||
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
||||
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
||||
const paths = {}
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const operation of Object.values(item)) {
|
||||
const params = operation && operation.parameters
|
||||
if (!Array.isArray(params)) continue
|
||||
const rank = (q) => {
|
||||
const i = outer.indexOf(q && q.name)
|
||||
return i === -1 ? outer.length : i
|
||||
}
|
||||
operation.parameters = params
|
||||
.map((q, i) => ({ q, i }))
|
||||
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
|
||||
.map(({ q }) => q)
|
||||
}
|
||||
// `router.get('/')` under a prefix concatenates to a trailing slash, a URL no
|
||||
// client calls. Core's generator normalises the same way.
|
||||
paths[`${oas}${p}`.replace(/\/$/, '')] = item
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the whole fragment: every mounted router, re-rooted and merged.
|
||||
*
|
||||
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a
|
||||
* fragment carry. `info`, `servers` and the security schemes belong to the merged
|
||||
* document, which is to say to core.
|
||||
*/
|
||||
async function build() {
|
||||
const spec = { paths: {}, tags: [], components: { schemas: {} } }
|
||||
let shared = false
|
||||
|
||||
for (const { file, prefix, what } of mountedRouters()) {
|
||||
const generated = await fragmentFor(file)
|
||||
// Tags and schemas are the same on every pass — each was handed the same
|
||||
// `doc` — so take them from whichever ran first. What lands in the fragment
|
||||
// has to be what swagger-autogen PRODUCED and not what it was given: those
|
||||
// two differ (see fragmentFor), and core merges this file verbatim into a
|
||||
// spec whose own schemas went through the same mill.
|
||||
if (!shared) {
|
||||
spec.tags = generated.tags || []
|
||||
spec.components.schemas = (generated.components || {}).schemas || {}
|
||||
shared = true
|
||||
}
|
||||
const paths = prefixPaths(generated, prefix)
|
||||
const count = Object.keys(paths).length
|
||||
if (count === 0) {
|
||||
// An empty result is precisely what the silent drop looks like, so it is a
|
||||
// hard failure rather than a router that happens to declare no routes.
|
||||
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
|
||||
}
|
||||
for (const [p, item] of Object.entries(paths)) {
|
||||
if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`)
|
||||
spec.paths[p] = item
|
||||
}
|
||||
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
|
||||
}
|
||||
|
||||
// Sorted, because swagger-autogen emits router-traversal order: without this,
|
||||
// moving a route between files rewrites most of a committed artifact even when
|
||||
// the API is provably unchanged.
|
||||
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||
return spec
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const spec = await build()
|
||||
const json = `${JSON.stringify(spec, null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(FRAGMENT, json)
|
||||
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(FRAGMENT)) {
|
||||
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
// 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' +
|
||||
'so a stale one documents a URL surface this module does not serve.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`${err.stack}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }
|
||||
285
template/server/sidecarClient.js
Normal file
285
template/server/sidecarClient.js
Normal 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 }
|
||||
105
template/server/swagger/doc.js
Normal file
105
template/server/swagger/doc.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// ── The OpenAPI fragment: the shared half ─────────────────────────────────
|
||||
//
|
||||
// The tags and component schemas your `#swagger.*` annotations refer to.
|
||||
// `scripts/swaggerFragment.js` feeds this to swagger-autogen; the per-endpoint
|
||||
// detail lives beside each route, exactly as it does in core.
|
||||
//
|
||||
// **Two rules about names, and both belong to the MERGED document rather than to
|
||||
// this file** (MODULE_API.md §6.1a). Core merges every started module's fragment
|
||||
// over its own spec and serves the result at `/api/docs.json`, and core wins any
|
||||
// key collision:
|
||||
//
|
||||
// • **Namespace what you DEFINE.** `ExamplegameWorldStatus`, not `WorldStatus`.
|
||||
// A second game's module describing the same idea under the same bare name
|
||||
// would silently clobber yours or be clobbered by it. The prefix is what
|
||||
// makes two modules able to coexist.
|
||||
// • **Reference what CORE defines by core's name.** `#/components/schemas/Error`
|
||||
// and `ValidationError` are core's; point at them and do not redefine them.
|
||||
// They resolve in the merged document, where core's definitions are. Shipping
|
||||
// your own copy is a collision core drops — which is the right outcome, and
|
||||
// an expensive way to learn it.
|
||||
//
|
||||
// A tag is how the docs UI groups operations. Name yours after your module so an
|
||||
// operator reading `/api/docs` can see which operations arrived with it.
|
||||
//
|
||||
// **swagger-autogen renders `components.schemas` from an EXAMPLE object, not from
|
||||
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
|
||||
// That is uniform across core's committed spec and is the house shape — match it,
|
||||
// do not fight it.
|
||||
|
||||
module.exports = {
|
||||
tags: [
|
||||
{
|
||||
name: 'Public · Example Game',
|
||||
description: 'Live world data and the game’s clans, as last reported by the game server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
schemas: {
|
||||
ExamplegameWorldStatus: {
|
||||
type: 'object',
|
||||
description: 'The game world’s status (GET /public/world/status).',
|
||||
properties: {
|
||||
online: { type: 'boolean', example: true },
|
||||
players: { type: 'integer', example: 12 },
|
||||
worldName: { type: 'string', nullable: true, example: 'Example World' },
|
||||
updatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
stale: {
|
||||
type: 'boolean',
|
||||
description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.',
|
||||
example: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
136
template/server/test/_fakes.js
Normal file
136
template/server/test/_fakes.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// ── Test doubles for what core hands the module ───────────────────────────
|
||||
//
|
||||
// Your server half is testable WITHOUT core, and that is not a convenience — it
|
||||
// is the contract holding. Everything a module may touch arrives on `ctx`
|
||||
// (MODULE_API.md §2.3), so a `ctx` this file can build is a complete statement of
|
||||
// what your module depends on. **If a test ever needs something that is not here,
|
||||
// either your module reached past the boundary or §2.3 needs a new member.** Both
|
||||
// are worth stopping for.
|
||||
//
|
||||
// The fake mirrors §2.3 member for member — including the freezing, so a module
|
||||
// that assigns to `ctx.something` fails here the way it would in core.
|
||||
//
|
||||
// This file lives under `test/`, which `checkImports.js` treats as not-shipped —
|
||||
// which is why it may `require('express')` when the module's own routers may not.
|
||||
// It builds a REAL express Router on purpose: a fake Router would only ever test
|
||||
// the fake.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
/** Records every call, so a test can assert what the module asked for. */
|
||||
function spy(returns) {
|
||||
const fn = (...args) => {
|
||||
fn.calls.push(args)
|
||||
return typeof returns === 'function' ? returns(...args) : returns
|
||||
}
|
||||
fn.calls = []
|
||||
return fn
|
||||
}
|
||||
|
||||
function fakeLog() {
|
||||
return { error: spy(), warn: spy(), info: spy(), debug: spy() }
|
||||
}
|
||||
|
||||
function fakeCtx(overrides = {}) {
|
||||
// `freeze: false` is a test seam for a suite that wants to adjust the ctx it
|
||||
// installed. Core always freezes; the unfrozen variant is never a claim about
|
||||
// what a module is handed in production.
|
||||
const { freeze = true, ...rest } = overrides
|
||||
const logs = []
|
||||
const ctx = {
|
||||
moduleId: 'examplegame',
|
||||
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
|
||||
express,
|
||||
validator: {},
|
||||
db: { query: spy(Promise.resolve([])), pool: {} },
|
||||
log: (namespace) => {
|
||||
const log = fakeLog()
|
||||
logs.push({ namespace, log })
|
||||
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(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
// The factory returns a pass-through rather than a real limiter: a test
|
||||
// that tripped a rate limit would be a test whose result depended on how
|
||||
// many times the suite had run.
|
||||
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
|
||||
accountChangeLimiter: (req, res, next) => next(),
|
||||
},
|
||||
site: { baseUrl: 'http://localhost:5173' },
|
||||
...rest,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
|
||||
// by the loop below and every `log.info` would throw on push. Keeping it out of
|
||||
// the enumeration also makes the fake more faithful: a module iterating `ctx`
|
||||
// sees §2.3's members and nothing a test put there.
|
||||
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||
if (!freeze) return ctx
|
||||
for (const value of Object.values(ctx)) {
|
||||
if (value && typeof value === 'object') Object.freeze(value)
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration api, recording rather than mounting.
|
||||
*
|
||||
* Copies core's `once()` rule (§2.4: "calling twice is an error"), so a module
|
||||
* that registers the same thing twice fails in its own suite rather than first on
|
||||
* an operator's install.
|
||||
*/
|
||||
function fakeApi() {
|
||||
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`)
|
||||
called.add(name)
|
||||
}
|
||||
const api = {
|
||||
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
|
||||
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
||||
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 },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
}
|
||||
|
||||
module.exports = { fakeCtx, fakeApi, spy }
|
||||
149
template/server/test/checkImports.test.js
Normal file
149
template/server/test/checkImports.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// The boundary check, checked.
|
||||
//
|
||||
// `scripts/checkImports.js` is the acceptance test for the whole module contract
|
||||
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
|
||||
// nobody knows the state of. These point it at fixtures that break each rule and
|
||||
// assert it says so — and at prose that merely *describes* breaking them, which
|
||||
// is what it got wrong the first time it was run.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing.** The scanner
|
||||
// reads the files in this directory too, so an ordinary quoted string holding
|
||||
// `require('../../x')` would make this file fail the very check it is testing.
|
||||
// Templates are blanked by the stripper for exactly this class of text: source
|
||||
// being composed as data is not source being imported.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
|
||||
|
||||
/** Write `files` into a throwaway module tree and scan it. */
|
||||
function scanFixture(files, { dev = new Set() } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-tpl-'))
|
||||
const src = path.join(root, 'server')
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(src, name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
try {
|
||||
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('the real server half is clean', () => {
|
||||
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
|
||||
})
|
||||
|
||||
test('catches a relative path that escapes the module root', () => {
|
||||
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.strictEqual(found[0].why, 'escapes the module root')
|
||||
})
|
||||
|
||||
test('allows a relative path that stays inside it, however deep', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches an absolute path', () => {
|
||||
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
|
||||
assert.strictEqual(found[0].why, 'absolute path')
|
||||
})
|
||||
|
||||
test('catches a bare specifier in shipped code, even a devDependency', () => {
|
||||
// The rule that makes the boundary real: express arrives on ctx. A shipped
|
||||
// file requiring it would fail on a real install, because a module lives
|
||||
// outside core's server/ and never reaches core's node_modules.
|
||||
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0].why, /should this come from ctx/)
|
||||
})
|
||||
|
||||
test('allows a devDependency in test code, which never runs inside core', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows node builtins anywhere, with or without the node: prefix', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows node:test, which older Node versions omit from builtinModules', () => {
|
||||
// The first CI run failed on exactly this and on nothing else: `builtinModules`
|
||||
// omits `test` on Node 20 and includes it on Node 24, so every test file in
|
||||
// this suite was reported as breaking the module boundary. The check asks
|
||||
// Node (`isBuiltin`) rather than rebuilding the list, and treats the `node:`
|
||||
// prefix as sufficient on its own — a prefixed specifier can never resolve to
|
||||
// a package, whatever the running version enumerates.
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': `require('node:test'); require('node:test/reporters')` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches ESM and dynamic forms, not only require()', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 2)
|
||||
})
|
||||
|
||||
test('ignores a violation that is only DESCRIBED in a comment', () => {
|
||||
// The first run of this check failed on its own documentation, and on
|
||||
// index.js's comment explaining why the module must never require('express').
|
||||
// Prose about the rule must not trip the rule.
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({
|
||||
'a.js': [
|
||||
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
|
||||
`/* Nor import express from "express": core hands it over on ctx. */`,
|
||||
`const path = require('path')`,
|
||||
].join('\n'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores a specifier-shaped string inside a template literal', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('a comment opener inside a string does not swallow the rest of the file', () => {
|
||||
// The reason this is a character walk and not a regexp: a URL in a string
|
||||
// contains `//`, and treating that as a comment would blank everything after
|
||||
// it — turning the check into one that silently passes.
|
||||
const found = scanFixture({
|
||||
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
|
||||
})
|
||||
|
||||
test('a quote inside a comment does not swallow the rest of the file', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1)
|
||||
})
|
||||
|
||||
test('stripping preserves line numbers', () => {
|
||||
// Blanked rather than removed, so anything that later reports a line still
|
||||
// reports the right one.
|
||||
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
|
||||
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
|
||||
})
|
||||
207
template/server/test/clanProvider.test.js
Normal file
207
template/server/test/clanProvider.test.js
Normal 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}'))
|
||||
})
|
||||
353
template/server/test/entry.test.js
Normal file
353
template/server/test/entry.test.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// ── The registration handshake ────────────────────────────────────────────
|
||||
//
|
||||
// The one suite every module should have, whatever else it does. Core validates
|
||||
// all of this at boot and refuses to mount a module that fails — so testing it
|
||||
// here is the difference between finding out in half a second and finding out on
|
||||
// an operator's install.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||
const manifest = require('../../module.json')
|
||||
|
||||
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
|
||||
function register(ctx = fakeCtx()) {
|
||||
require('../core')._reset()
|
||||
const api = fakeApi()
|
||||
require('../index')(ctx, api)
|
||||
return { api, ctx }
|
||||
}
|
||||
|
||||
test('registers exactly the mounts module.json declares', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
|
||||
// declared and never registered is as fatal as a route registered and never
|
||||
// declared. Asserting it against the manifest rather than against a literal is
|
||||
// what keeps the test true after you add a prefix.
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(api.record.routes).sort(),
|
||||
Object.keys(manifest.mounts).sort(),
|
||||
)
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
|
||||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
|
||||
}
|
||||
})
|
||||
|
||||
test('every registered mount is a real express router', () => {
|
||||
const { api } = register()
|
||||
for (const byPrefix of Object.values(api.record.routes)) {
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
|
||||
assert.ok(router.stack, `${prefix} has no middleware stack`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('prefixes are one segment, lowercase, no parameters', () => {
|
||||
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
|
||||
// module that fails it does not mount at all.
|
||||
for (const prefixes of Object.values(manifest.mounts)) {
|
||||
for (const prefix of prefixes) {
|
||||
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('registration touches no database and awaits nothing', () => {
|
||||
const ctx = fakeCtx()
|
||||
register(ctx)
|
||||
|
||||
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
|
||||
// port in two build tools, so a query here would hang both — and the symptom
|
||||
// is a build that never finishes rather than an error naming this module.
|
||||
assert.deepStrictEqual(ctx.db.query.calls, [])
|
||||
})
|
||||
|
||||
test('registers both lifecycle hooks', () => {
|
||||
const { api } = register()
|
||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||
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+/)
|
||||
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
|
||||
// Declaring a schema without a purge is refused: a module that can create
|
||||
// tables and cannot drop them leaves an operator with orphaned data.
|
||||
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
|
||||
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
|
||||
// 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)
|
||||
})
|
||||
399
template/server/test/eventActions.test.js
Normal file
399
template/server/test/eventActions.test.js
Normal 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
|
||||
}
|
||||
})
|
||||
124
template/server/test/noGameConnection.test.js
Normal file
124
template/server/test/noGameConnection.test.js
Normal 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`)
|
||||
}
|
||||
})
|
||||
116
template/server/test/schema.test.js
Normal file
116
template/server/test/schema.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── The schema fragment, checked against §2.6's rules ─────────────────────
|
||||
//
|
||||
// Core validates the fragment at LOAD time and refuses to mount a module that
|
||||
// breaks a rule — with no tables created and no routes served. That is the right
|
||||
// behaviour and a slow way to find a typo, so the same rules are checked here.
|
||||
//
|
||||
// **This is also the suite that catches a half-finished rename.** Change the id
|
||||
// in `module.json` and forget a table name, and the prefix assertion below fails
|
||||
// immediately rather than at an operator's first boot.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const manifest = require('../../module.json')
|
||||
|
||||
const read = (rel) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8')
|
||||
|
||||
/**
|
||||
* Split a SQL file into statements the way core does.
|
||||
*
|
||||
* Core's own splitter is shared code (`utils/sqlStatements.js`) used by both the
|
||||
* loader and the schema replay — this is a small stand-in for a test, and it is
|
||||
* deliberately simple because the fragment it reads is deliberately simple. If
|
||||
* your schema grows a stored procedure or a string containing a semicolon, stop
|
||||
* trusting this and read the fragment a different way.
|
||||
*/
|
||||
function statements(sql) {
|
||||
return sql
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const schema = statements(read(manifest.schema))
|
||||
const purge = statements(read(manifest.purge))
|
||||
|
||||
// The allowlist core enforces. Note it is an ALLOWLIST and not a `DROP` denylist:
|
||||
// this file replays on every boot, so TRUNCATE or DELETE would empty a table on
|
||||
// every restart — which no denylist naming only DROP would have caught.
|
||||
const ALLOWED_VERBS = ['CREATE', 'ALTER', 'INSERT', 'UPDATE']
|
||||
|
||||
test('every statement starts with an allowed verb', () => {
|
||||
for (const statement of schema) {
|
||||
const verb = statement.split(/\s+/)[0].toUpperCase()
|
||||
assert.ok(ALLOWED_VERBS.includes(verb), `"${verb}" is not one of ${ALLOWED_VERBS.join(', ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every table is prefixed with the module id', () => {
|
||||
for (const statement of schema) {
|
||||
const match = /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(statement)
|
||||
if (!match) continue
|
||||
assert.ok(
|
||||
match[1].startsWith(`${manifest.id}_`),
|
||||
`table "${match[1]}" is not prefixed "${manifest.id}_" — core will refuse to load this module`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('the fragment is idempotent — it replays on every boot', () => {
|
||||
for (const statement of schema) {
|
||||
if (/^CREATE\s+TABLE/i.test(statement)) {
|
||||
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'CREATE TABLE without IF NOT EXISTS')
|
||||
}
|
||||
if (/^ALTER\s+TABLE/i.test(statement) && /ADD\s+COLUMN/i.test(statement)) {
|
||||
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'ADD COLUMN without IF NOT EXISTS')
|
||||
}
|
||||
if (/^INSERT\s+INTO/i.test(statement)) {
|
||||
// A plain INSERT succeeds once and then fails the whole replay on the next
|
||||
// boot with a duplicate key — the classic "worked until I restarted it".
|
||||
assert.ok(
|
||||
/INSERT\s+IGNORE/i.test(statement) || /ON\s+DUPLICATE\s+KEY/i.test(statement),
|
||||
'INSERT must be IGNORE or carry ON DUPLICATE KEY — it runs again every boot',
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('purge drops every table the schema creates', () => {
|
||||
const created = schema
|
||||
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
const dropped = purge
|
||||
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
|
||||
for (const table of created) {
|
||||
assert.ok(dropped.includes(table), `${table} is created but never dropped — purge would orphan it`)
|
||||
}
|
||||
for (const table of dropped) {
|
||||
assert.ok(created.includes(table), `${table} is dropped but never created`)
|
||||
}
|
||||
})
|
||||
|
||||
test('purge drops in the reverse of creation order', () => {
|
||||
// With one table this proves nothing; with a parent and its children it is the
|
||||
// difference between a clean teardown and a purge that fails halfway, leaving
|
||||
// exactly the orphaned data it exists to remove.
|
||||
const created = schema
|
||||
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
const dropped = purge
|
||||
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
|
||||
assert.deepStrictEqual(dropped, [...created].reverse())
|
||||
})
|
||||
58
template/server/test/worldStatus.test.js
Normal file
58
template/server/test/worldStatus.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// ── The model, with no database ───────────────────────────────────────────
|
||||
//
|
||||
// The `.db.js` / `.model.js` split pays for itself here: the logic worth testing
|
||||
// is in the model, and the model's only dependency is a function that returns a
|
||||
// row. Stub that and there is nothing to stand up.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const db = require('../model/worldStatus/worldStatus.db')
|
||||
const { getPublicStatus, STALE_AFTER_MS } = require('../model/worldStatus/worldStatus.model')
|
||||
|
||||
const NOW = Date.parse('2026-08-12T12:00:00Z')
|
||||
|
||||
/** Replace `getStatus` for one test and put it back afterwards. */
|
||||
function withRow(row, fn) {
|
||||
const real = db.getStatus
|
||||
db.getStatus = async () => row
|
||||
return Promise.resolve(fn()).finally(() => { db.getStatus = real })
|
||||
}
|
||||
|
||||
test('a fresh row reports the world online', () =>
|
||||
withRow(
|
||||
{ online: 1, players: 12, worldName: 'Example World', updatedAt: new Date(NOW - 1000) },
|
||||
async () => {
|
||||
const status = await getPublicStatus(NOW)
|
||||
assert.strictEqual(status.online, true)
|
||||
assert.strictEqual(status.players, 12)
|
||||
assert.strictEqual(status.worldName, 'Example World')
|
||||
assert.strictEqual(status.stale, false)
|
||||
},
|
||||
))
|
||||
|
||||
test('a stale row is reported offline, whatever it says', () =>
|
||||
withRow(
|
||||
{ online: 1, players: 12, worldName: 'Example World', updatedAt: new Date(NOW - STALE_AFTER_MS - 1) },
|
||||
async () => {
|
||||
const status = await getPublicStatus(NOW)
|
||||
// The row claims the world is up. Nothing has written it in longer than the
|
||||
// freshness window, so the claim is not evidence of anything.
|
||||
assert.strictEqual(status.online, false)
|
||||
assert.strictEqual(status.players, 0)
|
||||
assert.strictEqual(status.stale, true)
|
||||
// The name is still worth showing — it does not go stale the way a player
|
||||
// count does.
|
||||
assert.strictEqual(status.worldName, 'Example World')
|
||||
},
|
||||
))
|
||||
|
||||
test('no row at all is an answer, not an error', () =>
|
||||
withRow(null, async () => {
|
||||
// A fresh install whose first boot has not finished replaying the schema.
|
||||
// The site must render; the next boot fixes it.
|
||||
const status = await getPublicStatus(NOW)
|
||||
assert.deepStrictEqual(status, {
|
||||
online: false, players: 0, worldName: null, updatedAt: null, stale: true,
|
||||
})
|
||||
}))
|
||||
495
template/swagger-fragment.json
Normal file
495
template/swagger-fragment.json
Normal file
@@ -0,0 +1,495 @@
|
||||
{
|
||||
"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": [
|
||||
"Public · Example Game"
|
||||
],
|
||||
"summary": "The game world’s current status",
|
||||
"description": "What the game server last reported: whether it is up, how many players are on, and when that was. Answers with `online: false` and `stale: true` rather than failing when the game or its sidecar is unreachable — the site’s availability does not depend on the game’s.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The world’s status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExamplegameWorldStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Public · Example Game",
|
||||
"description": "Live world data and the game’s clans, as last reported by the game server"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ExamplegameWorldStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The game world’s status (GET /public/world/status)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"online": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"worldName": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Example World"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"stale": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Has nothing reported in longer than the freshness window? A stale row is reported offline."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user