Merge pull request 'feat(kit): the two shapes Teams added, taught and built (Teams phase 11)' (#6) from feature/teams-phase11 into main

Reviewed-on: #6
This commit is contained in:
2026-08-19 09:03:38 +00:00
27 changed files with 1925 additions and 59 deletions

View File

@@ -149,9 +149,9 @@ Two more things that look like your module failing and are not:
Restart core and read the log. A module that loaded says so: Restart core and read the log. A module that loaded says so:
``` ```
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world"} INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world,/clans"}
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world"]}} INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world","/clans"]}}
INFO [modules] schema ensured for module "examplegame" {"statements":2} INFO [modules] schema ensured for module "examplegame" {"statements":4}
INFO [examplegame:boot] booted {"refreshMs":30000} INFO [examplegame:boot] booted {"refreshMs":30000}
INFO [modules] module "examplegame" started INFO [modules] module "examplegame" started
``` ```
@@ -165,7 +165,13 @@ Then, in the browser:
- **`/examplegame/status`** renders your page, with a **World** row in the public - **`/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 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. reorder it, relabel it or hide it from the nav editor exactly as they can core's.
- **`/api/v1/public/world/status`** answers JSON. - **`/examplegame/clans`** lists the two clans the template seeds at boot, and one
of them renders at `/examplegame/clans/clan-1` — the page that declares three
places for core to fill. On a core with Teams those hold the activity feed, the
forum and the notification control; on one without, they render nothing and the
page is exactly as complete. Both are correct outcomes and neither logs anything.
- **`/api/v1/public/world/status`** answers JSON, and so does
`/api/v1/public/clans`.
- **`/api/v1/public/modules`** lists you, with the `capabilities` array from your - **`/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 — `module.json`. This is how a client — core's SPA, the Android app, anything —
feature-detects you. feature-detects you.

View File

@@ -147,7 +147,7 @@ statement of your dependencies, and it makes a test double for it — see
## What you register ## What you register
Seven calls, all synchronous, all documented in [§2.4][api]. What is worth knowing Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
is not their signatures but the model behind them. is not their signatures but the model behind them.
**Every call stages; nothing is committed until your whole module is known good.** **Every call stages; nothing is committed until your whole module is known good.**
@@ -183,8 +183,8 @@ An operator looking at a user in the admin panel wants that user's characters
right there, not on a separate screen. right there, not on a separate screen.
`api.registerExtension(slot, router)` mounts your routes under a core resource, `api.registerExtension(slot, router)` mounts your routes under a core resource,
and its client twin renders your component inside a core page. **Only core may and its client twin renders your component inside a core page. Core declares the
declare a slot; a module may only fill one**, and one module per slot. slot, you fill it, and one module per slot.
The naming rule is worth internalising, because it is what keeps a game-agnostic 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.** core game-agnostic: **a slot is named for a PLACE, never for a meaning.**
@@ -194,6 +194,63 @@ styling; the module owns the label, the target, the data, and whether it renders
anything at all. The moment core types a slot by its content, it has re-acquired anything at all. The moment core types a slot by its content, it has re-acquired
the semantics the module system exists to remove. 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 ### Notification streams, announce legs, post hooks
Three registries for three genuinely different things, and the distinctions are Three registries for three genuinely different things, and the distinctions are
@@ -222,6 +279,95 @@ Every hook is awaited and none may throw past core: a subscriber's failure costs
neither another subscriber nor the save itself. A hiccup in your sidecar breaking neither another subscriber nor the save itself. A hiccup in your sidecar breaking
somebody's blog post edit would be a worse bug than a stale mirror. somebody's blog post edit would be a worse bug than a stale mirror.
### 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 ### The lifecycle hooks
`api.onBoot(fn)` runs after core's schema, after your schema fragment, and `api.onBoot(fn)` runs after core's schema, after your schema fragment, and
@@ -388,9 +534,16 @@ every case than one that shows a link which then answers `403`.
Core publishes a small set of components and hooks on `window.__rg.ui` 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 ([§3.4][api] is the list): the public layout, a page header, the loading, error
and empty states, the async hook every data page uses, and read-only access to the and empty states, the async hook every data page uses, read-only access to the
session and site settings. Enough to build a page that looks like the site it is session and site settings, and `Slot`. Enough to build a page that looks like the
installed in, and nothing else. 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 **`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` layout is the *chrome* — header, footer, the flex column they sit in. The `shell`
@@ -413,6 +566,14 @@ That paragraph exists because the kit's acceptance run
([`kit-acceptance.md`][acceptance]) built a module by following this chapter to the ([`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. 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 **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; 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. changing an existing prop is a major one.
@@ -514,6 +675,7 @@ where you publish.
| Do not read `process.env` for core configuration | Configuration with two sources and no panel. Your own config is a settings key or your own table. | | 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. | | 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. | | 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). | | **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 That last one is newer than the others and is the reason this kit is three

View File

@@ -1,29 +1,34 @@
{ {
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"branch": "main", "branch": "main",
"ref": "4ad8b2bb0ede2747622075dcfa4cb1fe460f91ca", "ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
"why": [ "why": [
"The core this kit is written against, pinned to a commit rather than a branch.", "The core this kit is written against, pinned to a commit rather than a branch.",
"This one is the MODULE_API_VERSION 1.5.0 bump, which is the version", "This one is the Teams cutover, the commit MODULE_API_VERSION 1.6.0 reached",
"template/module.json declares. It moved here from the 1.4.0 bump because", "`main` on, and 1.6.0 is what template/module.json declares. It moved here from",
"the kit's acceptance run found PublicLayout had no way to give a module", "the 1.5.0 bump because Teams expanded the contract the book teaches: the",
"page the site's body wrapper, and core grew a `shell` prop for it - so the", "template now registers a Team provider and declares slots for core to fill,",
"template now uses a member that only exists at this ref and later.", "and both are members that exist only at this ref and later.",
"", "",
"Moving this pin is the moment someone re-reads the chapters: CI asserts the", "Moving this pin is the moment someone re-reads the chapters: CI asserts the",
"version template/module.json declares still equals this core's", "version template/module.json declares still equals this core's",
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose", "MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10).", "(MODULE_SYSTEM.md 2.11.1 d2, 2.10).",
"", "",
"The branch said `edge` until 2026-08-12, when the module system cut over", "That mechanism earned its keep this time. Writing the chapters against 1.6.0",
"and that branch was deleted (MODULE_SYSTEM.md 2.9). The SHA DID NOT MOVE:", "found that core's inverted-slot fills named three of module-uo's slots",
"the pinned commit is an ancestor of `main`, so this is a label correction", "literally, so the direction worked for that one module and silently did",
"and not a re-pin - the contract is still 1.5.0 and no chapter changed.", "nothing for any other game - an empty page with nothing logged. That is the",
"Nothing in CI reads this field; it clones the repo and checks out the sha,", "exact class of thing a book written for an audience outside this org is meant",
"which is why the cutover could not break the build and why a wrong label", "to catch, and it was fixed in core before this pin moved.",
"here would have sat unnoticed. It is for the person deciding whether a", "",
"newer core is worth re-reading the book for, and a branch that no longer", "The branch said `edge` until 2026-08-12, when the module system cut over and",
"exists tells them nothing.", "that branch was deleted (MODULE_SYSTEM.md 2.9). Teams cut a second `edge` and",
"this pin skipped it entirely: 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", "Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
"sha, reviewable in a diff." "sha, reviewable in a diff."

View File

@@ -5,11 +5,16 @@ rename it, and you have a running module before you have read a chapter.
Installed into a core, it adds: Installed into a core, it adds:
- **one public page** at `/examplegame/status`, and a nav row pointing at it; - **three public pages** `/examplegame/status`, `/examplegame/clans` and one
- **one API route**, `GET /api/v1/public/world/status`, described in an OpenAPI clan at `/examplegame/clans/:externalId` — and nav rows pointing at the first two;
fragment core merges into its own `/api/docs`; - **three API routes** under `/api/v1/public/world` and `/api/v1/public/clans`,
- **one table**, `examplegame_world_status`, created by an idempotent schema 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; 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;
- **both lifecycle hooks**, so there is something to see at boot and at shutdown. - **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 That is deliberately less than your module will do. What it is *complete* about is
@@ -27,17 +32,18 @@ server/
db/schema.sql idempotent, replayed every boot db/schema.sql idempotent, replayed every boot
db/purge.sql destructive, run only by an explicit admin purge db/purge.sql destructive, run only by an explicit admin purge
model/worldStatus/ the .db.js / .model.js pair model/worldStatus/ the .db.js / .model.js pair
router/public/ one router, one controller, the #swagger annotations model/clans/ the Team provider, its SQL, and the audience rule
router/public/ two routers, two controllers, the #swagger annotations
swagger/doc.js tags and schemas the annotations refer to swagger/doc.js tags and schemas the annotations refer to
scripts/checkImports.js the module boundary, enforced scripts/checkImports.js the module boundary, enforced
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
test/ the suites — start with entry.test.js test/ the suites — start with entry.test.js
client/ client/
vite.config.js the library build: anchored aliases, external: [] vite.config.js the library build: anchored aliases, external: []
src/entry.jsx registers routes and nav at evaluation time src/entry.jsx registers routes, nav and declared slots at evaluation time
src/core.js what core hands you: the UI kit (eight exports) src/core.js what core hands you: the UI kit (nine exports)
src/shim/ the four shared dependencies, re-exported from core src/shim/ the four shared dependencies, re-exported from core
src/routes/public/ the page src/routes/public/ the pages — Clan.jsx is the one with slots in it
scripts/checkExternals.js asks the BUILT chunk whether a bare import survived scripts/checkExternals.js asks the BUILT chunk whether a bare import survived
test/ build.test.js and registration.test.js test/ build.test.js and registration.test.js
.gitea/workflows/release.yml packaging CI — Gitea .gitea/workflows/release.yml packaging CI — Gitea
@@ -110,17 +116,23 @@ backticking table names**.
| `server/db/schema.sql` | every table name — the prefix must be your id | | `server/db/schema.sql` | every table name — the prefix must be your id |
| `server/db/purge.sql` | the same table names | | `server/db/purge.sql` | the same table names |
| `server/model/worldStatus/worldStatus.db.js` | the `TABLE` constant | | `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/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/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` | | `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
| `server/test/_fakes.js` | `ctx.moduleId` | | `server/test/_fakes.js` | `ctx.moduleId` |
| `server/test/worldStatus.test.js` | the fixture's world name | | `server/test/worldStatus.test.js` | the fixture's world name |
| `server/test/clanProvider.test.js` | the fixture's world name |
| `server/package-lock.json` | **regenerated**`npm install --prefix server` | | `server/package-lock.json` | **regenerated**`npm install --prefix server` |
| `client/package.json` | package `name` and `description` | | `client/package.json` | package `name` and `description` |
| `client/vite.config.js` | the guard plugin's `name` | | `client/vite.config.js` | the guard plugin's `name` |
| `client/src/core.js` | the console tag on the identity check | | `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/shim/rg.js` | the console tag on the missing-global error |
| `client/src/entry.jsx` | `ID`, and every route path and nav `to` | | `client/src/entry.jsx` | `ID`, every route path and nav `to`, and the three `declareModuleSlot` names — core enforces that a slot is namespaced under your id |
| `client/src/routes/public/Clans.jsx` | the link to the clan page |
| `client/src/routes/public/Clan.jsx` | the three `<Slot name>` values and their `moduleId` |
| `client/test/registration.test.js` | the example path in the comment | | `client/test/registration.test.js` | the example path in the comment |
| `client/package-lock.json` | **regenerated**`npm install --prefix client` | | `client/package-lock.json` | **regenerated**`npm install --prefix client` |
| `swagger-fragment.json` | **regenerated**`npm run swagger --prefix server` | | `swagger-fragment.json` | **regenerated**`npm run swagger --prefix server` |
@@ -133,11 +145,14 @@ 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 nothing left to rename. A checklist nobody verifies is a checklist that is wrong
by the second edit. by the second edit.
Two things you do **not** rename: the mount prefix `/world` need not be your id Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
(the server's prefix namespace is shared with core's, and `/status`, `/settings`, not be your id (the server's prefix namespace is shared with core's `/status`,
`/version` and `/contact` are already taken), and the `world` / `worldStatus` `/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 naming throughout is ordinary vocabulary you should replace with your own domain's
when you replace the feature. when you replace the feature. **`clan` in particular is the point rather than the
placeholder:** core's word is Team, yours is whatever your game says, and the
provider exists because core cannot pick one.
## Licence ## Licence

View File

@@ -27,8 +27,16 @@ export const world = {
status: () => req('/public/world/status'), 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 // Exported for the rare caller that needs the base itself — an `<img src>`, a
// download link, an EventSource. Reach for `request` first. // download link, an EventSource. Reach for `request` first.
export { BASE } export { BASE }
export default { world, BASE } export default { world, clans, BASE }

View File

@@ -45,10 +45,17 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
) )
} }
// The curated kit (§3.4). Eight exports, and it is CLOSED: layout, headings, the // The curated kit (§3.4). Nine exports, and it is CLOSED: layout, headings, the
// three data-page states, the fetch hook, and read-only access to the session and // three data-page states, the fetch hook, read-only access to the session and the
// the site's settings. Anything else your pages need — tables, tabs, an editor — // site's settings, and `Slot`. Anything else your pages need — tables, tabs, an
// you bundle yourself, in a `components/` directory of your own. // 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 // 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 // anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing an
@@ -64,6 +71,7 @@ export const {
useAsync, useAsync,
useAuth, useAuth,
useSite, useSite,
Slot,
} = rg.ui } = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages. // The registry, for entry.jsx. Everything else here is read by pages.

View File

@@ -19,6 +19,8 @@
import { registry, coreApiVersion } from './core.js' import { registry, coreApiVersion } from './core.js'
import WorldStatus from './routes/public/WorldStatus.jsx' 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 // Your module id, exactly as `module.json` spells it. Core keys the registry by
// it and prefixes every route path with it. // it and prefixes every route path with it.
@@ -42,6 +44,13 @@ const ID = 'examplegame'
registry.registerRoutes(ID, { registry.registerRoutes(ID, {
public: [ public: [
{ path: 'status', element: <WorldStatus /> }, { path: 'status', element: <WorldStatus /> },
{ path: 'clans', element: <Clans /> },
// A parameter, and the name matters twice: `useParams()` in the page reads
// `externalId`, and the server's `pageUrlTemplate` substitutes `{externalId}`
// into this same path so core's notification email can link here. Nothing
// checks those three against each other — this is the seam to get right by
// hand, and the cost of getting it wrong is mail linking at a page that 404s.
{ path: 'clans/:externalId', element: <Clan /> },
], ],
}) })
@@ -68,9 +77,58 @@ registry.registerNav(ID, {
area: 'public', area: 'public',
items: [ items: [
{ label: 'World', to: '/examplegame/status' }, { 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 // `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 // 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 // between the core that validated your manifest and the core that published this

View File

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

View File

@@ -0,0 +1,52 @@
// ── 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} />}
{data && data.clans.length === 0 && (
<EmptyState message="No clans have been reported yet." />
)}
{data && data.clans.length > 0 && (
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: '0.5rem' }}>
{data.clans.map((clan) => (
<li key={clan.externalId}>
<Link to={`/examplegame/clans/${clan.externalId}`}>
{clan.name}{clan.abbr ? ` [${clan.abbr}]` : ''}
</Link>
<span style={{ opacity: 0.7 }}>
{' '} {clan.memberCount} member{clan.memberCount === 1 ? '' : 's'}
</span>
</li>
))}
</ul>
)}
{data && data.stale && (
<p style={{ opacity: 0.7, marginTop: '1rem' }}>
The game has not reported recently, so this list may be out of date.
</p>
)}
</PublicLayout>
)
}

View File

@@ -48,7 +48,12 @@ export default function WorldStatus() {
<PublicLayout shell="narrow"> <PublicLayout shell="narrow">
<PageHeader <PageHeader
title="World status" title="World status"
subtitle="What the game server last told us about itself" // `lead`, not `subtitle`. PageHeader takes `eyebrow`, `title`, `lead` and
// `center`, and an unknown prop on a React component is silently dropped
// so a page written with `subtitle` renders its title and nothing else, on
// a site where every core page has a line under its heading. Nothing warns.
// Found by installing this template into a real core and looking at it.
lead="What the game server last told us about itself"
/> />
{loading && <Loading />} {loading && <Loading />}

View File

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

View File

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

View File

@@ -30,6 +30,7 @@
const core = require('./core') const core = require('./core')
const worldStatusDb = require('./model/worldStatus/worldStatus.db') const worldStatusDb = require('./model/worldStatus/worldStatus.db')
const clanDb = require('./model/clans/clanProvider.db')
const log = core.logger('boot') const log = core.logger('boot')
@@ -57,6 +58,42 @@ async function refresh() {
} }
} }
/**
* 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. * Runs once, after the schema and before the listener binds.
* *
@@ -66,6 +103,7 @@ async function refresh() {
*/ */
async function onBoot() { async function onBoot() {
await refresh() await refresh()
await seedClans()
refreshTimer = setInterval(refresh, REFRESH_MS) refreshTimer = setInterval(refresh, REFRESH_MS)
// Node keeps the process alive for a pending timer. Core's own intervals are // 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 // unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
@@ -89,4 +127,4 @@ async function onShutdown() {
log.info('shut down') log.info('shut down')
} }
module.exports = { onBoot, onShutdown, refresh, REFRESH_MS } module.exports = { onBoot, onShutdown, refresh, seedClans, REFRESH_MS }

View File

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

View File

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

View File

@@ -48,6 +48,8 @@ module.exports = function register(ctx, api) {
/* eslint-disable global-require */ /* eslint-disable global-require */
const worldRouter = require('./router/public/world.router') const worldRouter = require('./router/public/world.router')
const clansRouter = require('./router/public/clans.router')
const clanProvider = require('./model/clans/clanProvider.model')
const boot = require('./boot') const boot = require('./boot')
/* eslint-enable global-require */ /* eslint-enable global-require */
@@ -74,9 +76,33 @@ module.exports = function register(ctx, api) {
// is exactly the one that would have gone wrong. Check the list before you // 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). // choose (§2.4, and MODULE_SYSTEM.md §2.7's own note about the probe).
api.registerRoutes({ api.registerRoutes({
public: { '/world': worldRouter }, public: { '/world': worldRouter, '/clans': clansRouter },
}) })
// ── Teams: this module is the authoritative source of them ───────────────
//
// A Team is a CORE entity — core owns the tables, the reconciler, the access
// rules, the forum and the activity feed. What core does not own is the word for
// one, because this game says clan and the next will say company. So core asks
// this module three questions and never reads its tables (MODULE_API 1.6.0).
//
// **This is the first registration where core calls YOU and waits**, which is
// what makes it unlike every other line in this file: the others hand core a
// router to mount or a row to draw. Two consequences worth carrying:
//
// • **Registration is a claim, not a call.** Nothing in the provider runs
// until core reconciles, which is after `onBoot` — which is what makes it
// legal for every one of its methods to read the database while this
// function may not (§2.2).
// • **One provider per deployment.** Unlike every other registry this holds a
// single value: two modules answering "what Teams exist" would produce two
// disjoint sets under one table with no rule for merging them. A second
// registration is a collision, reported against the module that holds it.
//
// The whole object is passed rather than picking its members out, so adding the
// optional ones is an edit to the provider and not to this file.
api.registerTeamProvider(clanProvider)
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this // The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
// module's schema fragment, and BEFORE the HTTP listener binds — so a module // module's schema fragment, and BEFORE the HTTP listener binds — so a module
// that must not serve traffic until it has warmed a cache gets that for free. // that must not serve traffic until it has warmed a cache gets that for free.
@@ -93,6 +119,6 @@ module.exports = function register(ctx, api) {
log.info('registered', { log.info('registered', {
version: require('../module.json').version, version: require('../module.json').version,
routes: 'public:/world', routes: 'public:/world,/clans',
}) })
} }

View File

@@ -0,0 +1,85 @@
// ── 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],
)
}
}
module.exports = { listClans, findClan, listMembers, replaceClan, CLANS, MEMBERS }

View File

@@ -0,0 +1,303 @@
// ── 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}'
module.exports = {
getTeams,
getTeamMembers,
getTeamLeaders,
projectRoster,
rosterVisibleTo,
pageUrlTemplate,
gameIsReachable,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ function fakeCtx(overrides = {}) {
* an operator's install. * an operator's install.
*/ */
function fakeApi() { function fakeApi() {
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {} } const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null }
const called = new Set() const called = new Set()
const once = (name) => { const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`) if (called.has(name)) throw new Error(`${name}() called twice`)
@@ -97,6 +97,12 @@ function fakeApi() {
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams }, registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) }, registerAnnounceLeg(leg) { record.legs.push(leg) },
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook }, 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 },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
} }

View File

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

View File

@@ -71,6 +71,54 @@ test('registers both lifecycle hooks', () => {
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function') 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 modules route namespace')
})
test('the manifest declares what the loader requires', () => { test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/) assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/) assert.match(manifest.version, /^\d+\.\d+\.\d+/)

View File

@@ -1,5 +1,73 @@
{ {
"paths": { "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 games 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 games own id, with the roster as the game reported it. This is the modules 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 cores 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": { "/api/v1/public/world/status": {
"get": { "get": {
"tags": [ "tags": [
@@ -28,7 +96,7 @@
"tags": [ "tags": [
{ {
"name": "Public · Example Game", "name": "Public · Example Game",
"description": "Live world data, as last reported by the game server" "description": "Live world data and the games clans, as last reported by the game server"
} }
], ],
"components": { "components": {
@@ -127,6 +195,300 @@
} }
} }
} }
},
"ExamplegameClanList": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Every clan the game has reported (GET /public/clans)."
},
"properties": {
"type": "object",
"properties": {
"stale": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"clans": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/ExamplegameClanSummary"
}
}
}
}
}
}
},
"ExamplegameClanSummary": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "clan-1"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Gilded Company"
}
}
},
"abbr": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "GC"
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
}
}
}
}
},
"ExamplegameClan": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One clan and the roster this viewer may see (GET /public/clans/{externalId})."
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "clan-1"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Gilded Company"
}
}
},
"abbr": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "GC"
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
},
"projected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Was the audience rule answered? False means the roster was withheld because the question could not be resolved — which is a different thing from a clan with no members."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"members": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Deliberately carries no member key and no linked account id. Both exist and both go to core on the Team providers 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
}
}
}
}
}
}
} }
} }
} }