# 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**, `@=`, for a deployment that declares its module set rather than clicking it. 3. **A directory on the volume.** Copy this whole tree to `/modules//` 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**. | 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 `` 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` | 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.